Makes cluster-nodes functional.

This commit is contained in:
2025-11-04 16:44:11 +00:00
parent 6f438901e0
commit 2469acbc88
34 changed files with 4441 additions and 192 deletions

View File

@@ -0,0 +1,90 @@
import type { HardwareInfo } from '../../services/api/types';
interface HardwareDetectionDisplayProps {
detection: HardwareInfo;
}
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]}`;
}
export function HardwareDetectionDisplay({ detection }: HardwareDetectionDisplayProps) {
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 space-y-3">
<div className="flex items-start">
<svg
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">IP Address</p>
<p className="text-sm text-gray-900 dark:text-gray-100 font-mono">{detection.ip}</p>
</div>
</div>
{detection.interface && (
<div className="flex items-start">
<svg
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Network Interface</p>
<p className="text-sm text-gray-900 dark:text-gray-100 font-mono">{detection.interface}</p>
</div>
</div>
)}
{detection.disks && detection.disks.length > 0 && (
<div className="flex items-start">
<svg
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Available Disks</p>
<ul className="mt-1 space-y-1">
{detection.disks.map((disk) => (
<li key={disk.path} className="text-sm text-gray-900 dark:text-gray-100">
<span className="font-mono">{disk.path}</span>
{disk.size > 0 && (
<span className="text-gray-500 dark:text-gray-400 ml-2">
({formatBytes(disk.size)})
</span>
)}
</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,605 @@
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;
currentIp?: string;
interface?: string;
schematicId?: string;
maintenance: boolean;
}
interface NodeFormProps {
initialValues?: Partial<NodeFormData>;
detection?: HardwareInfo;
onSubmit: (data: NodeFormData) => Promise<void>;
onApply?: (data: NodeFormData) => Promise<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 || '', // Don't auto-fill targetIp from detection
currentIp: initial?.currentIp || detection?.ip || '', // Auto-fill from detection
interface: defaultInterface,
schematicId: initial?.schematicId || '',
maintenance: initial?.maintenance ?? true,
};
}
export function NodeForm({
initialValues,
detection,
onSubmit,
onApply,
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 initialValues change (e.g., switching to configure a different node)
// This ensures select boxes and all fields show the current values
// Use a ref to track the hostname to avoid infinite loops from object reference changes
const prevHostnameRef = useRef<string | undefined>(undefined);
useEffect(() => {
const currentHostname = initialValues?.hostname;
// Only reset if the hostname actually changed (switching between nodes)
if (currentHostname !== prevHostnameRef.current) {
prevHostnameRef.current = currentHostname;
const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix);
reset(newValues);
}
}, [initialValues, detection, nodes, hostnamePrefix, reset]);
// 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);
}
}
}, [nodes, initialValues?.role, setValue, watch]);
// Pre-populate schematic ID from cluster config if available
useEffect(() => {
if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) {
setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId);
}
}, [instanceConfig, schematicId, setValue]);
// 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);
}
}
}
}, [role, nodes, hostnamePrefix, setValue, watch, isExistingNode]);
// Auto-calculate target IP for control plane nodes
useEffect(() => {
// Skip if this is an existing node (configure mode)
if (initialValues?.targetIp) return;
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') {
// For new worker nodes, clear target IP (let user set if needed)
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', '');
}
}
}
}, [role, instanceConfig, nodes, setValue, watch, initialValues?.targetIp]);
// 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 }) => (
<Select value={field.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="currentIp">Current IP Address</Label>
<Input
id="currentIp"
type="text"
{...register('currentIp')}
className="mt-1"
disabled={!!detection?.ip}
/>
{errors.currentIp && (
<p className="text-sm text-red-600 mt-1">{errors.currentIp.message}</p>
)}
{detection?.ip && (
<p className="mt-1 text-xs text-muted-foreground">
Auto-detected from hardware (read-only)
</p>
)}
</div>
<div>
<Label htmlFor="interface">Network Interface</Label>
{interfaceOptions.length > 0 ? (
<Controller
name="interface"
control={control}
render={({ field }) => (
<Select value={field.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 items-center gap-2">
<input
id="maintenance"
type="checkbox"
{...register('maintenance')}
className="h-4 w-4 rounded border-input"
/>
<Label htmlFor="maintenance" className="font-normal">
Start in maintenance mode
</Label>
</div>
<div className="flex gap-2">
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? 'Saving...' : submitLabel}
</Button>
{showApplyButton && onApply && (
<Button
type="button"
onClick={handleSubmit(onApply)}
disabled={isSubmitting}
variant="secondary"
className="flex-1"
>
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
</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]}`;
}

View File

@@ -0,0 +1,392 @@
import { describe, it, expect } from 'vitest';
import type { NodeFormData } from './NodeForm';
import { createMockNode, createMockNodes, createMockHardwareInfo } from '../../test/utils/nodeFormTestUtils';
import type { HardwareInfo } from '../../services/api/types';
function getInitialValues(
initial?: Partial<NodeFormData>,
detection?: HardwareInfo,
nodes?: Array<{ role: string; hostname?: string }>,
hostnamePrefix?: string
): NodeFormData {
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;
let defaultHostname = '';
if (!initial?.hostname && nodes && hostnamePrefix !== undefined) {
const prefix = hostnamePrefix || '';
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;
defaultHostname = `${prefix}control-${nextNumber}`;
} 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;
defaultHostname = `${prefix}worker-${nextNumber}`;
}
}
let defaultDisk = initial?.disk || detection?.selected_disk || '';
if (!defaultDisk && detection?.disks && detection.disks.length > 0) {
defaultDisk = detection.disks[0].path;
}
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 || '',
currentIp: initial?.currentIp || detection?.ip || '',
interface: defaultInterface,
schematicId: initial?.schematicId || '',
maintenance: initial?.maintenance ?? true,
};
}
describe('getInitialValues', () => {
describe('Role Selection', () => {
it('defaults to controlplane when no nodes exist', () => {
const result = getInitialValues(undefined, undefined, [], 'test-');
expect(result.role).toBe('controlplane');
});
it('defaults to controlplane when fewer than 3 control nodes exist', () => {
const nodes = createMockNodes(2, 'controlplane');
const result = getInitialValues(undefined, undefined, nodes, 'test-');
expect(result.role).toBe('controlplane');
});
it('defaults to worker when 3 or more control nodes exist', () => {
const nodes = createMockNodes(3, 'controlplane');
const result = getInitialValues(undefined, undefined, nodes, 'test-');
expect(result.role).toBe('worker');
});
it('respects explicit role in initial values', () => {
const nodes = createMockNodes(3, 'controlplane');
const result = getInitialValues({ role: 'controlplane' }, undefined, nodes, 'test-');
expect(result.role).toBe('controlplane');
});
});
describe('Hostname Generation', () => {
it('generates first control node hostname', () => {
const result = getInitialValues(undefined, undefined, [], 'test-');
expect(result.hostname).toBe('test-control-1');
});
it('generates second control node hostname', () => {
const nodes = [createMockNode({ hostname: 'test-control-1', role: 'controlplane' })];
const result = getInitialValues(undefined, undefined, nodes, 'test-');
expect(result.hostname).toBe('test-control-2');
});
it('generates third control node hostname', () => {
const nodes = [
createMockNode({ hostname: 'test-control-1', role: 'controlplane' }),
createMockNode({ hostname: 'test-control-2', role: 'controlplane' }),
];
const result = getInitialValues(undefined, undefined, nodes, 'test-');
expect(result.hostname).toBe('test-control-3');
});
it('generates first worker node hostname', () => {
const nodes = createMockNodes(3, 'controlplane');
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
expect(result.hostname).toBe('test-worker-1');
});
it('generates second worker node hostname', () => {
const nodes = [
...createMockNodes(3, 'controlplane'),
createMockNode({ hostname: 'test-worker-1', role: 'worker' }),
];
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
expect(result.hostname).toBe('test-worker-2');
});
it('handles empty hostname prefix', () => {
const result = getInitialValues(undefined, undefined, [], '');
expect(result.hostname).toBe('control-1');
});
it('handles gaps in hostname numbering for control nodes', () => {
const nodes = [
createMockNode({ hostname: 'test-control-1', role: 'controlplane' }),
createMockNode({ hostname: 'test-control-3', role: 'controlplane' }),
];
const result = getInitialValues(undefined, undefined, nodes, 'test-');
expect(result.hostname).toBe('test-control-4');
});
it('handles gaps in hostname numbering for worker nodes', () => {
const nodes = [
...createMockNodes(3, 'controlplane'),
createMockNode({ hostname: 'test-worker-1', role: 'worker' }),
createMockNode({ hostname: 'test-worker-5', role: 'worker' }),
];
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
expect(result.hostname).toBe('test-worker-6');
});
it('preserves initial hostname when provided', () => {
const result = getInitialValues(
{ hostname: 'custom-hostname' },
undefined,
[],
'test-'
);
expect(result.hostname).toBe('custom-hostname');
});
it('does not generate hostname when hostnamePrefix is undefined', () => {
const result = getInitialValues(undefined, undefined, [], undefined);
expect(result.hostname).toBe('');
});
it('does not generate hostname when initial hostname is provided', () => {
const result = getInitialValues(
{ hostname: 'existing-node' },
undefined,
[],
'test-'
);
expect(result.hostname).toBe('existing-node');
});
});
describe('Disk Selection', () => {
it('uses disk from initial values', () => {
const result = getInitialValues(
{ disk: '/dev/nvme0n1' },
createMockHardwareInfo(),
[],
'test-'
);
expect(result.disk).toBe('/dev/nvme0n1');
});
it('uses selected_disk from detection', () => {
const detection = createMockHardwareInfo({ selected_disk: '/dev/sdb' });
const result = getInitialValues(undefined, detection, [], 'test-');
expect(result.disk).toBe('/dev/sdb');
});
it('auto-selects first disk from detection when no selected_disk', () => {
const detection = createMockHardwareInfo({ selected_disk: undefined });
const result = getInitialValues(undefined, detection, [], 'test-');
expect(result.disk).toBe('/dev/sda');
});
it('returns empty string when no disk info available', () => {
const result = getInitialValues(undefined, undefined, [], 'test-');
expect(result.disk).toBe('');
});
it('returns empty string when detection has no disks', () => {
const detection = createMockHardwareInfo({ disks: [], selected_disk: undefined });
const result = getInitialValues(undefined, detection, [], 'test-');
expect(result.disk).toBe('');
});
});
describe('Interface Selection', () => {
it('uses interface from initial values', () => {
const result = getInitialValues(
{ interface: 'eth2' },
createMockHardwareInfo(),
[],
'test-'
);
expect(result.interface).toBe('eth2');
});
it('uses interface from detection', () => {
const detection = createMockHardwareInfo({ interface: 'eth1' });
const result = getInitialValues(undefined, detection, [], 'test-');
expect(result.interface).toBe('eth1');
});
it('auto-selects first interface from detection when no interface set', () => {
const detection = createMockHardwareInfo({ interface: undefined });
const result = getInitialValues(undefined, detection, [], 'test-');
expect(result.interface).toBe('eth0');
});
it('returns empty string when no interface info available', () => {
const result = getInitialValues(undefined, undefined, [], 'test-');
expect(result.interface).toBe('');
});
it('returns empty string when detection has no interfaces', () => {
const detection = createMockHardwareInfo({ interface: undefined, interfaces: [] });
const result = getInitialValues(undefined, detection, [], 'test-');
expect(result.interface).toBe('');
});
});
describe('IP Address Handling', () => {
it('does not auto-fill targetIp', () => {
const result = getInitialValues(undefined, createMockHardwareInfo(), [], 'test-');
expect(result.targetIp).toBe('');
});
it('preserves initial targetIp', () => {
const result = getInitialValues(
{ targetIp: '192.168.1.200' },
createMockHardwareInfo(),
[],
'test-'
);
expect(result.targetIp).toBe('192.168.1.200');
});
it('auto-fills currentIp from detection', () => {
const detection = createMockHardwareInfo({ ip: '192.168.1.75' });
const result = getInitialValues(undefined, detection, [], 'test-');
expect(result.currentIp).toBe('192.168.1.75');
});
it('preserves initial currentIp over detection', () => {
const detection = createMockHardwareInfo({ ip: '192.168.1.75' });
const result = getInitialValues({ currentIp: '192.168.1.80' }, detection, [], 'test-');
expect(result.currentIp).toBe('192.168.1.80');
});
it('returns empty currentIp when no detection', () => {
const result = getInitialValues(undefined, undefined, [], 'test-');
expect(result.currentIp).toBe('');
});
});
describe('SchematicId Handling', () => {
it('uses initial schematicId when provided', () => {
const result = getInitialValues({ schematicId: 'custom-123' }, undefined, [], 'test-');
expect(result.schematicId).toBe('custom-123');
});
it('returns empty string when no initial schematicId', () => {
const result = getInitialValues(undefined, undefined, [], 'test-');
expect(result.schematicId).toBe('');
});
});
describe('Maintenance Mode', () => {
it('defaults to true when not provided', () => {
const result = getInitialValues(undefined, undefined, [], 'test-');
expect(result.maintenance).toBe(true);
});
it('respects explicit true value', () => {
const result = getInitialValues({ maintenance: true }, undefined, [], 'test-');
expect(result.maintenance).toBe(true);
});
it('respects explicit false value', () => {
const result = getInitialValues({ maintenance: false }, undefined, [], 'test-');
expect(result.maintenance).toBe(false);
});
});
describe('Combined Scenarios', () => {
it('handles adding first control node with full detection', () => {
const detection = createMockHardwareInfo();
const result = getInitialValues(undefined, detection, [], 'prod-');
expect(result).toEqual({
hostname: 'prod-control-1',
role: 'controlplane',
disk: '/dev/sda',
targetIp: '',
currentIp: '192.168.1.50',
interface: 'eth0',
schematicId: '',
maintenance: true,
});
});
it('handles configuring existing node (all initial values)', () => {
const initial: Partial<NodeFormData> = {
hostname: 'existing-control-1',
role: 'controlplane',
disk: '/dev/nvme0n1',
targetIp: '192.168.1.105',
currentIp: '192.168.1.60',
interface: 'eth1',
schematicId: 'existing-schematic-456',
maintenance: false,
};
const result = getInitialValues(initial, undefined, [], 'test-');
expect(result).toEqual(initial);
});
it('handles adding second control node with partial detection', () => {
const nodes = [createMockNode({ hostname: 'test-control-1', role: 'controlplane' })];
const detection = createMockHardwareInfo({
interfaces: ['enp0s1'],
interface: 'enp0s1'
});
const result = getInitialValues(undefined, detection, nodes, 'test-');
expect(result.hostname).toBe('test-control-2');
expect(result.role).toBe('controlplane');
expect(result.interface).toBe('enp0s1');
});
it('handles missing detection data', () => {
const result = getInitialValues(undefined, undefined, [], 'test-');
expect(result).toEqual({
hostname: 'test-control-1',
role: 'controlplane',
disk: '',
targetIp: '',
currentIp: '',
interface: '',
schematicId: '',
maintenance: true,
});
});
it('handles partial detection data', () => {
const detection: HardwareInfo = {
ip: '192.168.1.50',
};
const result = getInitialValues(undefined, detection, [], 'test-');
expect(result.currentIp).toBe('192.168.1.50');
expect(result.disk).toBe('');
expect(result.interface).toBe('');
});
});
});

View File

@@ -0,0 +1,67 @@
import { Drawer } from '../ui/drawer';
import { HardwareDetectionDisplay } from './HardwareDetectionDisplay';
import { NodeForm, type NodeFormData } from './NodeForm';
import { NodeStatusBadge } from './NodeStatusBadge';
import type { Node, HardwareInfo } from '../../services/api/types';
interface NodeFormDrawerProps {
open: boolean;
onClose: () => void;
mode: 'add' | 'configure';
node?: Node;
detection?: HardwareInfo;
onSubmit: (data: NodeFormData) => Promise<void>;
onApply?: (data: NodeFormData) => Promise<void>;
instanceName?: string;
}
export function NodeFormDrawer({
open,
onClose,
mode,
node,
detection,
onSubmit,
onApply,
instanceName,
}: NodeFormDrawerProps) {
const title = mode === 'add' ? 'Add Node to Cluster' : `Configure ${node?.hostname}`;
return (
<Drawer open={open} onClose={onClose} title={title}>
{detection && (
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Hardware Detection Results
</h3>
<HardwareDetectionDisplay detection={detection} />
</div>
)}
{mode === 'configure' && node && (
<div className="mb-6">
<NodeStatusBadge node={node} showAction />
</div>
)}
<NodeForm
initialValues={node ? {
hostname: node.hostname,
role: node.role,
disk: node.disk,
targetIp: node.target_ip,
currentIp: node.current_ip,
interface: node.interface,
schematicId: node.schematic_id,
maintenance: node.maintenance ?? true,
} : undefined}
detection={detection}
onSubmit={onSubmit}
onApply={onApply}
submitLabel={mode === 'add' ? 'Add Node' : 'Save'}
showApplyButton={mode === 'configure'}
instanceName={instanceName}
/>
</Drawer>
);
}

View File

@@ -0,0 +1,66 @@
import {
MagnifyingGlassIcon,
ClockIcon,
ArrowPathIcon,
DocumentCheckIcon,
CheckCircleIcon,
HeartIcon,
WrenchScrewdriverIcon,
ExclamationTriangleIcon,
XCircleIcon,
QuestionMarkCircleIcon
} from '@heroicons/react/24/outline';
import type { Node } from '../../services/api/types';
import { deriveNodeStatus } from '../../utils/deriveNodeStatus';
import { statusDesigns } from '../../config/nodeStatus';
interface NodeStatusBadgeProps {
node: Node;
showAction?: boolean;
compact?: boolean;
}
const iconComponents = {
MagnifyingGlassIcon,
ClockIcon,
ArrowPathIcon,
DocumentCheckIcon,
CheckCircleIcon,
HeartIcon,
WrenchScrewdriverIcon,
ExclamationTriangleIcon,
XCircleIcon,
QuestionMarkCircleIcon
};
export function NodeStatusBadge({ node, showAction = false, compact = false }: NodeStatusBadgeProps) {
const status = deriveNodeStatus(node);
const design = statusDesigns[status];
const IconComponent = iconComponents[design.icon as keyof typeof iconComponents];
const isSpinning = ['configuring', 'applying', 'provisioning', 'reprovisioning'].includes(status);
if (compact) {
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${design.color} ${design.bgColor}`}>
<IconComponent className={`h-3.5 w-3.5 ${isSpinning ? 'animate-spin' : ''}`} />
<span>{design.label}</span>
</span>
);
}
return (
<div className={`inline-flex flex-col gap-1 px-3 py-2 rounded-lg ${design.color} ${design.bgColor}`}>
<div className="flex items-center gap-2">
<IconComponent className={`h-5 w-5 ${isSpinning ? 'animate-spin' : ''}`} />
<span className="text-sm font-semibold">{design.label}</span>
</div>
<p className="text-xs opacity-90">{design.description}</p>
{showAction && design.nextAction && (
<p className="text-xs font-medium mt-1">
{design.nextAction}
</p>
)}
</div>
);
}