Makes cluster-nodes functional.
This commit is contained in:
90
src/components/nodes/HardwareDetectionDisplay.tsx
Normal file
90
src/components/nodes/HardwareDetectionDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1356
src/components/nodes/NodeForm.test.tsx
Normal file
1356
src/components/nodes/NodeForm.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
605
src/components/nodes/NodeForm.tsx
Normal file
605
src/components/nodes/NodeForm.tsx
Normal 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]}`;
|
||||
}
|
||||
392
src/components/nodes/NodeForm.unit.test.tsx
Normal file
392
src/components/nodes/NodeForm.unit.test.tsx
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
67
src/components/nodes/NodeFormDrawer.tsx
Normal file
67
src/components/nodes/NodeFormDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/nodes/NodeStatusBadge.tsx
Normal file
66
src/components/nodes/NodeStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user