'use client'; import { useState, useEffect } from 'react'; import { api } from '~/trpc/react'; import type { Script } from '~/types/script'; import type { Server } from '~/types/server'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { useRegisterModal } from './modal/ModalStackProvider'; export type EnvVars = Record; interface ConfigurationModalProps { isOpen: boolean; onClose: () => void; onConfirm: (envVars: EnvVars) => void; script: Script | null; server: Server | null; mode: 'default' | 'advanced'; } export function ConfigurationModal({ isOpen, onClose, onConfirm, script, server, mode, }: ConfigurationModalProps) { useRegisterModal(isOpen, { id: 'configuration-modal', allowEscape: true, onClose }); // Fetch script data if we only have slug const { data: scriptData } = api.scripts.getScriptBySlug.useQuery( { slug: script?.slug ?? '' }, { enabled: !!script?.slug && isOpen } ); const actualScript = script ?? (scriptData?.script ?? null); // Fetch storages const { data: rootfsStoragesData } = api.scripts.getRootfsStorages.useQuery( { serverId: server?.id ?? 0, forceRefresh: false }, { enabled: !!server?.id && isOpen } ); const { data: templateStoragesData } = api.scripts.getTemplateStorages.useQuery( { serverId: server?.id ?? 0, forceRefresh: false }, { enabled: !!server?.id && isOpen && mode === 'advanced' } ); // Get resources from JSON const resources = actualScript?.install_methods?.[0]?.resources; const slug = actualScript?.slug ?? ''; // Default mode state const [containerStorage, setContainerStorage] = useState(''); // Advanced mode state const [advancedVars, setAdvancedVars] = useState({}); // Discovered SSH keys on the Proxmox host (advanced mode only) const [discoveredSshKeys, setDiscoveredSshKeys] = useState([]); const [discoveredSshKeysLoading, setDiscoveredSshKeysLoading] = useState(false); const [discoveredSshKeysError, setDiscoveredSshKeysError] = useState(null); // Validation errors const [errors, setErrors] = useState>({}); // Initialize defaults when script/server data is available useEffect(() => { if (!actualScript || !server) return; if (mode === 'default') { // Default mode: minimal vars setContainerStorage(''); } else { // Advanced mode: all vars with defaults const defaults: EnvVars = { // Resources from JSON var_cpu: resources?.cpu ?? 1, var_ram: resources?.ram ?? 1024, var_disk: resources?.hdd ?? 4, var_unprivileged: script?.privileged === false ? 1 : (script?.privileged === true ? 0 : 1), // Network defaults var_net: 'dhcp', var_brg: 'vmbr0', var_gateway: '', var_ipv6_method: 'none', var_ipv6_static: '', var_vlan: '', var_mtu: 1500, var_mac: '', var_ns: '', // Identity var_hostname: slug, var_pw: '', var_tags: 'community-script', // SSH var_ssh: 'no', var_ssh_authorized_key: '', // Features var_nesting: 1, var_fuse: 0, var_keyctl: 0, var_mknod: 0, var_mount_fs: '', var_protection: 'no', // System var_timezone: '', var_verbose: 'no', var_apt_cacher: 'no', var_apt_cacher_ip: '', // Storage var_container_storage: '', var_template_storage: '', }; setAdvancedVars(defaults); } }, [actualScript, server, mode, resources, slug]); // Discover SSH keys on the Proxmox host when advanced mode is open useEffect(() => { if (!server?.id || !isOpen || mode !== 'advanced') { setDiscoveredSshKeys([]); setDiscoveredSshKeysError(null); return; } let cancelled = false; setDiscoveredSshKeysLoading(true); setDiscoveredSshKeysError(null); fetch(`/api/servers/${server.id}/discover-ssh-keys`) .then((res) => { if (!res.ok) throw new Error(res.status === 404 ? 'Server not found' : res.statusText); return res.json(); }) .then((data: { keys?: string[] }) => { if (!cancelled && Array.isArray(data.keys)) setDiscoveredSshKeys(data.keys); }) .catch((err) => { if (!cancelled) { setDiscoveredSshKeys([]); setDiscoveredSshKeysError(err instanceof Error ? err.message : 'Could not detect keys'); } }) .finally(() => { if (!cancelled) setDiscoveredSshKeysLoading(false); }); return () => { cancelled = true; }; }, [server?.id, isOpen, mode]); // Validation functions const validateIPv4 = (ip: string): boolean => { if (!ip) return true; // Empty is allowed (auto) const pattern = /^(\d{1,3}\.){3}\d{1,3}$/; if (!pattern.test(ip)) return false; const parts = ip.split('.').map(Number); return parts.every(p => p >= 0 && p <= 255); }; const validateCIDR = (cidr: string): boolean => { if (!cidr) return true; // Empty is allowed const pattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/; if (!pattern.test(cidr)) return false; const parts = cidr.split('/'); if (parts.length !== 2) return false; const [ip, prefix] = parts; if (!ip || !prefix) return false; const ipParts = ip.split('.').map(Number); if (!ipParts.every(p => p >= 0 && p <= 255)) return false; const prefixNum = parseInt(prefix, 10); return prefixNum >= 0 && prefixNum <= 32; }; const validateIPv6 = (ipv6: string): boolean => { if (!ipv6) return true; // Empty is allowed // Basic IPv6 validation (simplified - allows compressed format) const pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/; return pattern.test(ipv6); }; const validateMAC = (mac: string): boolean => { if (!mac) return true; // Empty is allowed (auto) const pattern = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/; return pattern.test(mac); }; const validatePositiveInt = (value: string | number | undefined): boolean => { if (value === '' || value === undefined) return true; const num = typeof value === 'string' ? parseInt(value, 10) : value; return !isNaN(num) && num > 0; }; const validateForm = (): boolean => { const newErrors: Record = {}; if (mode === 'default') { // Default mode: only storage is optional // No validation needed } else { // Advanced mode: validate all fields if (advancedVars.var_gateway && !validateIPv4(advancedVars.var_gateway as string)) { newErrors.var_gateway = 'Invalid IPv4 address'; } if (advancedVars.var_mac && !validateMAC(advancedVars.var_mac as string)) { newErrors.var_mac = 'Invalid MAC address format (XX:XX:XX:XX:XX:XX)'; } if (advancedVars.var_ns && !validateIPv4(advancedVars.var_ns as string)) { newErrors.var_ns = 'Invalid IPv4 address'; } if (advancedVars.var_apt_cacher_ip && !validateIPv4(advancedVars.var_apt_cacher_ip as string)) { newErrors.var_apt_cacher_ip = 'Invalid IPv4 address'; } // Validate IPv4 CIDR if network mode is static const netValue = advancedVars.var_net; const isStaticMode = netValue === 'static' || (typeof netValue === 'string' && netValue.includes('/')); if (isStaticMode) { const cidrValue = (typeof netValue === 'string' && netValue.includes('/')) ? netValue : (advancedVars.var_ip as string ?? ''); if (cidrValue && !validateCIDR(cidrValue)) { newErrors.var_ip = 'Invalid CIDR format (e.g., 10.10.10.1/24)'; } } // Validate IPv6 static if IPv6 method is static if (advancedVars.var_ipv6_method === 'static' && advancedVars.var_ipv6_static) { if (!validateIPv6(advancedVars.var_ipv6_static as string)) { newErrors.var_ipv6_static = 'Invalid IPv6 address'; } } if (!validatePositiveInt(advancedVars.var_cpu as string | number | undefined)) { newErrors.var_cpu = 'Must be a positive integer'; } if (!validatePositiveInt(advancedVars.var_ram as string | number | undefined)) { newErrors.var_ram = 'Must be a positive integer'; } if (!validatePositiveInt(advancedVars.var_disk as string | number | undefined)) { newErrors.var_disk = 'Must be a positive integer'; } if (advancedVars.var_mtu && !validatePositiveInt(advancedVars.var_mtu as string | number | undefined)) { newErrors.var_mtu = 'Must be a positive integer'; } if (advancedVars.var_vlan && !validatePositiveInt(advancedVars.var_vlan as string | number | undefined)) { newErrors.var_vlan = 'Must be a positive integer'; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleConfirm = () => { if (!validateForm()) { return; } let envVars: EnvVars = {}; if (mode === 'default') { // Default mode: minimal vars envVars = { var_hostname: slug, var_brg: 'vmbr0', var_net: 'dhcp', var_ipv6_method: 'auto', var_ssh: 'no', var_nesting: 1, var_verbose: 'no', var_cpu: resources?.cpu ?? 1, var_ram: resources?.ram ?? 1024, var_disk: resources?.hdd ?? 4, var_unprivileged: script?.privileged === false ? 1 : (script?.privileged === true ? 0 : 1), }; if (containerStorage) { envVars.var_container_storage = containerStorage; } } else { // Advanced mode: all vars envVars = { ...advancedVars }; // If network mode is static and var_ip is set, replace var_net with the CIDR if (envVars.var_net === 'static' && envVars.var_ip) { envVars.var_net = envVars.var_ip as string; delete envVars.var_ip; // Remove the temporary var_ip } // Format password correctly: if var_pw is set, format it as "-password " // build.func expects PW to be in "-password " format when added to PCT_OPTIONS const rawPassword = envVars.var_pw; const hasPassword = rawPassword && typeof rawPassword === 'string' && rawPassword.trim() !== ''; const hasSSHKey = envVars.var_ssh_authorized_key && typeof envVars.var_ssh_authorized_key === 'string' && envVars.var_ssh_authorized_key.trim() !== ''; if (hasPassword) { // Remove any existing "-password" prefix to avoid double-formatting const cleanPassword = rawPassword.startsWith('-password ') ? rawPassword.substring(11) : rawPassword; // Format as "-password " for build.func envVars.var_pw = `-password ${cleanPassword}`; } else { // Empty password means auto-login, clear var_pw envVars.var_pw = ''; } if ((hasPassword || hasSSHKey) && envVars.var_ssh !== 'no') { envVars.var_ssh = 'yes'; } // Normalize var_tags: accept both comma and semicolon, output comma-separated const rawTags = envVars.var_tags; if (typeof rawTags === 'string' && rawTags.trim() !== '') { envVars.var_tags = rawTags .split(/[,;]/) .map((s) => s.trim()) .filter(Boolean) .join(','); } } // Remove empty string values (but keep 0, false, etc.) const cleaned: EnvVars = {}; for (const [key, value] of Object.entries(envVars)) { if (value !== '' && value !== undefined) { cleaned[key] = value; } } // Always set mode to "default" (build.func line 1783 expects this) cleaned.mode = 'default'; onConfirm(cleaned); }; const updateAdvancedVar = (key: string, value: string | number | boolean) => { setAdvancedVars(prev => ({ ...prev, [key]: value })); // Clear error for this field if (errors[key]) { setErrors(prev => { const newErrors = { ...prev }; delete newErrors[key]; return newErrors; }); } }; if (!isOpen) return null; const rootfsStorages = rootfsStoragesData?.storages ?? []; const templateStorages = templateStoragesData?.storages ?? []; return (
{/* Header */}

{mode === 'default' ? 'Default Configuration' : 'Advanced Configuration'}

{/* Content */}
{mode === 'default' ? ( /* Default Mode */
{rootfsStorages.length === 0 && (

Could not fetch storages. Script will use default selection.

)}

Default Values

Hostname: {slug}

Bridge: vmbr0

Network: DHCP

IPv6: Auto

SSH: Disabled

Nesting: Enabled

CPU: {resources?.cpu ?? 1}

RAM: {resources?.ram ?? 1024} MB

Disk: {resources?.hdd ?? 4} GB

) : ( /* Advanced Mode */
{/* Resources */}

Resources

updateAdvancedVar('var_cpu', parseInt(e.target.value) || 1)} className={errors.var_cpu ? 'border-destructive' : ''} /> {errors.var_cpu && (

{errors.var_cpu}

)}
updateAdvancedVar('var_ram', parseInt(e.target.value) || 1024)} className={errors.var_ram ? 'border-destructive' : ''} /> {errors.var_ram && (

{errors.var_ram}

)}
updateAdvancedVar('var_disk', parseInt(e.target.value) || 4)} className={errors.var_disk ? 'border-destructive' : ''} /> {errors.var_disk && (

{errors.var_disk}

)}
{/* Network */}

Network

{(advancedVars.var_net === 'static' || (typeof advancedVars.var_net === 'string' && advancedVars.var_net.includes('/'))) && (
{ // Store in var_ip temporarily, will be moved to var_net on confirm updateAdvancedVar('var_ip', e.target.value); }} placeholder="10.10.10.1/24" className={errors.var_ip ? 'border-destructive' : ''} /> {errors.var_ip && (

{errors.var_ip}

)}
)}
updateAdvancedVar('var_brg', e.target.value)} placeholder="vmbr0" />
updateAdvancedVar('var_gateway', e.target.value)} placeholder="Auto" className={errors.var_gateway ? 'border-destructive' : ''} /> {errors.var_gateway && (

{errors.var_gateway}

)}
{advancedVars.var_ipv6_method === 'static' && (
updateAdvancedVar('var_ipv6_static', e.target.value)} placeholder="2001:db8::1/64" className={errors.var_ipv6_static ? 'border-destructive' : ''} /> {errors.var_ipv6_static && (

{errors.var_ipv6_static}

)}
)}
updateAdvancedVar('var_vlan', e.target.value ? parseInt(e.target.value) : '')} placeholder="None" className={errors.var_vlan ? 'border-destructive' : ''} /> {errors.var_vlan && (

{errors.var_vlan}

)}
updateAdvancedVar('var_mtu', e.target.value ? parseInt(e.target.value) : 1500)} placeholder="1500" className={errors.var_mtu ? 'border-destructive' : ''} /> {errors.var_mtu && (

{errors.var_mtu}

)}
updateAdvancedVar('var_mac', e.target.value)} placeholder="Auto" className={errors.var_mac ? 'border-destructive' : ''} /> {errors.var_mac && (

{errors.var_mac}

)}
updateAdvancedVar('var_ns', e.target.value)} placeholder="Auto" className={errors.var_ns ? 'border-destructive' : ''} /> {errors.var_ns && (

{errors.var_ns}

)}
{/* Identity & Metadata */}

Identity & Metadata

updateAdvancedVar('var_hostname', e.target.value)} placeholder={slug} />
updateAdvancedVar('var_pw', e.target.value)} placeholder="Random (empty = auto-login)" />
updateAdvancedVar('var_tags', e.target.value)} placeholder="e.g. tag1; tag2" />
{/* SSH Access */}

SSH Access

{discoveredSshKeysLoading && (

Detecting SSH keys...

)} {discoveredSshKeysError && !discoveredSshKeysLoading && (

Could not detect keys on host

)} {discoveredSshKeys.length > 0 && !discoveredSshKeysLoading && (
)} updateAdvancedVar('var_ssh_authorized_key', e.target.value)} placeholder="Or paste a public key: ssh-rsa AAAA..." />
{/* Container Features */}

Container Features

updateAdvancedVar('var_mount_fs', e.target.value)} placeholder="nfs,cifs" />
{/* System Configuration */}

System Configuration

updateAdvancedVar('var_timezone', e.target.value)} placeholder="System" />
updateAdvancedVar('var_apt_cacher_ip', e.target.value)} placeholder="192.168.1.10" className={errors.var_apt_cacher_ip ? 'border-destructive' : ''} /> {errors.var_apt_cacher_ip && (

{errors.var_apt_cacher_ip}

)}
{/* Storage Selection */}

Storage Selection

{rootfsStorages.length === 0 && (

Could not fetch storages. Leave empty for auto selection.

)}
{templateStorages.length === 0 && (

Could not fetch storages. Leave empty for auto selection.

)}
)} {/* Action Buttons */}
); }