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

@@ -146,10 +146,168 @@ pnpm dlx shadcn@latest add alert-dialog
You can then use components with `import { Button } from "@/components/ui/button"`
### UI Principles
### UX Principles
- Use shadcn AppSideBar as the main navigation for the app: https://ui.shadcn.com/docs/components/sidebar
- Support light and dark mode with Tailwind's built-in dark mode support: https://tailwindcss.com/docs/dark-mode
These principles ensure consistent, intuitive interfaces that align with Wild Cloud's philosophy of simplicity and clarity. Use them as quality control when building new components.
#### Navigation & Structure
- **Use shadcn AppSideBar** as the main navigation: https://ui.shadcn.com/docs/components/sidebar
- **Card-Based Layout**: Group related content in Card components
- Primary cards: `p-6` padding
- Nested cards: `p-4` padding with subtle shadows
- Use cards to create visual hierarchy through nesting
- **Spacing Rhythm**: Maintain consistent vertical spacing
- Major sections: `space-y-6`
- Related items: `space-y-4`
- Form fields: `space-y-3`
- Inline elements: `gap-2`, `gap-3`, or `gap-4`
#### Visual Design
- **Dark Mode**: Support both light and dark modes using Tailwind's `dark:` prefix
- Test all components in both modes for contrast and readability
- Use semantic color tokens that adapt to theme
- **Status Color System**: Use semantic left border colors to categorize content
- Blue (`border-l-blue-500`): Configuration sections
- Green (`border-l-green-500`): Network/infrastructure
- Red (`border-l-red-500`): Errors and warnings
- Cyan: Educational content
- **Icon-Text Pairing**: Pair important text with Lucide icons
- Place icons in colored containers: `p-2 bg-primary/10 rounded-lg`
- Provides visual anchors and improves scannability
- **Technical Data Display**: Show technical information clearly
- Use `font-mono` class for IPs, domains, configuration values
- Display in `bg-muted rounded-md p-2` containers
#### Component Patterns
- **Edit/View Mode Toggle**: For configuration sections
- Read-only: Display in `bg-muted rounded-md font-mono` containers with Edit button
- Edit mode: Replace with form inputs in-place
- Provides lightweight editing without context switching
- **Drawers for Complex Forms**: Use side panels for detailed input
- Maintains context with main content
- Better than modals for forms that benefit from seeing related data
- **Educational Content**: Use gradient cards for helpful information
- Background: `from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20`
- Include book icon and clear, concise guidance
- Makes learning feel integrated, not intrusive
- **Empty States**: Center content with clear next actions
- Large icon: `h-12 w-12 text-muted-foreground`
- Descriptive title and explanation
- Suggest action to resolve empty state
#### Section Headers
Structure all major section headers consistently:
```tsx
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<IconComponent className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Section Title</h2>
<p className="text-muted-foreground">
Brief description of section purpose
</p>
</div>
</div>
```
#### Status & Feedback
- **Status Badges**: Use colored badges with icons for state indication
- Keep compact but descriptive
- Include hover/expansion for additional detail
- **Alert Positioning**: Place alerts near related content
- Use semantic colors and icons (CheckCircle, AlertCircle, XCircle)
- Include dismissible X button for manual dismissal
- **Success Messages**: Auto-dismiss after 5 seconds
- Green color with CheckCircle icon
- Clear, affirmative message
- **Error Messages**: Structured and actionable
- Title in bold, detailed message below
- Red color with AlertCircle icon
- Suggest resolution when possible
- **Loading States**: Context-appropriate indicators
- Inline: Use `Loader2` spinner in buttons/actions
- Full section: Card with centered spinner and descriptive text
#### Form Components
Use react-hook-form for all forms. Never duplicate component styling.
**Standard Form Pattern**:
```tsx
import { useForm, Controller } from 'react-hook-form';
import { Input, Label, Button } from '@/components/ui';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
const { register, handleSubmit, control, formState: { errors } } = useForm({
defaultValues: { /* ... */ }
});
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
<div>
<Label htmlFor="text">Text Field</Label>
<Input {...register('text', { required: 'Required' })} className="mt-1" />
{errors.text && <p className="text-sm text-red-600 mt-1">{errors.text.message}</p>}
</div>
<div>
<Label htmlFor="select">Select Field</Label>
<Controller
name="select"
control={control}
rules={{ required: 'Required' }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Choose..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
</SelectContent>
</Select>
)}
/>
{errors.select && <p className="text-sm text-red-600 mt-1">{errors.select.message}</p>}
</div>
</form>
```
**Rules**:
- **Text inputs**: Use `Input` with `register()`
- **Select dropdowns**: Use `Select` components with `Controller` (never native `<select>`)
- **All labels**: Use `Label` with `htmlFor` attribute
- **Never copy classes**: Components provide default styling, only add spacing like `mt-1`
- **Form spacing**: `space-y-3` on form containers
- **Error messages**: `text-sm text-red-600 mt-1`
- **Multi-action forms**: Place buttons side-by-side with `flex gap-2`
#### Accessibility
- **Focus Indicators**: All interactive elements must have visible focus states
- Use consistent `focus-visible:ring-*` styles
- Test keyboard navigation on all new components
- **Screen Reader Support**: Proper semantic HTML and ARIA labels
- Use Label components for form inputs
- Provide descriptive text for icon-only buttons
- Test with screen readers when adding complex interactions
#### Progressive Disclosure
- **Just-in-Time Information**: Start simple, reveal details on demand
- Summary view by default
- Details through drawers, accordions, or inline expansion
- Reduces initial cognitive load
- **Educational Context**: Provide help without interrupting flow
- Use gradient educational cards in logical places
- Include "Learn more" links to external documentation
- Keep content concise and actionable
### App Layout

View File

@@ -16,6 +16,7 @@
"check": "pnpm run lint && pnpm run type-check && pnpm run test"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
@@ -44,6 +45,7 @@
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",

25
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@19.1.0)
'@hookform/resolvers':
specifier: ^5.1.1
version: 5.1.1(react-hook-form@7.58.1(react@19.1.0))
@@ -87,6 +90,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^24.0.3
version: 24.0.3
@@ -472,6 +478,11 @@ packages:
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
'@heroicons/react@2.2.0':
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
peerDependencies:
react: '>= 16 || ^19.0.0-rc'
'@hookform/resolvers@5.1.1':
resolution: {integrity: sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==}
peerDependencies:
@@ -1131,6 +1142,12 @@ packages:
'@types/react-dom':
optional: true
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -2890,6 +2907,10 @@ snapshots:
'@floating-ui/utils@0.2.9': {}
'@heroicons/react@2.2.0(react@19.1.0)':
dependencies:
react: 19.1.0
'@hookform/resolvers@5.1.1(react-hook-form@7.58.1(react@19.1.0))':
dependencies:
'@standard-schema/utils': 0.3.0
@@ -3461,6 +3482,10 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
'@testing-library/dom': 10.4.1
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':

View File

@@ -135,15 +135,6 @@ export function CentralComponent() {
</div>
</Card>
<Card className="p-4 border-l-4 border-l-orange-500">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-orange-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Setup Files</div>
<div className="font-medium capitalize">{centralStatus?.setupFiles || 'Unknown'}</div>
</div>
</div>
</Card>
</div>
</div>

View File

@@ -20,23 +20,39 @@ interface CloudConfig {
};
}
interface ClusterConfig {
endpointIp: string;
hostnamePrefix?: string;
nodes: {
talos: {
version: string;
};
};
}
export function CloudComponent() {
const { currentInstance } = useInstanceContext();
const { config: fullConfig, isLoading, error, updateConfig, isUpdating } = useInstanceConfig(currentInstance);
// Extract cloud config from full config
// Extract cloud and cluster config from full config
const config = fullConfig?.cloud as CloudConfig | undefined;
const clusterConfig = fullConfig?.cluster as ClusterConfig | undefined;
const [editingDomains, setEditingDomains] = useState(false);
const [editingNetwork, setEditingNetwork] = useState(false);
const [editingCluster, setEditingCluster] = useState(false);
const [formValues, setFormValues] = useState<CloudConfig | null>(null);
const [clusterFormValues, setClusterFormValues] = useState<ClusterConfig | null>(null);
// Sync form values when config loads
useEffect(() => {
if (config && !formValues) {
setFormValues(config as CloudConfig);
}
}, [config, formValues]);
if (clusterConfig && !clusterFormValues) {
setClusterFormValues(clusterConfig as ClusterConfig);
}
}, [config, clusterConfig, formValues, clusterFormValues]);
const handleDomainsEdit = () => {
if (config) {
@@ -106,6 +122,33 @@ export function CloudComponent() {
setEditingNetwork(false);
};
const handleClusterEdit = () => {
if (clusterConfig) {
setClusterFormValues(clusterConfig as ClusterConfig);
setEditingCluster(true);
}
};
const handleClusterSave = async () => {
if (!clusterFormValues || !fullConfig) return;
try {
// Update only the cluster section, preserving other config sections
await updateConfig({
...fullConfig,
cluster: clusterFormValues,
});
setEditingCluster(false);
} catch (err) {
console.error('Failed to save cluster settings:', err);
}
};
const handleClusterCancel = () => {
setClusterFormValues(clusterConfig as ClusterConfig);
setEditingCluster(false);
};
const updateFormValue = (path: string, value: string) => {
if (!formValues) return;
@@ -130,6 +173,35 @@ export function CloudComponent() {
});
};
const updateClusterFormValue = (path: string, value: string) => {
if (!clusterFormValues) return;
setClusterFormValues(prev => {
if (!prev) return prev;
// Handle nested paths like "nodes.talos.version"
const keys = path.split('.');
if (keys.length === 1) {
return { ...prev, [keys[0]]: value };
}
if (keys.length === 3 && keys[0] === 'nodes' && keys[1] === 'talos') {
return {
...prev,
nodes: {
...prev.nodes,
talos: {
...prev.nodes.talos,
[keys[2]]: value,
},
},
};
}
return prev;
});
};
// Show message if no instance is selected
if (!currentInstance) {
return (
@@ -390,6 +462,120 @@ export function CloudComponent() {
</div>
)}
</Card>
{/* Cluster Configuration Section */}
{clusterFormValues && (
<Card className="p-4 border-l-4 border-l-purple-500">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium">Cluster Configuration</h3>
<p className="text-sm text-muted-foreground">
Kubernetes cluster and node settings
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<HelpCircle className="h-4 w-4" />
</Button>
{!editingCluster && (
<Button
variant="outline"
size="sm"
onClick={handleClusterEdit}
disabled={isUpdating}
>
<Edit2 className="h-4 w-4 mr-1" />
Edit
</Button>
)}
</div>
</div>
{editingCluster ? (
<div className="space-y-3">
<div>
<Label htmlFor="endpoint-ip-edit">Cluster Endpoint IP</Label>
<Input
id="endpoint-ip-edit"
value={clusterFormValues.endpointIp}
onChange={(e) => updateClusterFormValue('endpointIp', e.target.value)}
placeholder="192.168.1.60"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Virtual IP for the Kubernetes API endpoint
</p>
</div>
<div>
<Label htmlFor="hostname-prefix-edit">Hostname Prefix (Optional)</Label>
<Input
id="hostname-prefix-edit"
value={clusterFormValues.hostnamePrefix || ''}
onChange={(e) => updateClusterFormValue('hostnamePrefix', e.target.value)}
placeholder="mycluster-"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Prefix for auto-generated node hostnames (e.g., "mycluster-control-1")
</p>
</div>
<div>
<Label htmlFor="talos-version-edit">Talos Version</Label>
<Input
id="talos-version-edit"
value={clusterFormValues.nodes.talos.version}
onChange={(e) => updateClusterFormValue('nodes.talos.version', e.target.value)}
placeholder="v1.8.0"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Talos Linux version for cluster nodes
</p>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleClusterSave} disabled={isUpdating}>
{isUpdating ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Check className="h-4 w-4 mr-1" />
)}
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClusterCancel}
disabled={isUpdating}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div>
<Label>Cluster Endpoint IP</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{clusterFormValues.endpointIp}
</div>
</div>
<div>
<Label>Hostname Prefix</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{clusterFormValues.hostnamePrefix || '(none)'}
</div>
</div>
<div>
<Label>Talos Version</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{clusterFormValues.nodes.talos.version}
</div>
</div>
</div>
)}
</Card>
)}
</div>
</Card>
</div>

View File

@@ -1,10 +1,18 @@
import { useState } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Alert } from './ui/alert';
import { Input } from './ui/input';
import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
import { useInstanceContext } from '../hooks/useInstanceContext';
import { useNodes, useDiscoveryStatus } from '../hooks/useNodes';
import { useCluster } from '../hooks/useCluster';
import { BootstrapModal } from './cluster/BootstrapModal';
import { NodeStatusBadge } from './nodes/NodeStatusBadge';
import { NodeFormDrawer } from './nodes/NodeFormDrawer';
import type { NodeFormData } from './nodes/NodeForm';
import type { Node, HardwareInfo, DiscoveredNode } from '../services/api/types';
export function ClusterNodesComponent() {
const { currentInstance } = useInstanceContext();
@@ -13,61 +21,91 @@ export function ClusterNodesComponent() {
isLoading,
error,
addNode,
isAdding,
addError,
deleteNode,
isDeleting,
deleteError,
discover,
isDiscovering,
detect,
isDetecting
discoverError: discoverMutationError,
getHardware,
isGettingHardware,
getHardwareError,
cancelDiscovery,
isCancellingDiscovery,
updateNode,
applyNode,
isApplying,
refetch
} = useNodes(currentInstance);
const {
data: discoveryStatus
} = useDiscoveryStatus(currentInstance);
const [subnet, setSubnet] = useState('192.168.1.0/24');
const {
status: clusterStatus
} = useCluster(currentInstance);
const getStatusIcon = (status?: string) => {
switch (status) {
case 'ready':
case 'healthy':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'connecting':
case 'provisioning':
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
default:
return <Monitor className="h-5 w-5 text-muted-foreground" />;
const [discoverSubnet, setDiscoverSubnet] = useState('192.168.8.0/24');
const [addNodeIp, setAddNodeIp] = useState('');
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [detectError, setDetectError] = useState<string | null>(null);
const [discoverSuccess, setDiscoverSuccess] = useState<string | null>(null);
const [showBootstrapModal, setShowBootstrapModal] = useState(false);
const [bootstrapNode, setBootstrapNode] = useState<{ name: string; ip: string } | null>(null);
const [drawerState, setDrawerState] = useState<{
open: boolean;
mode: 'add' | 'configure';
node?: Node;
detection?: HardwareInfo;
}>({
open: false,
mode: 'add',
});
const closeDrawer = () => setDrawerState({ ...drawerState, open: false });
// Sync mutation errors to local state for display
useEffect(() => {
if (discoverMutationError) {
const errorMsg = (discoverMutationError as any)?.message || 'Failed to discover nodes';
setDiscoverError(errorMsg);
}
};
}, [discoverMutationError]);
const getStatusBadge = (status?: string) => {
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive'> = {
pending: 'secondary',
connecting: 'default',
provisioning: 'default',
ready: 'success',
healthy: 'success',
error: 'destructive',
};
useEffect(() => {
if (getHardwareError) {
const errorMsg = (getHardwareError as any)?.message || 'Failed to detect hardware';
setDetectError(errorMsg);
}
}, [getHardwareError]);
const labels: Record<string, string> = {
pending: 'Pending',
connecting: 'Connecting',
provisioning: 'Provisioning',
ready: 'Ready',
healthy: 'Healthy',
error: 'Error',
};
// Track previous discovery status to detect completion
const [prevDiscoveryActive, setPrevDiscoveryActive] = useState<boolean | null>(null);
return (
<Badge variant={variants[status || 'pending']}>
{labels[status || 'pending'] || status}
</Badge>
);
};
// Handle discovery completion (when active changes from true to false)
useEffect(() => {
const isActive = discoveryStatus?.active ?? false;
// Discovery just completed (was active, now inactive)
if (prevDiscoveryActive === true && isActive === false && discoveryStatus) {
const count = discoveryStatus.nodes_found?.length || 0;
if (count === 0) {
setDiscoverSuccess(`Discovery complete! No nodes were found in the subnet.`);
} else {
setDiscoverSuccess(`Discovery complete! Found ${count} node${count !== 1 ? 's' : ''} in subnet.`);
}
setDiscoverError(null);
refetch();
const timer = setTimeout(() => setDiscoverSuccess(null), 5000);
return () => clearTimeout(timer);
}
// Update previous state
setPrevDiscoveryActive(isActive);
}, [discoveryStatus, prevDiscoveryActive, refetch]);
const getRoleIcon = (role: string) => {
return role === 'controlplane' ? (
@@ -77,9 +115,103 @@ export function ClusterNodesComponent() {
);
};
const handleAddNode = (ip: string, hostname: string, role: 'controlplane' | 'worker') => {
if (!currentInstance) return;
addNode({ target_ip: ip, hostname, role, disk: '/dev/sda' });
const handleAddFromDiscovery = async (discovered: DiscoveredNode) => {
// Fetch full hardware details for the discovered node
try {
const hardware = await getHardware(discovered.ip);
setDrawerState({
open: true,
mode: 'add',
detection: hardware,
});
} catch (err) {
console.error('Failed to detect hardware:', err);
setDetectError((err as any)?.message || 'Failed to detect hardware');
}
};
const handleAddNode = async () => {
if (!addNodeIp) return;
try {
const hardware = await getHardware(addNodeIp);
setDrawerState({
open: true,
mode: 'add',
detection: hardware,
});
} catch (err) {
console.error('Failed to detect hardware:', err);
setDetectError((err as any)?.message || 'Failed to detect hardware');
}
};
const handleConfigureNode = async (node: Node) => {
// Try to detect hardware if target_ip is available
if (node.target_ip) {
try {
const hardware = await getHardware(node.target_ip);
setDrawerState({
open: true,
mode: 'configure',
node,
detection: hardware,
});
return;
} catch (err) {
console.error('Failed to detect hardware:', err);
// Fall through to open drawer without detection data
}
}
// Open drawer without detection data (either no target_ip or detection failed)
setDrawerState({
open: true,
mode: 'configure',
node,
});
};
const handleAddSubmit = async (data: NodeFormData) => {
await addNode({
hostname: data.hostname,
role: data.role,
disk: data.disk,
target_ip: data.targetIp,
current_ip: data.currentIp,
interface: data.interface,
schematic_id: data.schematicId,
maintenance: data.maintenance,
});
closeDrawer();
setAddNodeIp('');
};
const handleConfigureSubmit = async (data: NodeFormData) => {
if (!drawerState.node) return;
await updateNode({
nodeName: drawerState.node.hostname,
updates: {
role: data.role,
config: {
disk: data.disk,
target_ip: data.targetIp,
current_ip: data.currentIp,
interface: data.interface,
schematic_id: data.schematicId,
maintenance: data.maintenance,
},
},
});
closeDrawer();
};
const handleApply = async (data: NodeFormData) => {
if (!drawerState.node) return;
await handleConfigureSubmit(data);
await applyNode(drawerState.node.hostname);
};
const handleDeleteNode = (hostname: string) => {
@@ -90,14 +222,11 @@ export function ClusterNodesComponent() {
};
const handleDiscover = () => {
if (!currentInstance) return;
discover(subnet);
setDiscoverError(null);
setDiscoverSuccess(null);
discover(discoverSubnet);
};
const handleDetect = () => {
if (!currentInstance) return;
detect();
};
// Derive status from backend state flags for each node
const assignedNodes = nodes.map(node => {
@@ -112,8 +241,25 @@ export function ClusterNodesComponent() {
return { ...node, status };
});
// Extract IPs from discovered nodes
const discoveredIps = discoveryStatus?.nodes_found?.map(n => n.ip) || [];
// Check if cluster needs bootstrap
const needsBootstrap = useMemo(() => {
// Find first ready control plane node
const hasReadyControlPlane = assignedNodes.some(
n => n.role === 'controlplane' && n.status === 'ready'
);
// Check if cluster is already bootstrapped using cluster status
// The backend checks for kubeconfig existence and cluster connectivity
const hasBootstrapped = clusterStatus?.status !== 'not_bootstrapped' && clusterStatus?.status !== undefined;
return hasReadyControlPlane && !hasBootstrapped;
}, [assignedNodes, clusterStatus]);
const firstReadyControl = useMemo(() => {
return assignedNodes.find(
n => n.role === 'controlplane' && n.status === 'ready'
);
}, [assignedNodes]);
// Show message if no instance is selected
if (!currentInstance) {
@@ -155,12 +301,12 @@ export function ClusterNodesComponent() {
What are Cluster Nodes?
</h3>
<p className="text-cyan-800 dark:text-cyan-200 mb-3 leading-relaxed">
Think of cluster nodes as the "workers" in your personal cloud factory. Each node is a separate computer
that contributes its processing power, memory, and storage to the overall cluster. Some nodes are "controllers"
Think of cluster nodes as the "workers" in your personal cloud factory. Each node is a separate computer
that contributes its processing power, memory, and storage to the overall cluster. Some nodes are "controllers"
(like managers) that coordinate the work, while others are "workers" that do the heavy lifting.
</p>
<p className="text-cyan-700 dark:text-cyan-300 mb-4 text-sm">
By connecting multiple computers together as nodes, you create a powerful, resilient system where if one
By connecting multiple computers together as nodes, you create a powerful, resilient system where if one
computer fails, the others can pick up the work. This is how you scale your personal cloud from one machine to many.
</p>
<Button variant="outline" size="sm" className="text-cyan-700 border-cyan-300 hover:bg-cyan-100 dark:text-cyan-300 dark:border-cyan-700 dark:hover:bg-cyan-900/20">
@@ -171,6 +317,32 @@ export function ClusterNodesComponent() {
</div>
</Card>
{/* Bootstrap Alert */}
{needsBootstrap && firstReadyControl && (
<Alert variant="info" className="mb-6">
<CheckCircle className="h-5 w-5" />
<div className="flex-1">
<h3 className="font-semibold mb-1">First Control Plane Node Ready!</h3>
<p className="text-sm text-muted-foreground mb-3">
Your first control plane node ({firstReadyControl.hostname}) is ready.
Bootstrap the cluster to initialize etcd and start Kubernetes control plane components.
</p>
<Button
onClick={() => {
setBootstrapNode({
name: firstReadyControl.hostname,
ip: firstReadyControl.target_ip
});
setShowBootstrapModal(true);
}}
size="sm"
>
Bootstrap Cluster
</Button>
</div>
</Alert>
)}
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
@@ -191,41 +363,177 @@ export function ClusterNodesComponent() {
</Card>
) : (
<>
{/* Error and Success Alerts */}
{discoverError && (
<Alert variant="error" onClose={() => setDiscoverError(null)} className="mb-4">
<AlertCircle className="h-4 w-4" />
<div>
<strong>Discovery Failed</strong>
<p className="text-sm mt-1">{discoverError}</p>
</div>
</Alert>
)}
{discoverSuccess && (
<Alert variant="success" onClose={() => setDiscoverSuccess(null)} className="mb-4">
<CheckCircle className="h-4 w-4" />
<div>
<strong>Discovery Successful</strong>
<p className="text-sm mt-1">{discoverSuccess}</p>
</div>
</Alert>
)}
{detectError && (
<Alert variant="error" onClose={() => setDetectError(null)} className="mb-4">
<AlertCircle className="h-4 w-4" />
<div>
<strong>Auto-Detect Failed</strong>
<p className="text-sm mt-1">{detectError}</p>
</div>
</Alert>
)}
{addError && (
<Alert variant="error" onClose={() => {}} className="mb-4">
<AlertCircle className="h-4 w-4" />
<div>
<strong>Failed to Add Node</strong>
<p className="text-sm mt-1">{(addError as any)?.message || 'An error occurred'}</p>
</div>
</Alert>
)}
{deleteError && (
<Alert variant="error" onClose={() => {}} className="mb-4">
<AlertCircle className="h-4 w-4" />
<div>
<strong>Failed to Remove Node</strong>
<p className="text-sm mt-1">{(deleteError as any)?.message || 'An error occurred'}</p>
</div>
</Alert>
)}
{/* DISCOVERY SECTION - Scan subnet for nodes */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
Discover Nodes on Network
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Scan a subnet to find nodes in maintenance mode
</p>
<div className="flex gap-3 mb-4">
<Input
type="text"
value={discoverSubnet}
onChange={(e) => setDiscoverSubnet(e.target.value)}
placeholder="192.168.8.0/24"
className="flex-1"
/>
<Button
onClick={handleDiscover}
disabled={isDiscovering || discoveryStatus?.active}
>
{isDiscovering || discoveryStatus?.active ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Discovering...
</>
) : (
'Discover'
)}
</Button>
{(isDiscovering || discoveryStatus?.active) && (
<Button
onClick={() => cancelDiscovery()}
disabled={isCancellingDiscovery}
variant="destructive"
>
{isCancellingDiscovery && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Cancel
</Button>
)}
</div>
{discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Discovered {discoveryStatus.nodes_found.length} node(s)
</h4>
<div className="space-y-3">
{discoveryStatus.nodes_found.map((discovered) => (
<div key={discovered.ip} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium font-mono text-gray-900 dark:text-gray-100">{discovered.ip}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Maintenance mode{discovered.version ? ` • Talos ${discovered.version}` : ''}
</p>
{discovered.hostname && (
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">{discovered.hostname}</p>
)}
</div>
<Button
onClick={() => handleAddFromDiscovery(discovered)}
size="sm"
>
Add to Cluster
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* ADD NODE SECTION - Add single node by IP */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
Add Single Node
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Add a node by IP address to detect hardware and configure
</p>
<div className="flex gap-3">
<Input
type="text"
value={addNodeIp}
onChange={(e) => setAddNodeIp(e.target.value)}
placeholder="192.168.8.128"
className="flex-1"
/>
<Button
onClick={handleAddNode}
disabled={isGettingHardware}
variant="secondary"
>
{isGettingHardware ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Detecting...
</>
) : (
'Add Node'
)}
</Button>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Cluster Nodes ({assignedNodes.length})</h2>
<div className="flex gap-2">
<input
type="text"
placeholder="Subnet (e.g., 192.168.1.0/24)"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
className="px-3 py-1 text-sm border rounded-lg"
/>
<Button
size="sm"
onClick={handleDiscover}
disabled={isDiscovering || discoveryStatus?.active}
>
{isDiscovering || discoveryStatus?.active ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
{discoveryStatus?.active ? 'Discovering...' : 'Discover'}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDetect}
disabled={isDetecting}
>
{isDetecting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Auto Detect
</Button>
</div>
</div>
{assignedNodes.map((node) => (
<Card key={node.hostname} className="p-4">
<Card key={node.hostname} className="p-4 hover:shadow-md transition-shadow">
<div className="mb-2">
<NodeStatusBadge node={node} compact />
</div>
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getRoleIcon(node.role)}
@@ -236,13 +544,17 @@ export function ClusterNodesComponent() {
<Badge variant="outline" className="text-xs">
{node.role}
</Badge>
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
IP: {node.target_ip}
Target: {node.target_ip}
</div>
{node.disk && (
<div className="text-xs text-muted-foreground">
Disk: {node.disk}
</div>
)}
{node.hardware && (
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-2">
{node.hardware.cpu && (
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
@@ -270,15 +582,30 @@ export function ClusterNodesComponent() {
</div>
)}
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
<div className="flex flex-col gap-2">
<Button
size="sm"
onClick={() => handleConfigureNode(node)}
>
Configure
</Button>
{node.configured && !node.applied && (
<Button
size="sm"
onClick={() => applyNode(node.hostname)}
disabled={isApplying}
variant="secondary"
>
{isApplying ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteNode(node.hostname)}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'}
</Button>
</div>
</div>
@@ -295,78 +622,35 @@ export function ClusterNodesComponent() {
</Card>
)}
</div>
{discoveredIps.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-medium mb-4">Discovered IPs ({discoveredIps.length})</h3>
<div className="space-y-2">
{discoveredIps.map((ip) => (
<Card key={ip} className="p-3 flex items-center justify-between">
<span className="text-sm font-mono">{ip}</span>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleAddNode(ip, `node-${ip}`, 'worker')}
disabled={isAdding}
>
Add as Worker
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAddNode(ip, `controlplane-${ip}`, 'controlplane')}
disabled={isAdding}
>
Add as Control Plane
</Button>
</div>
</Card>
))}
</div>
</div>
)}
</>
)}
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4">PXE Boot Instructions</h3>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
1
</div>
<div>
<p className="font-medium">Power on your nodes</p>
<p className="text-muted-foreground">
Ensure network boot (PXE) is enabled in BIOS/UEFI settings
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
2
</div>
<div>
<p className="font-medium">Connect to the wild-cloud network</p>
<p className="text-muted-foreground">
Nodes will automatically receive IP addresses via DHCP
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
3
</div>
<div>
<p className="font-medium">Boot Talos Linux</p>
<p className="text-muted-foreground">
Nodes will automatically download and boot Talos Linux via PXE
</p>
</div>
</div>
</div>
</Card>
{/* Bootstrap Modal */}
{showBootstrapModal && bootstrapNode && (
<BootstrapModal
instanceName={currentInstance!}
nodeName={bootstrapNode.name}
nodeIp={bootstrapNode.ip}
onClose={() => {
setShowBootstrapModal(false);
setBootstrapNode(null);
refetch();
}}
/>
)}
{/* Node Form Drawer */}
<NodeFormDrawer
open={drawerState.open}
onClose={closeDrawer}
mode={drawerState.mode}
node={drawerState.mode === 'configure' ? drawerState.node : undefined}
detection={drawerState.detection}
onSubmit={drawerState.mode === 'add' ? handleAddSubmit : handleConfigureSubmit}
onApply={drawerState.mode === 'configure' ? handleApply : undefined}
instanceName={currentInstance || ''}
/>
</div>
);
}

View File

@@ -237,6 +237,22 @@ export const ConfigurationForm = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="cluster.hostnamePrefix"
render={({ field }) => (
<FormItem>
<FormLabel>Hostname Prefix (Optional)</FormLabel>
<FormControl>
<Input placeholder="test-" {...field} />
</FormControl>
<FormDescription>
Optional prefix for node hostnames (e.g., 'test-' for unique names on LAN)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cluster.nodes.talos.version"

View File

@@ -0,0 +1,184 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Alert } from '../ui/alert';
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { BootstrapProgress } from './BootstrapProgress';
import { clusterApi } from '../../services/api/cluster';
import { useOperation } from '../../services/api/hooks/useOperations';
interface BootstrapModalProps {
instanceName: string;
nodeName: string;
nodeIp: string;
onClose: () => void;
}
export function BootstrapModal({
instanceName,
nodeName,
nodeIp,
onClose,
}: BootstrapModalProps) {
const [operationId, setOperationId] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
const [showConfirmation, setShowConfirmation] = useState(true);
const { data: operation } = useOperation(instanceName, operationId || '');
const handleStartBootstrap = async () => {
setIsStarting(true);
setStartError(null);
try {
const response = await clusterApi.bootstrap(instanceName, nodeName);
setOperationId(response.operation_id);
setShowConfirmation(false);
} catch (err) {
setStartError((err as Error).message || 'Failed to start bootstrap');
} finally {
setIsStarting(false);
}
};
useEffect(() => {
if (operation?.status === 'completed') {
setTimeout(() => onClose(), 2000);
}
}, [operation?.status, onClose]);
const isComplete = operation?.status === 'completed';
const isFailed = operation?.status === 'failed';
const isRunning = operation?.status === 'running' || operation?.status === 'pending';
return (
<Dialog open onOpenChange={onClose}>
<DialogContent
className="max-w-2xl"
showCloseButton={!isRunning}
>
<DialogHeader>
<DialogTitle>Bootstrap Cluster</DialogTitle>
<DialogDescription>
Initialize the Kubernetes cluster on {nodeName} ({nodeIp})
</DialogDescription>
</DialogHeader>
{showConfirmation ? (
<>
<div className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<div>
<strong className="font-semibold">Important</strong>
<p className="text-sm mt-1">
This will initialize the etcd cluster and start the control plane
components. This operation can only be performed once per cluster and
should be run on the first control plane node.
</p>
</div>
</Alert>
{startError && (
<Alert variant="error" onClose={() => setStartError(null)}>
<AlertCircle className="h-4 w-4" />
<div>
<strong className="font-semibold">Bootstrap Failed</strong>
<p className="text-sm mt-1">{startError}</p>
</div>
</Alert>
)}
<div className="space-y-2 text-sm">
<p className="font-medium">Before bootstrapping, ensure:</p>
<ul className="ml-4 list-disc space-y-1 text-muted-foreground">
<li>Node configuration has been applied successfully</li>
<li>Node is in maintenance mode and ready</li>
<li>This is the first control plane node</li>
<li>No other nodes have been bootstrapped</li>
</ul>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isStarting}>
Cancel
</Button>
<Button onClick={handleStartBootstrap} disabled={isStarting}>
{isStarting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Starting...
</>
) : (
'Start Bootstrap'
)}
</Button>
</DialogFooter>
</>
) : (
<>
<div className="py-4">
{operation && operation.details?.bootstrap ? (
<BootstrapProgress
progress={operation.details.bootstrap}
error={isFailed ? operation.error : undefined}
/>
) : (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground">
Starting bootstrap...
</span>
</div>
)}
</div>
{isComplete && (
<Alert variant="success">
<CheckCircle className="h-4 w-4" />
<div>
<strong className="font-semibold">Bootstrap Complete!</strong>
<p className="text-sm mt-1">
The cluster has been successfully initialized. Additional control
plane nodes can now join automatically.
</p>
</div>
</Alert>
)}
{isFailed && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<div>
<strong className="font-semibold">Bootstrap Failed</strong>
<p className="text-sm mt-1">
{operation.error || 'The bootstrap process encountered an error.'}
</p>
</div>
</Alert>
)}
<DialogFooter>
{isComplete || isFailed ? (
<Button onClick={onClose}>Close</Button>
) : (
<Button variant="outline" disabled>
Bootstrap in progress...
</Button>
)}
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,115 @@
import { CheckCircle, AlertCircle, Loader2, Clock } from 'lucide-react';
import { Card } from '../ui/card';
import { Badge } from '../ui/badge';
import { TroubleshootingPanel } from './TroubleshootingPanel';
import type { BootstrapProgress as BootstrapProgressType } from '../../services/api/types';
interface BootstrapProgressProps {
progress: BootstrapProgressType;
error?: string;
}
const BOOTSTRAP_STEPS = [
{ id: 0, name: 'Bootstrap Command', description: 'Running talosctl bootstrap' },
{ id: 1, name: 'etcd Health', description: 'Verifying etcd cluster health' },
{ id: 2, name: 'VIP Assignment', description: 'Waiting for VIP assignment' },
{ id: 3, name: 'Control Plane', description: 'Waiting for control plane components' },
{ id: 4, name: 'API Server', description: 'Waiting for API server on VIP' },
{ id: 5, name: 'Cluster Access', description: 'Configuring cluster access' },
{ id: 6, name: 'Node Registration', description: 'Verifying node registration' },
];
export function BootstrapProgress({ progress, error }: BootstrapProgressProps) {
const getStepIcon = (stepId: number) => {
if (stepId < progress.current_step) {
return <CheckCircle className="h-5 w-5 text-green-500" />;
}
if (stepId === progress.current_step) {
if (error) {
return <AlertCircle className="h-5 w-5 text-red-500" />;
}
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
}
return <Clock className="h-5 w-5 text-gray-400" />;
};
const getStepStatus = (stepId: number) => {
if (stepId < progress.current_step) {
return 'completed';
}
if (stepId === progress.current_step) {
return error ? 'error' : 'running';
}
return 'pending';
};
return (
<div className="space-y-4">
<div className="space-y-3">
{BOOTSTRAP_STEPS.map((step) => {
const status = getStepStatus(step.id);
const isActive = step.id === progress.current_step;
return (
<Card
key={step.id}
className={`p-4 ${
isActive
? error
? 'border-red-300 bg-red-50 dark:bg-red-950/20'
: 'border-blue-300 bg-blue-50 dark:bg-blue-950/20'
: status === 'completed'
? 'border-green-200 bg-green-50 dark:bg-green-950/20'
: ''
}`}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">{getStepIcon(step.id)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-sm">{step.name}</h4>
{status === 'completed' && (
<Badge variant="success" className="text-xs">
Complete
</Badge>
)}
{status === 'running' && !error && (
<Badge variant="default" className="text-xs">
In Progress
</Badge>
)}
{status === 'error' && (
<Badge variant="destructive" className="text-xs">
Failed
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{step.description}</p>
{isActive && !error && (
<div className="mt-2 space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
Attempt {progress.attempt} of {progress.max_attempts}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{
width: `${(progress.attempt / progress.max_attempts) * 100}%`,
}}
/>
</div>
</div>
)}
</div>
</div>
</Card>
);
})}
</div>
{error && <TroubleshootingPanel step={progress.current_step} />}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Alert } from '../ui/alert';
import { AlertCircle } from 'lucide-react';
interface TroubleshootingPanelProps {
step: number;
}
const TROUBLESHOOTING_STEPS: Record<number, string[]> = {
1: [
'Check etcd service status with: talosctl -n <node-ip> service etcd',
'View etcd logs: talosctl -n <node-ip> logs etcd',
'Verify bootstrap completed successfully',
],
2: [
'Check VIP controller logs: kubectl logs -n kube-system -l k8s-app=kube-vip',
'Verify network configuration allows VIP assignment',
'Check that VIP range is configured correctly in cluster config',
],
3: [
'Check kubelet logs: talosctl -n <node-ip> logs kubelet',
'Verify static pod manifests: talosctl -n <node-ip> list /etc/kubernetes/manifests',
'Try restarting kubelet: talosctl -n <node-ip> service kubelet restart',
],
4: [
'Check API server logs: kubectl logs -n kube-system kube-apiserver-<node>',
'Verify API server is running: talosctl -n <node-ip> service kubelet',
'Test API server on node IP: curl -k https://<node-ip>:6443/healthz',
],
5: [
'Check API server logs for connection errors',
'Test API server on node IP first: curl -k https://<node-ip>:6443/healthz',
'Verify network connectivity to VIP address',
],
6: [
'Check kubelet logs: talosctl -n <node-ip> logs kubelet',
'Verify API server is accessible: kubectl get nodes',
'Check network connectivity between node and API server',
],
};
export function TroubleshootingPanel({ step }: TroubleshootingPanelProps) {
const steps = TROUBLESHOOTING_STEPS[step] || [
'Check logs for detailed error information',
'Verify network connectivity',
'Ensure all prerequisites are met',
];
return (
<Alert variant="error" className="mt-4">
<AlertCircle className="h-4 w-4" />
<div>
<strong className="font-semibold">Troubleshooting Steps</strong>
<ul className="mt-2 ml-4 list-disc space-y-1 text-sm">
{steps.map((troubleshootingStep, index) => (
<li key={index}>{troubleshootingStep}</li>
))}
</ul>
</div>
</Alert>
);
}

View File

@@ -0,0 +1,3 @@
export { BootstrapModal } from './BootstrapModal';
export { BootstrapProgress } from './BootstrapProgress';
export { TroubleshootingPanel } from './TroubleshootingPanel';

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>
);
}

View File

@@ -0,0 +1,76 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11',
{
variants: {
variant: {
default: 'bg-background text-foreground border-border',
success: 'bg-green-50 text-green-900 border-green-200 dark:bg-green-950/20 dark:text-green-100 dark:border-green-800',
error: 'bg-red-50 text-red-900 border-red-200 dark:bg-red-950/20 dark:text-red-100 dark:border-red-800',
warning: 'bg-yellow-50 text-yellow-900 border-yellow-200 dark:bg-yellow-950/20 dark:text-yellow-100 dark:border-yellow-800',
info: 'bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-950/20 dark:text-blue-100 dark:border-blue-800',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface AlertProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof alertVariants> {
onClose?: () => void;
}
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
({ className, variant, onClose, children, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={alertVariants({ variant, className })}
{...props}
>
{children}
{onClose && (
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
)}
</div>
)
);
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={`mb-1 font-medium leading-none tracking-tight ${className || ''}`}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={`text-sm [&_p]:leading-relaxed ${className || ''}`}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,95 @@
import { useEffect, type ReactNode } from 'react';
interface DrawerProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
}
export function Drawer({ open, onClose, title, children, footer }: DrawerProps) {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [open, onClose]);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
return (
<>
{/* Overlay with fade transition */}
<div
className={`
fixed inset-0 z-50
bg-black/50 backdrop-blur-sm
transition-opacity duration-300 ease-in-out
${open ? 'opacity-100' : 'opacity-0 pointer-events-none'}
`}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer panel with slide transition */}
<div
className={`
fixed inset-y-0 right-0 z-50
w-full max-w-md
transform transition-transform duration-300 ease-in-out
${open ? 'translate-x-0' : 'translate-x-full'}
`}
>
<div className="flex h-full flex-col bg-background shadow-xl">
{/* Header */}
<div className="border-b border-border px-6 py-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<button
onClick={onClose}
className="rounded-md text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close drawer"
>
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-6">
{children}
</div>
{/* Footer */}
{footer && (
<div className="border-t border-border px-6 py-4">
{footer}
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -1,6 +1,7 @@
export { Button, buttonVariants } from './button';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card';
export { Badge, badgeVariants } from './badge';
export { Alert, AlertTitle, AlertDescription } from './alert';
export { Input } from './input';
export { Label } from './label';
export { Textarea } from './textarea';

View File

@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-transparent dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:bg-transparent dark:focus-visible:bg-input/30",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}

161
src/config/nodeStatus.ts Normal file
View File

@@ -0,0 +1,161 @@
import { NodeStatus, type StatusDesign } from '../types/nodeStatus';
export const statusDesigns: Record<NodeStatus, StatusDesign> = {
[NodeStatus.DISCOVERED]: {
status: NodeStatus.DISCOVERED,
color: "text-purple-700",
bgColor: "bg-purple-50",
icon: "MagnifyingGlassIcon",
label: "Discovered",
description: "Node detected on network but not yet configured",
nextAction: "Configure node settings",
severity: "info"
},
[NodeStatus.PENDING]: {
status: NodeStatus.PENDING,
color: "text-gray-700",
bgColor: "bg-gray-50",
icon: "ClockIcon",
label: "Pending",
description: "Node awaiting configuration",
nextAction: "Configure and apply settings",
severity: "neutral"
},
[NodeStatus.CONFIGURING]: {
status: NodeStatus.CONFIGURING,
color: "text-blue-700",
bgColor: "bg-blue-50",
icon: "ArrowPathIcon",
label: "Configuring",
description: "Node configuration in progress",
severity: "info"
},
[NodeStatus.CONFIGURED]: {
status: NodeStatus.CONFIGURED,
color: "text-indigo-700",
bgColor: "bg-indigo-50",
icon: "DocumentCheckIcon",
label: "Configured",
description: "Node configured but not yet applied",
nextAction: "Apply configuration to node",
severity: "info"
},
[NodeStatus.APPLYING]: {
status: NodeStatus.APPLYING,
color: "text-blue-700",
bgColor: "bg-blue-50",
icon: "ArrowPathIcon",
label: "Applying",
description: "Applying configuration to node",
severity: "info"
},
[NodeStatus.PROVISIONING]: {
status: NodeStatus.PROVISIONING,
color: "text-blue-700",
bgColor: "bg-blue-50",
icon: "ArrowPathIcon",
label: "Provisioning",
description: "Node is being provisioned with Talos",
severity: "info"
},
[NodeStatus.READY]: {
status: NodeStatus.READY,
color: "text-green-700",
bgColor: "bg-green-50",
icon: "CheckCircleIcon",
label: "Ready",
description: "Node is ready and operational",
severity: "success"
},
[NodeStatus.HEALTHY]: {
status: NodeStatus.HEALTHY,
color: "text-emerald-700",
bgColor: "bg-emerald-50",
icon: "HeartIcon",
label: "Healthy",
description: "Node is healthy and part of Kubernetes cluster",
severity: "success"
},
[NodeStatus.MAINTENANCE]: {
status: NodeStatus.MAINTENANCE,
color: "text-yellow-700",
bgColor: "bg-yellow-50",
icon: "WrenchScrewdriverIcon",
label: "Maintenance",
description: "Node is in maintenance mode",
severity: "warning"
},
[NodeStatus.REPROVISIONING]: {
status: NodeStatus.REPROVISIONING,
color: "text-orange-700",
bgColor: "bg-orange-50",
icon: "ArrowPathIcon",
label: "Reprovisioning",
description: "Node is being reprovisioned",
severity: "warning"
},
[NodeStatus.UNREACHABLE]: {
status: NodeStatus.UNREACHABLE,
color: "text-red-700",
bgColor: "bg-red-50",
icon: "ExclamationTriangleIcon",
label: "Unreachable",
description: "Node cannot be contacted",
nextAction: "Check network connectivity",
severity: "error"
},
[NodeStatus.DEGRADED]: {
status: NodeStatus.DEGRADED,
color: "text-orange-700",
bgColor: "bg-orange-50",
icon: "ExclamationTriangleIcon",
label: "Degraded",
description: "Node is experiencing issues",
nextAction: "Check node health",
severity: "warning"
},
[NodeStatus.FAILED]: {
status: NodeStatus.FAILED,
color: "text-red-700",
bgColor: "bg-red-50",
icon: "XCircleIcon",
label: "Failed",
description: "Node operation failed",
nextAction: "Review logs and retry",
severity: "error"
},
[NodeStatus.UNKNOWN]: {
status: NodeStatus.UNKNOWN,
color: "text-gray-700",
bgColor: "bg-gray-50",
icon: "QuestionMarkCircleIcon",
label: "Unknown",
description: "Node status cannot be determined",
nextAction: "Check node connection",
severity: "neutral"
},
[NodeStatus.ORPHANED]: {
status: NodeStatus.ORPHANED,
color: "text-purple-700",
bgColor: "bg-purple-50",
icon: "ExclamationTriangleIcon",
label: "Orphaned",
description: "Node exists in Kubernetes but not in configuration",
nextAction: "Add to configuration or remove from cluster",
severity: "warning"
}
};

View File

@@ -6,7 +6,7 @@ import { useConfig } from '../useConfig';
import { apiService } from '../../services/api-legacy';
// Mock the API service
vi.mock('../../services/api', () => ({
vi.mock('../../services/api-legacy', () => ({
apiService: {
getConfig: vi.fn(),
createConfig: vi.fn(),
@@ -56,7 +56,7 @@ describe('useConfig', () => {
},
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
@@ -81,7 +81,7 @@ describe('useConfig', () => {
message: 'No configuration found',
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
@@ -122,8 +122,8 @@ describe('useConfig', () => {
},
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
vi.mocked(apiService.createConfig).mockResolvedValue(mockCreateResponse);
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
(apiService.createConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockCreateResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
@@ -149,7 +149,7 @@ describe('useConfig', () => {
it('should handle error when fetching config fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiService.getConfig).mockRejectedValue(mockError);
(apiService.getConfig as ReturnType<typeof vi.fn>).mockRejectedValue(mockError);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),

View File

@@ -6,7 +6,7 @@ import { useStatus } from '../useStatus';
import { apiService } from '../../services/api-legacy';
// Mock the API service
vi.mock('../../services/api', () => ({
vi.mock('../../services/api-legacy', () => ({
apiService: {
getStatus: vi.fn(),
},
@@ -40,7 +40,7 @@ describe('useStatus', () => {
timestamp: '2024-01-01T00:00:00Z',
};
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
(apiService.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
@@ -60,7 +60,7 @@ describe('useStatus', () => {
it('should handle error when fetching status fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiService.getStatus).mockRejectedValue(mockError);
(apiService.getStatus as ReturnType<typeof vi.fn>).mockRejectedValue(mockError);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
@@ -82,7 +82,7 @@ describe('useStatus', () => {
timestamp: '2024-01-01T00:00:00Z',
};
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
(apiService.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),

View File

@@ -13,10 +13,17 @@ export function useNodes(instanceName: string | null | undefined) {
const discoverMutation = useMutation({
mutationFn: (subnet: string) => nodesApi.discover(instanceName!, subnet),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'discovery'] });
},
});
const detectMutation = useMutation({
mutationFn: () => nodesApi.detect(instanceName!),
mutationFn: (ip?: string) => nodesApi.detect(instanceName!, ip),
});
const autoDetectMutation = useMutation({
mutationFn: () => nodesApi.autoDetect(instanceName!),
});
const addMutation = useMutation({
@@ -24,6 +31,10 @@ export function useNodes(instanceName: string | null | undefined) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
},
onError: (error) => {
// Don't refetch on error to avoid showing inconsistent state
console.error('Failed to add node:', error);
},
});
const updateMutation = useMutation({
@@ -39,6 +50,10 @@ export function useNodes(instanceName: string | null | undefined) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
},
onError: (error) => {
// Don't refetch on error to avoid showing inconsistent state
console.error('Failed to delete node:', error);
},
});
const applyMutation = useMutation({
@@ -49,6 +64,17 @@ export function useNodes(instanceName: string | null | undefined) {
mutationFn: () => nodesApi.fetchTemplates(instanceName!),
});
const cancelDiscoveryMutation = useMutation({
mutationFn: () => nodesApi.cancelDiscovery(instanceName!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'discovery'] });
},
});
const getHardwareMutation = useMutation({
mutationFn: (ip: string) => nodesApi.getHardware(instanceName!, ip),
});
return {
nodes: nodesQuery.data?.nodes || [],
isLoading: nodesQuery.isLoading,
@@ -57,19 +83,32 @@ export function useNodes(instanceName: string | null | undefined) {
discover: discoverMutation.mutate,
isDiscovering: discoverMutation.isPending,
discoverResult: discoverMutation.data,
discoverError: discoverMutation.error,
detect: detectMutation.mutate,
isDetecting: detectMutation.isPending,
detectResult: detectMutation.data,
detectError: detectMutation.error,
autoDetect: autoDetectMutation.mutate,
isAutoDetecting: autoDetectMutation.isPending,
autoDetectResult: autoDetectMutation.data,
autoDetectError: autoDetectMutation.error,
getHardware: getHardwareMutation.mutateAsync,
isGettingHardware: getHardwareMutation.isPending,
getHardwareError: getHardwareMutation.error,
addNode: addMutation.mutate,
isAdding: addMutation.isPending,
addError: addMutation.error,
updateNode: updateMutation.mutate,
isUpdating: updateMutation.isPending,
deleteNode: deleteMutation.mutate,
isDeleting: deleteMutation.isPending,
deleteError: deleteMutation.error,
applyNode: applyMutation.mutate,
isApplying: applyMutation.isPending,
fetchTemplates: fetchTemplatesMutation.mutate,
isFetchingTemplates: fetchTemplatesMutation.isPending,
cancelDiscovery: cancelDiscoveryMutation.mutate,
isCancellingDiscovery: cancelDiscoveryMutation.isPending,
};
}

View File

@@ -74,6 +74,7 @@ const nodesConfigSchema = z.object({
// Cluster configuration schema
const clusterConfigSchema = z.object({
endpointIp: ipAddressSchema,
hostnamePrefix: z.string().optional(),
nodes: nodesConfigSchema,
});
@@ -138,6 +139,7 @@ export const configFormSchema = z.object({
(val) => ipAddressSchema.safeParse(val).success,
'Must be a valid IP address'
),
hostnamePrefix: z.string().optional(),
nodes: z.object({
talos: z.object({
version: z.string().min(1, 'Talos version is required').refine(
@@ -175,6 +177,7 @@ export const defaultConfigValues: ConfigFormData = {
},
cluster: {
endpointIp: '192.168.8.60',
hostnamePrefix: '',
nodes: {
talos: {
version: 'v1.8.0',

View File

@@ -13,8 +13,8 @@ export const clusterApi = {
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/config/generate`, config);
},
async bootstrap(instanceName: string, node: string): Promise<OperationResponse> {
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/bootstrap`, { node });
async bootstrap(instanceName: string, nodeName: string): Promise<OperationResponse> {
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/bootstrap`, { node_name: nodeName });
},
async configureEndpoints(instanceName: string, includeNodes = false): Promise<OperationResponse> {

View File

@@ -39,14 +39,23 @@ export const nodesApi = {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/discover`, { subnet });
},
async detect(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/detect`);
async detect(instanceName: string, ip?: string): Promise<OperationResponse> {
const body = ip ? { ip } : {};
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/detect`, body);
},
async autoDetect(instanceName: string): Promise<{ networks: string[]; nodes: any[]; count: number }> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/auto-detect`);
},
async discoveryStatus(instanceName: string): Promise<DiscoveryStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/discovery`);
},
async cancelDiscovery(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/discovery/cancel`);
},
async getHardware(instanceName: string, ip: string): Promise<HardwareInfo> {
return apiClient.get(`/api/v1/instances/${instanceName}/nodes/hardware/${ip}`);
},

View File

@@ -11,6 +11,13 @@ export interface Node {
maintenance?: boolean;
configured?: boolean;
applied?: boolean;
// Active operation flags
configureInProgress?: boolean;
applyInProgress?: boolean;
// Optional runtime fields for enhanced status
isReachable?: boolean;
inKubernetes?: boolean;
lastHealthCheck?: string;
// Optional fields (not yet returned by API)
hardware?: HardwareInfo;
talosVersion?: string;
@@ -23,15 +30,19 @@ export interface HardwareInfo {
disk?: string;
manufacturer?: string;
model?: string;
// Hardware detection fields
ip?: string;
interface?: string;
interfaces?: string[];
disks?: Array<{ path: string; size: number }>;
selected_disk?: string;
}
export interface DiscoveredNode {
ip: string;
hostname?: string;
maintenance_mode?: boolean;
maintenance_mode: boolean;
version?: string;
interface?: string;
disks?: string[];
}
export interface DiscoveryStatus {
@@ -50,6 +61,10 @@ export interface NodeAddRequest {
target_ip: string;
role: 'controlplane' | 'worker';
disk?: string;
current_ip?: string;
interface?: string;
schematic_id?: string;
maintenance?: boolean;
}
export interface NodeUpdateRequest {

View File

@@ -1,3 +1,15 @@
export interface BootstrapProgress {
current_step: number;
step_name: string;
attempt: number;
max_attempts: number;
step_description: string;
}
export interface OperationDetails {
bootstrap?: BootstrapProgress;
}
export interface Operation {
id: string;
instance_name: string;
@@ -9,6 +21,7 @@ export interface Operation {
started: string;
completed?: string;
error?: string;
details?: OperationDetails;
}
export interface OperationListResponse {

View File

@@ -0,0 +1,133 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Node, HardwareInfo } from '../../services/api/types';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
}
export function createWrapper(queryClient: QueryClient) {
return function TestWrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
}
export function createMockNode(overrides: Partial<Node> = {}): Node {
return {
hostname: 'test-control-1',
target_ip: '192.168.1.101',
role: 'controlplane',
current_ip: '192.168.1.50',
interface: 'eth0',
disk: '/dev/sda',
maintenance: true,
configured: false,
applied: false,
...overrides,
};
}
export function createMockNodes(count: number, role: 'controlplane' | 'worker' = 'controlplane'): Node[] {
return Array.from({ length: count }, (_, i) =>
createMockNode({
hostname: `test-${role === 'controlplane' ? 'control' : 'worker'}-${i + 1}`,
target_ip: `192.168.1.${100 + i + 1}`,
role,
})
);
}
export function createMockConfig(overrides: any = {}) {
return {
cluster: {
hostnamePrefix: 'test-',
nodes: {
control: {
vip: '192.168.1.100',
},
talos: {
schematicId: 'default-schematic-123',
},
},
},
...overrides,
};
}
export function createMockHardwareInfo(overrides: Partial<HardwareInfo> = {}): HardwareInfo {
return {
ip: '192.168.1.50',
interface: 'eth0',
interfaces: ['eth0', 'eth1'],
disks: [
{ path: '/dev/sda', size: 512000000000 },
{ path: '/dev/sdb', size: 1024000000000 },
],
selected_disk: '/dev/sda',
...overrides,
};
}
export function mockUseInstanceConfig(config: any = null) {
return {
config,
isLoading: false,
error: null,
updateConfig: vi.fn(),
isUpdating: false,
batchUpdate: vi.fn(),
isBatchUpdating: false,
};
}
export function mockUseNodes(nodes: Node[] = []) {
return {
nodes,
isLoading: false,
error: null,
refetch: vi.fn(),
discover: vi.fn(),
isDiscovering: false,
discoverResult: undefined,
discoverError: null,
detect: vi.fn(),
isDetecting: false,
detectResult: undefined,
detectError: null,
autoDetect: vi.fn(),
isAutoDetecting: false,
autoDetectResult: undefined,
autoDetectError: null,
getHardware: vi.fn(),
isGettingHardware: false,
getHardwareError: null,
addNode: vi.fn(),
isAdding: false,
addError: null,
updateNode: vi.fn(),
isUpdating: false,
deleteNode: vi.fn(),
isDeleting: false,
deleteError: null,
applyNode: vi.fn(),
isApplying: false,
fetchTemplates: vi.fn(),
isFetchingTemplates: false,
cancelDiscovery: vi.fn(),
isCancellingDiscovery: false,
};
}

View File

@@ -33,6 +33,7 @@ export interface CloudConfig {
export interface TalosConfig {
version: string;
schematicId?: string;
}
export interface NodesConfig {

41
src/types/nodeStatus.ts Normal file
View File

@@ -0,0 +1,41 @@
export enum NodeStatus {
// Discovery Phase
DISCOVERED = "discovered",
// Configuration Phase
PENDING = "pending",
CONFIGURING = "configuring",
CONFIGURED = "configured",
// Deployment Phase
APPLYING = "applying",
PROVISIONING = "provisioning",
// Operational Phase
READY = "ready",
HEALTHY = "healthy",
// Maintenance States
MAINTENANCE = "maintenance",
REPROVISIONING = "reprovisioning",
// Error States
UNREACHABLE = "unreachable",
DEGRADED = "degraded",
FAILED = "failed",
// Special States
UNKNOWN = "unknown",
ORPHANED = "orphaned"
}
export interface StatusDesign {
status: NodeStatus;
color: string;
bgColor: string;
icon: string;
label: string;
description: string;
nextAction?: string;
severity: "info" | "warning" | "error" | "success" | "neutral";
}

View File

@@ -0,0 +1,61 @@
import type { Node } from '../services/api/types';
import { NodeStatus } from '../types/nodeStatus';
export function deriveNodeStatus(node: Node): NodeStatus {
// Priority 1: Active operations
if (node.applyInProgress) {
return NodeStatus.APPLYING;
}
if (node.configureInProgress) {
return NodeStatus.CONFIGURING;
}
// Priority 2: Maintenance states
if (node.maintenance) {
if (node.applied) {
return NodeStatus.MAINTENANCE;
} else {
return NodeStatus.REPROVISIONING;
}
}
// Priority 3: Error states
if (node.isReachable === false) {
return NodeStatus.UNREACHABLE;
}
// Priority 4: Lifecycle progression
if (!node.configured) {
return NodeStatus.PENDING;
}
if (node.configured && !node.applied) {
return NodeStatus.CONFIGURED;
}
if (node.applied) {
// Check Kubernetes membership for healthy state
if (node.inKubernetes === true) {
return NodeStatus.HEALTHY;
}
// Applied but not yet in Kubernetes (could be provisioning or ready)
if (node.isReachable === true) {
return NodeStatus.READY;
}
// Applied but status unknown
if (node.isReachable === undefined && node.inKubernetes === undefined) {
return NodeStatus.READY;
}
// Applied but having issues
if (node.inKubernetes === false) {
return NodeStatus.DEGRADED;
}
}
// Fallback
return NodeStatus.UNKNOWN;
}