Files
wild-web-app/src/components/nodes/NodeForm.tsx
2025-11-09 00:59:36 +00:00

616 lines
22 KiB
TypeScript

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<NodeFormData>;
detection?: HardwareInfo;
onSubmit: (data: NodeFormData) => Promise<void>;
onApply?: (data: NodeFormData) => Promise<void>;
onCancel?: () => void;
submitLabel?: string;
showApplyButton?: boolean;
instanceName?: string;
}
function getInitialValues(
initial?: Partial<NodeFormData>,
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<NodeFormData>({
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<string | undefined>(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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
<div>
<Label htmlFor="role">Role</Label>
<Controller
name="role"
control={control}
rules={{ required: 'Role is required' }}
render={({ field }) => (
<Select value={field.value || ''} onValueChange={field.onChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="controlplane">Control Plane</SelectItem>
<SelectItem value="worker">Worker</SelectItem>
</SelectContent>
</Select>
)}
/>
{errors.role && <p className="text-sm text-red-600 mt-1">{errors.role.message}</p>}
</div>
<div>
<Label htmlFor="hostname">Hostname</Label>
<Input
id="hostname"
type="text"
{...register('hostname', {
required: 'Hostname is required',
pattern: {
value: /^[a-z0-9-]+$/,
message: 'Hostname must contain only lowercase letters, numbers, and hyphens',
},
})}
className="mt-1"
/>
{errors.hostname && (
<p className="text-sm text-red-600 mt-1">{errors.hostname.message}</p>
)}
{hostname && hostname.match(/^.*?(control|worker)-\d+$/) && (
<p className="mt-1 text-xs text-muted-foreground">
Auto-generated based on role and existing nodes
</p>
)}
</div>
<div>
<Label htmlFor="disk">Disk</Label>
{diskOptions.length > 0 ? (
<Controller
name="disk"
control={control}
rules={{ required: 'Disk is required' }}
render={({ field }) => {
// 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 (
<Select value={value} onValueChange={field.onChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select a disk" />
</SelectTrigger>
<SelectContent>
{diskOptions.map((disk) => (
<SelectItem key={disk.path} value={disk.path}>
{disk.path}
{disk.size > 0 && ` (${formatBytes(disk.size)})`}
</SelectItem>
))}
</SelectContent>
</Select>
);
}}
/>
) : (
<Controller
name="disk"
control={control}
rules={{ required: 'Disk is required' }}
render={({ field }) => (
<Input
id="disk"
type="text"
value={field.value || ''}
onChange={field.onChange}
className="mt-1"
placeholder="/dev/sda"
/>
)}
/>
)}
{errors.disk && <p className="text-sm text-red-600 mt-1">{errors.disk.message}</p>}
</div>
<div>
<Label htmlFor="targetIp">Target IP Address</Label>
<Input
id="targetIp"
type="text"
{...register('targetIp')}
className="mt-1"
/>
{errors.targetIp && (
<p className="text-sm text-red-600 mt-1">{errors.targetIp.message}</p>
)}
{role === 'controlplane' && (instanceConfig?.cluster as any)?.nodes?.control?.vip && (
<p className="mt-1 text-xs text-muted-foreground">
Auto-calculated from VIP ({(instanceConfig?.cluster as any)?.nodes?.control?.vip})
</p>
)}
</div>
<div>
<Label htmlFor="interface">Network Interface</Label>
{interfaceOptions.length > 0 ? (
<Controller
name="interface"
control={control}
render={({ field }) => {
// 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 (
<Select value={value} onValueChange={field.onChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select interface..." />
</SelectTrigger>
<SelectContent>
{interfaceOptions.map((iface) => (
<SelectItem key={iface} value={iface}>
{iface}
</SelectItem>
))}
</SelectContent>
</Select>
);
}}
/>
) : (
<Controller
name="interface"
control={control}
render={({ field }) => (
<Input
id="interface"
type="text"
value={field.value || ''}
onChange={field.onChange}
className="mt-1"
placeholder="eth0"
/>
)}
/>
)}
</div>
<div>
<Label htmlFor="schematicId">Schematic ID (Optional)</Label>
<Input
id="schematicId"
type="text"
{...register('schematicId')}
className="mt-1 font-mono text-xs"
placeholder="abc123def456..."
/>
<p className="mt-1 text-xs text-muted-foreground">
Leave blank to use default Talos configuration
</p>
</div>
<div className="flex gap-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={() => {
reset();
onCancel();
}}
disabled={isSubmitting}
>
Cancel
</Button>
)}
{showApplyButton && onApply ? (
<Button
type="button"
onClick={handleSubmit(onApply)}
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
</Button>
) : (
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? 'Saving...' : submitLabel}
</Button>
)}
</div>
</form>
);
}
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]}`;
}