import { useForm, Controller } from 'react-hook-form'; import { useEffect, useRef } from 'react'; import { useInstanceConfig } from '../../hooks/useInstances'; import { useNodes } from '../../hooks/useNodes'; import type { HardwareInfo } from '../../services/api/types'; import { Input, Label, Button } from '../ui'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../ui/select'; export interface NodeFormData { hostname: string; role: 'controlplane' | 'worker'; disk: string; targetIp: string; interface?: string; schematicId?: string; maintenance: boolean; } interface NodeFormProps { initialValues?: Partial; detection?: HardwareInfo; onSubmit: (data: NodeFormData) => Promise; onApply?: (data: NodeFormData) => Promise; onCancel?: () => void; submitLabel?: string; showApplyButton?: boolean; instanceName?: string; } function getInitialValues( initial?: Partial, detection?: HardwareInfo, nodes?: Array<{ role: string; hostname?: string }>, hostnamePrefix?: string ): NodeFormData { // Determine default role: controlplane unless there are already 3+ control nodes let defaultRole: 'controlplane' | 'worker' = 'controlplane'; if (nodes) { const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length; if (controlPlaneCount >= 3) { defaultRole = 'worker'; } } const role = initial?.role || defaultRole; // Generate default hostname based on role and existing nodes let defaultHostname = ''; if (!initial?.hostname) { const prefix = hostnamePrefix || ''; // Generate a hostname even if nodes is not loaded yet // The useEffect will fix it later when data is available if (role === 'controlplane') { if (nodes) { // Find next control plane number const controlNumbers = nodes .filter(n => n.role === 'controlplane') .map(n => { const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`)); return match ? parseInt(match[1], 10) : null; }) .filter((n): n is number => n !== null); const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1; defaultHostname = `${prefix}control-${nextNumber}`; } else { // No nodes loaded yet, default to 1 defaultHostname = `${prefix}control-1`; } } else { if (nodes) { // Find next worker number const workerNumbers = nodes .filter(n => n.role === 'worker') .map(n => { const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`)); return match ? parseInt(match[1], 10) : null; }) .filter((n): n is number => n !== null); const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1; defaultHostname = `${prefix}worker-${nextNumber}`; } else { // No nodes loaded yet, default to 1 defaultHostname = `${prefix}worker-1`; } } } // Auto-select first disk if none specified let defaultDisk = initial?.disk || detection?.selected_disk || ''; if (!defaultDisk && detection?.disks && detection.disks.length > 0) { defaultDisk = detection.disks[0].path; } // Auto-select first interface if none specified let defaultInterface = initial?.interface || detection?.interface || ''; if (!defaultInterface && detection?.interfaces && detection.interfaces.length > 0) { defaultInterface = detection.interfaces[0]; } return { hostname: initial?.hostname || defaultHostname, role, disk: defaultDisk, targetIp: initial?.targetIp || detection?.ip || '', // Auto-fill from detection interface: defaultInterface, schematicId: initial?.schematicId || '', maintenance: initial?.maintenance ?? true, }; } export function NodeForm({ initialValues, detection, onSubmit, onApply, onCancel, submitLabel = 'Save', showApplyButton = false, instanceName, }: NodeFormProps) { // Track if we're editing an existing node (has initial hostname from backend) const isExistingNode = Boolean(initialValues?.hostname); const { config: instanceConfig } = useInstanceConfig(instanceName); const { nodes } = useNodes(instanceName); const hostnamePrefix = instanceConfig?.cluster?.hostnamePrefix || ''; const { register, handleSubmit, setValue, watch, control, reset, formState: { errors, isSubmitting }, } = useForm({ defaultValues: getInitialValues(initialValues, detection, nodes, hostnamePrefix), }); const schematicId = watch('schematicId'); const role = watch('role'); const hostname = watch('hostname'); // Reset form when switching between different nodes in configure mode // This ensures select boxes and all fields show the current values // Use refs to track both the hostname and mode to avoid unnecessary resets const prevHostnameRef = useRef(undefined); const prevModeRef = useRef<'add' | 'configure' | undefined>(undefined); useEffect(() => { const currentHostname = initialValues?.hostname; const currentMode = initialValues?.hostname ? 'configure' : 'add'; // Only reset if we're actually switching between different nodes in configure mode // or switching from add to configure mode (or vice versa) const modeChanged = currentMode !== prevModeRef.current; const hostnameChanged = currentMode === 'configure' && currentHostname !== prevHostnameRef.current; if (modeChanged || hostnameChanged) { prevHostnameRef.current = currentHostname; prevModeRef.current = currentMode; const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix); reset(newValues); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialValues, detection, nodes, hostnamePrefix]); // Set default role based on existing control plane nodes useEffect(() => { if (!initialValues?.role && nodes) { const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length; const defaultRole: 'controlplane' | 'worker' = controlPlaneCount >= 3 ? 'worker' : 'controlplane'; const currentRole = watch('role'); // Only update if the current role is still the initial default and we now have node data if (currentRole === 'controlplane' && controlPlaneCount >= 3) { setValue('role', defaultRole); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodes, initialValues?.role]); // Pre-populate schematic ID from cluster config if available useEffect(() => { if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) { setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [instanceConfig, schematicId]); // Auto-generate hostname when role changes (only for NEW nodes without initial hostname) useEffect(() => { if (!nodes) return; // Don't auto-generate if this is an existing node with initial hostname // This check must happen FIRST to prevent regeneration when hostnamePrefix loads if (isExistingNode) return; const prefix = hostnamePrefix || ''; const currentHostname = watch('hostname'); if (!currentHostname) return; // Check if current hostname follows our naming pattern WITH prefix const hostnameMatch = currentHostname.match(new RegExp(`^${prefix}(control|worker)-(\\d+)$`)); // If no match with prefix, check if it matches WITHOUT prefix (generated before prefix was loaded) const hostnameMatchNoPrefix = !hostnameMatch && prefix ? currentHostname.match(/^(control|worker)-(\d+)$/) : null; // Check if this is a generated hostname (either with or without prefix) const isGeneratedHostname = hostnameMatch !== null || hostnameMatchNoPrefix !== null; // Use whichever match succeeded const activeMatch = hostnameMatch || hostnameMatchNoPrefix; // Check if the role prefix in the hostname matches the current role const hostnameRolePrefix = activeMatch?.[1]; // 'control' or 'worker' const expectedRolePrefix = role === 'controlplane' ? 'control' : 'worker'; const roleMatches = hostnameRolePrefix === expectedRolePrefix; // Check if the hostname has the expected prefix const hasCorrectPrefix = hostnameMatch !== null; // Auto-update hostname if it was previously auto-generated AND either: // 1. The role prefix doesn't match (e.g., hostname is "control-1" but role is "worker") // 2. The hostname is missing the prefix (e.g., "control-1" instead of "test-control-1") // 3. The number needs updating (existing logic) if (isGeneratedHostname && (!roleMatches || !hasCorrectPrefix)) { // Role changed, need to regenerate with correct prefix if (role === 'controlplane') { const controlNumbers = nodes .filter(n => n.role === 'controlplane') .map(n => { const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`)); return match ? parseInt(match[1], 10) : null; }) .filter((n): n is number => n !== null); const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1; const newHostname = `${prefix}control-${nextNumber}`; setValue('hostname', newHostname); } else { const workerNumbers = nodes .filter(n => n.role === 'worker') .map(n => { const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`)); return match ? parseInt(match[1], 10) : null; }) .filter((n): n is number => n !== null); const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1; const newHostname = `${prefix}worker-${nextNumber}`; setValue('hostname', newHostname); } } else if (isGeneratedHostname && roleMatches && hasCorrectPrefix) { // Role matches and prefix is correct, but check if the number needs updating (original logic) if (role === 'controlplane') { const controlNumbers = nodes .filter(n => n.role === 'controlplane') .map(n => { const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`)); return match ? parseInt(match[1], 10) : null; }) .filter((n): n is number => n !== null); const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1; const newHostname = `${prefix}control-${nextNumber}`; if (currentHostname !== newHostname) { setValue('hostname', newHostname); } } else { const workerNumbers = nodes .filter(n => n.role === 'worker') .map(n => { const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`)); return match ? parseInt(match[1], 10) : null; }) .filter((n): n is number => n !== null); const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1; const newHostname = `${prefix}worker-${nextNumber}`; if (currentHostname !== newHostname) { setValue('hostname', newHostname); } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [role, nodes, hostnamePrefix, isExistingNode]); // Auto-calculate target IP for control plane nodes useEffect(() => { // Skip if this is an existing node (configure mode) if (initialValues?.targetIp) return; // Skip if there's a detection IP (hardware detection provides the actual IP) if (detection?.ip) return; // Skip if there's already a targetIp from detection const currentTargetIp = watch('targetIp'); if (currentTargetIp && role === 'worker') return; // For workers, keep any existing value const clusterConfig = instanceConfig?.cluster as any; const vip = clusterConfig?.nodes?.control?.vip as string | undefined; if (role === 'controlplane' && vip) { // Parse VIP to get base and last octet const vipParts = vip.split('.'); if (vipParts.length !== 4) return; const vipLastOctet = parseInt(vipParts[3], 10); if (isNaN(vipLastOctet)) return; const vipPrefix = vipParts.slice(0, 3).join('.'); // Find all control plane IPs in the same subnet range const usedOctets = nodes .filter(node => node.role === 'controlplane' && node.target_ip) .map(node => { const parts = node.target_ip.split('.'); if (parts.length !== 4) return null; // Only consider IPs in the same subnet if (parts.slice(0, 3).join('.') !== vipPrefix) return null; const octet = parseInt(parts[3], 10); return isNaN(octet) ? null : octet; }) .filter((octet): octet is number => octet !== null && octet > vipLastOctet); // Find the first available IP after VIP let nextOctet = vipLastOctet + 1; // Sort used octets to find gaps const sortedOctets = [...usedOctets].sort((a, b) => a - b); // Check for gaps in the sequence starting from VIP+1 for (const usedOctet of sortedOctets) { if (usedOctet === nextOctet) { nextOctet++; } else if (usedOctet > nextOctet) { // Found a gap, use it break; } } // Ensure we don't exceed valid IP range if (nextOctet > 254) { console.warn('No available IPs in subnet after VIP'); return; } // Set the calculated IP setValue('targetIp', `${vipPrefix}.${nextOctet}`); } else if (role === 'worker' && !detection?.ip) { // For worker nodes without detection, only clear if it looks like an auto-calculated control plane IP const currentTargetIp = watch('targetIp'); // Only clear if it looks like an auto-calculated IP (matches VIP pattern) if (currentTargetIp && vip) { const vipPrefix = vip.split('.').slice(0, 3).join('.'); if (currentTargetIp.startsWith(vipPrefix)) { setValue('targetIp', ''); } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [role, instanceConfig, nodes, initialValues?.targetIp, detection?.ip]); // Build disk options from both detection and initial values const diskOptions = (() => { const options = [...(detection?.disks || [])]; // If configuring existing node, ensure its disk is in options if (initialValues?.disk && !options.some(d => d.path === initialValues.disk)) { options.push({ path: initialValues.disk, size: 0 }); } return options; })(); // Build interface options from both detection and initial values const interfaceOptions = (() => { const options = [...(detection?.interfaces || [])]; // If configuring existing node, ensure its interface is in options if (initialValues?.interface && !options.includes(initialValues.interface)) { options.push(initialValues.interface); } // Also add detection.interface if present if (detection?.interface && !options.includes(detection.interface)) { options.push(detection.interface); } return options; })(); return (
( )} /> {errors.role &&

{errors.role.message}

}
{errors.hostname && (

{errors.hostname.message}

)} {hostname && hostname.match(/^.*?(control|worker)-\d+$/) && (

Auto-generated based on role and existing nodes

)}
{diskOptions.length > 0 ? ( { // Ensure we have a value - use the field value or fall back to first option const value = field.value || (diskOptions.length > 0 ? diskOptions[0].path : ''); return ( ); }} /> ) : ( ( )} /> )} {errors.disk &&

{errors.disk.message}

}
{errors.targetIp && (

{errors.targetIp.message}

)} {role === 'controlplane' && (instanceConfig?.cluster as any)?.nodes?.control?.vip && (

Auto-calculated from VIP ({(instanceConfig?.cluster as any)?.nodes?.control?.vip})

)}
{interfaceOptions.length > 0 ? ( { // Ensure we have a value - use the field value or fall back to first option const value = field.value || (interfaceOptions.length > 0 ? interfaceOptions[0] : ''); return ( ); }} /> ) : ( ( )} /> )}

Leave blank to use default Talos configuration

{onCancel && ( )} {showApplyButton && onApply ? ( ) : ( )}
); } function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }