Makes cluster-nodes functional.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
25
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
184
src/components/cluster/BootstrapModal.tsx
Normal file
184
src/components/cluster/BootstrapModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/components/cluster/BootstrapProgress.tsx
Normal file
115
src/components/cluster/BootstrapProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/components/cluster/TroubleshootingPanel.tsx
Normal file
61
src/components/cluster/TroubleshootingPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/components/cluster/index.ts
Normal file
3
src/components/cluster/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { BootstrapModal } from './BootstrapModal';
|
||||
export { BootstrapProgress } from './BootstrapProgress';
|
||||
export { TroubleshootingPanel } from './TroubleshootingPanel';
|
||||
90
src/components/nodes/HardwareDetectionDisplay.tsx
Normal file
90
src/components/nodes/HardwareDetectionDisplay.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { HardwareInfo } from '../../services/api/types';
|
||||
|
||||
interface HardwareDetectionDisplayProps {
|
||||
detection: HardwareInfo;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function HardwareDetectionDisplay({ detection }: HardwareDetectionDisplayProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 space-y-3">
|
||||
<div className="flex items-start">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">IP Address</p>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100 font-mono">{detection.ip}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detection.interface && (
|
||||
<div className="flex items-start">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Network Interface</p>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100 font-mono">{detection.interface}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detection.disks && detection.disks.length > 0 && (
|
||||
<div className="flex items-start">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Available Disks</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{detection.disks.map((disk) => (
|
||||
<li key={disk.path} className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="font-mono">{disk.path}</span>
|
||||
{disk.size > 0 && (
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-2">
|
||||
({formatBytes(disk.size)})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1356
src/components/nodes/NodeForm.test.tsx
Normal file
1356
src/components/nodes/NodeForm.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
605
src/components/nodes/NodeForm.tsx
Normal file
605
src/components/nodes/NodeForm.tsx
Normal file
@@ -0,0 +1,605 @@
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useInstanceConfig } from '../../hooks/useInstances';
|
||||
import { useNodes } from '../../hooks/useNodes';
|
||||
import type { HardwareInfo } from '../../services/api/types';
|
||||
import { Input, Label, Button } from '../ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
|
||||
export interface NodeFormData {
|
||||
hostname: string;
|
||||
role: 'controlplane' | 'worker';
|
||||
disk: string;
|
||||
targetIp: string;
|
||||
currentIp?: string;
|
||||
interface?: string;
|
||||
schematicId?: string;
|
||||
maintenance: boolean;
|
||||
}
|
||||
|
||||
interface NodeFormProps {
|
||||
initialValues?: Partial<NodeFormData>;
|
||||
detection?: HardwareInfo;
|
||||
onSubmit: (data: NodeFormData) => Promise<void>;
|
||||
onApply?: (data: NodeFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
showApplyButton?: boolean;
|
||||
instanceName?: string;
|
||||
}
|
||||
|
||||
function getInitialValues(
|
||||
initial?: Partial<NodeFormData>,
|
||||
detection?: HardwareInfo,
|
||||
nodes?: Array<{ role: string; hostname?: string }>,
|
||||
hostnamePrefix?: string
|
||||
): NodeFormData {
|
||||
// Determine default role: controlplane unless there are already 3+ control nodes
|
||||
let defaultRole: 'controlplane' | 'worker' = 'controlplane';
|
||||
if (nodes) {
|
||||
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
||||
if (controlPlaneCount >= 3) {
|
||||
defaultRole = 'worker';
|
||||
}
|
||||
}
|
||||
|
||||
const role = initial?.role || defaultRole;
|
||||
|
||||
// Generate default hostname based on role and existing nodes
|
||||
let defaultHostname = '';
|
||||
if (!initial?.hostname) {
|
||||
const prefix = hostnamePrefix || '';
|
||||
|
||||
// Generate a hostname even if nodes is not loaded yet
|
||||
// The useEffect will fix it later when data is available
|
||||
if (role === 'controlplane') {
|
||||
if (nodes) {
|
||||
// Find next control plane number
|
||||
const controlNumbers = nodes
|
||||
.filter(n => n.role === 'controlplane')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||
defaultHostname = `${prefix}control-${nextNumber}`;
|
||||
} else {
|
||||
// No nodes loaded yet, default to 1
|
||||
defaultHostname = `${prefix}control-1`;
|
||||
}
|
||||
} else {
|
||||
if (nodes) {
|
||||
// Find next worker number
|
||||
const workerNumbers = nodes
|
||||
.filter(n => n.role === 'worker')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||
defaultHostname = `${prefix}worker-${nextNumber}`;
|
||||
} else {
|
||||
// No nodes loaded yet, default to 1
|
||||
defaultHostname = `${prefix}worker-1`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select first disk if none specified
|
||||
let defaultDisk = initial?.disk || detection?.selected_disk || '';
|
||||
if (!defaultDisk && detection?.disks && detection.disks.length > 0) {
|
||||
defaultDisk = detection.disks[0].path;
|
||||
}
|
||||
|
||||
// Auto-select first interface if none specified
|
||||
let defaultInterface = initial?.interface || detection?.interface || '';
|
||||
if (!defaultInterface && detection?.interfaces && detection.interfaces.length > 0) {
|
||||
defaultInterface = detection.interfaces[0];
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: initial?.hostname || defaultHostname,
|
||||
role,
|
||||
disk: defaultDisk,
|
||||
targetIp: initial?.targetIp || '', // Don't auto-fill targetIp from detection
|
||||
currentIp: initial?.currentIp || detection?.ip || '', // Auto-fill from detection
|
||||
interface: defaultInterface,
|
||||
schematicId: initial?.schematicId || '',
|
||||
maintenance: initial?.maintenance ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function NodeForm({
|
||||
initialValues,
|
||||
detection,
|
||||
onSubmit,
|
||||
onApply,
|
||||
submitLabel = 'Save',
|
||||
showApplyButton = false,
|
||||
instanceName,
|
||||
}: NodeFormProps) {
|
||||
// Track if we're editing an existing node (has initial hostname from backend)
|
||||
const isExistingNode = Boolean(initialValues?.hostname);
|
||||
const { config: instanceConfig } = useInstanceConfig(instanceName);
|
||||
const { nodes } = useNodes(instanceName);
|
||||
|
||||
const hostnamePrefix = instanceConfig?.cluster?.hostnamePrefix || '';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<NodeFormData>({
|
||||
defaultValues: getInitialValues(initialValues, detection, nodes, hostnamePrefix),
|
||||
});
|
||||
|
||||
const schematicId = watch('schematicId');
|
||||
const role = watch('role');
|
||||
const hostname = watch('hostname');
|
||||
|
||||
// Reset form when initialValues change (e.g., switching to configure a different node)
|
||||
// This ensures select boxes and all fields show the current values
|
||||
// Use a ref to track the hostname to avoid infinite loops from object reference changes
|
||||
const prevHostnameRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const currentHostname = initialValues?.hostname;
|
||||
// Only reset if the hostname actually changed (switching between nodes)
|
||||
if (currentHostname !== prevHostnameRef.current) {
|
||||
prevHostnameRef.current = currentHostname;
|
||||
const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix);
|
||||
reset(newValues);
|
||||
}
|
||||
}, [initialValues, detection, nodes, hostnamePrefix, reset]);
|
||||
|
||||
// Set default role based on existing control plane nodes
|
||||
useEffect(() => {
|
||||
if (!initialValues?.role && nodes) {
|
||||
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
||||
const defaultRole: 'controlplane' | 'worker' = controlPlaneCount >= 3 ? 'worker' : 'controlplane';
|
||||
const currentRole = watch('role');
|
||||
|
||||
// Only update if the current role is still the initial default and we now have node data
|
||||
if (currentRole === 'controlplane' && controlPlaneCount >= 3) {
|
||||
setValue('role', defaultRole);
|
||||
}
|
||||
}
|
||||
}, [nodes, initialValues?.role, setValue, watch]);
|
||||
|
||||
// Pre-populate schematic ID from cluster config if available
|
||||
useEffect(() => {
|
||||
if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) {
|
||||
setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId);
|
||||
}
|
||||
}, [instanceConfig, schematicId, setValue]);
|
||||
|
||||
// Auto-generate hostname when role changes (only for NEW nodes without initial hostname)
|
||||
useEffect(() => {
|
||||
if (!nodes) return;
|
||||
|
||||
// Don't auto-generate if this is an existing node with initial hostname
|
||||
// This check must happen FIRST to prevent regeneration when hostnamePrefix loads
|
||||
if (isExistingNode) return;
|
||||
|
||||
const prefix = hostnamePrefix || '';
|
||||
const currentHostname = watch('hostname');
|
||||
|
||||
if (!currentHostname) return;
|
||||
|
||||
// Check if current hostname follows our naming pattern WITH prefix
|
||||
const hostnameMatch = currentHostname.match(new RegExp(`^${prefix}(control|worker)-(\\d+)$`));
|
||||
|
||||
// If no match with prefix, check if it matches WITHOUT prefix (generated before prefix was loaded)
|
||||
const hostnameMatchNoPrefix = !hostnameMatch && prefix ?
|
||||
currentHostname.match(/^(control|worker)-(\d+)$/) : null;
|
||||
|
||||
// Check if this is a generated hostname (either with or without prefix)
|
||||
const isGeneratedHostname = hostnameMatch !== null || hostnameMatchNoPrefix !== null;
|
||||
|
||||
// Use whichever match succeeded
|
||||
const activeMatch = hostnameMatch || hostnameMatchNoPrefix;
|
||||
|
||||
// Check if the role prefix in the hostname matches the current role
|
||||
const hostnameRolePrefix = activeMatch?.[1]; // 'control' or 'worker'
|
||||
const expectedRolePrefix = role === 'controlplane' ? 'control' : 'worker';
|
||||
const roleMatches = hostnameRolePrefix === expectedRolePrefix;
|
||||
|
||||
// Check if the hostname has the expected prefix
|
||||
const hasCorrectPrefix = hostnameMatch !== null;
|
||||
|
||||
// Auto-update hostname if it was previously auto-generated AND either:
|
||||
// 1. The role prefix doesn't match (e.g., hostname is "control-1" but role is "worker")
|
||||
// 2. The hostname is missing the prefix (e.g., "control-1" instead of "test-control-1")
|
||||
// 3. The number needs updating (existing logic)
|
||||
if (isGeneratedHostname && (!roleMatches || !hasCorrectPrefix)) {
|
||||
// Role changed, need to regenerate with correct prefix
|
||||
if (role === 'controlplane') {
|
||||
const controlNumbers = nodes
|
||||
.filter(n => n.role === 'controlplane')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||
const newHostname = `${prefix}control-${nextNumber}`;
|
||||
setValue('hostname', newHostname);
|
||||
} else {
|
||||
const workerNumbers = nodes
|
||||
.filter(n => n.role === 'worker')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||
const newHostname = `${prefix}worker-${nextNumber}`;
|
||||
setValue('hostname', newHostname);
|
||||
}
|
||||
} else if (isGeneratedHostname && roleMatches && hasCorrectPrefix) {
|
||||
// Role matches and prefix is correct, but check if the number needs updating (original logic)
|
||||
if (role === 'controlplane') {
|
||||
const controlNumbers = nodes
|
||||
.filter(n => n.role === 'controlplane')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||
const newHostname = `${prefix}control-${nextNumber}`;
|
||||
if (currentHostname !== newHostname) {
|
||||
setValue('hostname', newHostname);
|
||||
}
|
||||
} else {
|
||||
const workerNumbers = nodes
|
||||
.filter(n => n.role === 'worker')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||
const newHostname = `${prefix}worker-${nextNumber}`;
|
||||
if (currentHostname !== newHostname) {
|
||||
setValue('hostname', newHostname);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [role, nodes, hostnamePrefix, setValue, watch, isExistingNode]);
|
||||
|
||||
// Auto-calculate target IP for control plane nodes
|
||||
useEffect(() => {
|
||||
// Skip if this is an existing node (configure mode)
|
||||
if (initialValues?.targetIp) return;
|
||||
|
||||
const clusterConfig = instanceConfig?.cluster as any;
|
||||
const vip = clusterConfig?.nodes?.control?.vip as string | undefined;
|
||||
|
||||
if (role === 'controlplane' && vip) {
|
||||
|
||||
// Parse VIP to get base and last octet
|
||||
const vipParts = vip.split('.');
|
||||
if (vipParts.length !== 4) return;
|
||||
|
||||
const vipLastOctet = parseInt(vipParts[3], 10);
|
||||
if (isNaN(vipLastOctet)) return;
|
||||
|
||||
const vipPrefix = vipParts.slice(0, 3).join('.');
|
||||
|
||||
// Find all control plane IPs in the same subnet range
|
||||
const usedOctets = nodes
|
||||
.filter(node => node.role === 'controlplane' && node.target_ip)
|
||||
.map(node => {
|
||||
const parts = node.target_ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
// Only consider IPs in the same subnet
|
||||
if (parts.slice(0, 3).join('.') !== vipPrefix) return null;
|
||||
const octet = parseInt(parts[3], 10);
|
||||
return isNaN(octet) ? null : octet;
|
||||
})
|
||||
.filter((octet): octet is number => octet !== null && octet > vipLastOctet);
|
||||
|
||||
// Find the first available IP after VIP
|
||||
let nextOctet = vipLastOctet + 1;
|
||||
|
||||
// Sort used octets to find gaps
|
||||
const sortedOctets = [...usedOctets].sort((a, b) => a - b);
|
||||
|
||||
// Check for gaps in the sequence starting from VIP+1
|
||||
for (const usedOctet of sortedOctets) {
|
||||
if (usedOctet === nextOctet) {
|
||||
nextOctet++;
|
||||
} else if (usedOctet > nextOctet) {
|
||||
// Found a gap, use it
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we don't exceed valid IP range
|
||||
if (nextOctet > 254) {
|
||||
console.warn('No available IPs in subnet after VIP');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the calculated IP
|
||||
setValue('targetIp', `${vipPrefix}.${nextOctet}`);
|
||||
} else if (role === 'worker') {
|
||||
// For new worker nodes, clear target IP (let user set if needed)
|
||||
const currentTargetIp = watch('targetIp');
|
||||
// Only clear if it looks like an auto-calculated IP (matches VIP pattern)
|
||||
if (currentTargetIp && vip) {
|
||||
const vipPrefix = vip.split('.').slice(0, 3).join('.');
|
||||
if (currentTargetIp.startsWith(vipPrefix)) {
|
||||
setValue('targetIp', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [role, instanceConfig, nodes, setValue, watch, initialValues?.targetIp]);
|
||||
|
||||
// Build disk options from both detection and initial values
|
||||
const diskOptions = (() => {
|
||||
const options = [...(detection?.disks || [])];
|
||||
// If configuring existing node, ensure its disk is in options
|
||||
if (initialValues?.disk && !options.some(d => d.path === initialValues.disk)) {
|
||||
options.push({ path: initialValues.disk, size: 0 });
|
||||
}
|
||||
return options;
|
||||
})();
|
||||
|
||||
// Build interface options from both detection and initial values
|
||||
const interfaceOptions = (() => {
|
||||
const options = [...(detection?.interfaces || [])];
|
||||
// If configuring existing node, ensure its interface is in options
|
||||
if (initialValues?.interface && !options.includes(initialValues.interface)) {
|
||||
options.push(initialValues.interface);
|
||||
}
|
||||
// Also add detection.interface if present
|
||||
if (detection?.interface && !options.includes(detection.interface)) {
|
||||
options.push(detection.interface);
|
||||
}
|
||||
return options;
|
||||
})();
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
rules={{ required: 'Role is required' }}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value || ''} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="controlplane">Control Plane</SelectItem>
|
||||
<SelectItem value="worker">Worker</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.role && <p className="text-sm text-red-600 mt-1">{errors.role.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="hostname">Hostname</Label>
|
||||
<Input
|
||||
id="hostname"
|
||||
type="text"
|
||||
{...register('hostname', {
|
||||
required: 'Hostname is required',
|
||||
pattern: {
|
||||
value: /^[a-z0-9-]+$/,
|
||||
message: 'Hostname must contain only lowercase letters, numbers, and hyphens',
|
||||
},
|
||||
})}
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.hostname && (
|
||||
<p className="text-sm text-red-600 mt-1">{errors.hostname.message}</p>
|
||||
)}
|
||||
{hostname && hostname.match(/^.*?(control|worker)-\d+$/) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Auto-generated based on role and existing nodes
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="disk">Disk</Label>
|
||||
{diskOptions.length > 0 ? (
|
||||
<Controller
|
||||
name="disk"
|
||||
control={control}
|
||||
rules={{ required: 'Disk is required' }}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value || ''} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select a disk" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{diskOptions.map((disk) => (
|
||||
<SelectItem key={disk.path} value={disk.path}>
|
||||
{disk.path}
|
||||
{disk.size > 0 && ` (${formatBytes(disk.size)})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
name="disk"
|
||||
control={control}
|
||||
rules={{ required: 'Disk is required' }}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="disk"
|
||||
type="text"
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
className="mt-1"
|
||||
placeholder="/dev/sda"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{errors.disk && <p className="text-sm text-red-600 mt-1">{errors.disk.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="targetIp">Target IP Address</Label>
|
||||
<Input
|
||||
id="targetIp"
|
||||
type="text"
|
||||
{...register('targetIp')}
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.targetIp && (
|
||||
<p className="text-sm text-red-600 mt-1">{errors.targetIp.message}</p>
|
||||
)}
|
||||
{role === 'controlplane' && (instanceConfig?.cluster as any)?.nodes?.control?.vip && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Auto-calculated from VIP ({(instanceConfig?.cluster as any)?.nodes?.control?.vip})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="currentIp">Current IP Address</Label>
|
||||
<Input
|
||||
id="currentIp"
|
||||
type="text"
|
||||
{...register('currentIp')}
|
||||
className="mt-1"
|
||||
disabled={!!detection?.ip}
|
||||
/>
|
||||
{errors.currentIp && (
|
||||
<p className="text-sm text-red-600 mt-1">{errors.currentIp.message}</p>
|
||||
)}
|
||||
{detection?.ip && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Auto-detected from hardware (read-only)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="interface">Network Interface</Label>
|
||||
{interfaceOptions.length > 0 ? (
|
||||
<Controller
|
||||
name="interface"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value || ''} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select interface..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{interfaceOptions.map((iface) => (
|
||||
<SelectItem key={iface} value={iface}>
|
||||
{iface}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
name="interface"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="interface"
|
||||
type="text"
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
className="mt-1"
|
||||
placeholder="eth0"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="schematicId">Schematic ID (Optional)</Label>
|
||||
<Input
|
||||
id="schematicId"
|
||||
type="text"
|
||||
{...register('schematicId')}
|
||||
className="mt-1 font-mono text-xs"
|
||||
placeholder="abc123def456..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Leave blank to use default Talos configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="maintenance"
|
||||
type="checkbox"
|
||||
{...register('maintenance')}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="maintenance" className="font-normal">
|
||||
Start in maintenance mode
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : submitLabel}
|
||||
</Button>
|
||||
|
||||
{showApplyButton && onApply && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit(onApply)}
|
||||
disabled={isSubmitting}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
392
src/components/nodes/NodeForm.unit.test.tsx
Normal file
392
src/components/nodes/NodeForm.unit.test.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { NodeFormData } from './NodeForm';
|
||||
import { createMockNode, createMockNodes, createMockHardwareInfo } from '../../test/utils/nodeFormTestUtils';
|
||||
import type { HardwareInfo } from '../../services/api/types';
|
||||
|
||||
function getInitialValues(
|
||||
initial?: Partial<NodeFormData>,
|
||||
detection?: HardwareInfo,
|
||||
nodes?: Array<{ role: string; hostname?: string }>,
|
||||
hostnamePrefix?: string
|
||||
): NodeFormData {
|
||||
let defaultRole: 'controlplane' | 'worker' = 'controlplane';
|
||||
if (nodes) {
|
||||
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
||||
if (controlPlaneCount >= 3) {
|
||||
defaultRole = 'worker';
|
||||
}
|
||||
}
|
||||
|
||||
const role = initial?.role || defaultRole;
|
||||
|
||||
let defaultHostname = '';
|
||||
if (!initial?.hostname && nodes && hostnamePrefix !== undefined) {
|
||||
const prefix = hostnamePrefix || '';
|
||||
if (role === 'controlplane') {
|
||||
const controlNumbers = nodes
|
||||
.filter(n => n.role === 'controlplane')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||
defaultHostname = `${prefix}control-${nextNumber}`;
|
||||
} else {
|
||||
const workerNumbers = nodes
|
||||
.filter(n => n.role === 'worker')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||
defaultHostname = `${prefix}worker-${nextNumber}`;
|
||||
}
|
||||
}
|
||||
|
||||
let defaultDisk = initial?.disk || detection?.selected_disk || '';
|
||||
if (!defaultDisk && detection?.disks && detection.disks.length > 0) {
|
||||
defaultDisk = detection.disks[0].path;
|
||||
}
|
||||
|
||||
let defaultInterface = initial?.interface || detection?.interface || '';
|
||||
if (!defaultInterface && detection?.interfaces && detection.interfaces.length > 0) {
|
||||
defaultInterface = detection.interfaces[0];
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: initial?.hostname || defaultHostname,
|
||||
role,
|
||||
disk: defaultDisk,
|
||||
targetIp: initial?.targetIp || '',
|
||||
currentIp: initial?.currentIp || detection?.ip || '',
|
||||
interface: defaultInterface,
|
||||
schematicId: initial?.schematicId || '',
|
||||
maintenance: initial?.maintenance ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getInitialValues', () => {
|
||||
describe('Role Selection', () => {
|
||||
it('defaults to controlplane when no nodes exist', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.role).toBe('controlplane');
|
||||
});
|
||||
|
||||
it('defaults to controlplane when fewer than 3 control nodes exist', () => {
|
||||
const nodes = createMockNodes(2, 'controlplane');
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.role).toBe('controlplane');
|
||||
});
|
||||
|
||||
it('defaults to worker when 3 or more control nodes exist', () => {
|
||||
const nodes = createMockNodes(3, 'controlplane');
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.role).toBe('worker');
|
||||
});
|
||||
|
||||
it('respects explicit role in initial values', () => {
|
||||
const nodes = createMockNodes(3, 'controlplane');
|
||||
const result = getInitialValues({ role: 'controlplane' }, undefined, nodes, 'test-');
|
||||
expect(result.role).toBe('controlplane');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hostname Generation', () => {
|
||||
it('generates first control node hostname', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.hostname).toBe('test-control-1');
|
||||
});
|
||||
|
||||
it('generates second control node hostname', () => {
|
||||
const nodes = [createMockNode({ hostname: 'test-control-1', role: 'controlplane' })];
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-control-2');
|
||||
});
|
||||
|
||||
it('generates third control node hostname', () => {
|
||||
const nodes = [
|
||||
createMockNode({ hostname: 'test-control-1', role: 'controlplane' }),
|
||||
createMockNode({ hostname: 'test-control-2', role: 'controlplane' }),
|
||||
];
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-control-3');
|
||||
});
|
||||
|
||||
it('generates first worker node hostname', () => {
|
||||
const nodes = createMockNodes(3, 'controlplane');
|
||||
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-worker-1');
|
||||
});
|
||||
|
||||
it('generates second worker node hostname', () => {
|
||||
const nodes = [
|
||||
...createMockNodes(3, 'controlplane'),
|
||||
createMockNode({ hostname: 'test-worker-1', role: 'worker' }),
|
||||
];
|
||||
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-worker-2');
|
||||
});
|
||||
|
||||
it('handles empty hostname prefix', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], '');
|
||||
expect(result.hostname).toBe('control-1');
|
||||
});
|
||||
|
||||
it('handles gaps in hostname numbering for control nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode({ hostname: 'test-control-1', role: 'controlplane' }),
|
||||
createMockNode({ hostname: 'test-control-3', role: 'controlplane' }),
|
||||
];
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-control-4');
|
||||
});
|
||||
|
||||
it('handles gaps in hostname numbering for worker nodes', () => {
|
||||
const nodes = [
|
||||
...createMockNodes(3, 'controlplane'),
|
||||
createMockNode({ hostname: 'test-worker-1', role: 'worker' }),
|
||||
createMockNode({ hostname: 'test-worker-5', role: 'worker' }),
|
||||
];
|
||||
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-worker-6');
|
||||
});
|
||||
|
||||
it('preserves initial hostname when provided', () => {
|
||||
const result = getInitialValues(
|
||||
{ hostname: 'custom-hostname' },
|
||||
undefined,
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.hostname).toBe('custom-hostname');
|
||||
});
|
||||
|
||||
it('does not generate hostname when hostnamePrefix is undefined', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], undefined);
|
||||
expect(result.hostname).toBe('');
|
||||
});
|
||||
|
||||
it('does not generate hostname when initial hostname is provided', () => {
|
||||
const result = getInitialValues(
|
||||
{ hostname: 'existing-node' },
|
||||
undefined,
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.hostname).toBe('existing-node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disk Selection', () => {
|
||||
it('uses disk from initial values', () => {
|
||||
const result = getInitialValues(
|
||||
{ disk: '/dev/nvme0n1' },
|
||||
createMockHardwareInfo(),
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.disk).toBe('/dev/nvme0n1');
|
||||
});
|
||||
|
||||
it('uses selected_disk from detection', () => {
|
||||
const detection = createMockHardwareInfo({ selected_disk: '/dev/sdb' });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.disk).toBe('/dev/sdb');
|
||||
});
|
||||
|
||||
it('auto-selects first disk from detection when no selected_disk', () => {
|
||||
const detection = createMockHardwareInfo({ selected_disk: undefined });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.disk).toBe('/dev/sda');
|
||||
});
|
||||
|
||||
it('returns empty string when no disk info available', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.disk).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when detection has no disks', () => {
|
||||
const detection = createMockHardwareInfo({ disks: [], selected_disk: undefined });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.disk).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interface Selection', () => {
|
||||
it('uses interface from initial values', () => {
|
||||
const result = getInitialValues(
|
||||
{ interface: 'eth2' },
|
||||
createMockHardwareInfo(),
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.interface).toBe('eth2');
|
||||
});
|
||||
|
||||
it('uses interface from detection', () => {
|
||||
const detection = createMockHardwareInfo({ interface: 'eth1' });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.interface).toBe('eth1');
|
||||
});
|
||||
|
||||
it('auto-selects first interface from detection when no interface set', () => {
|
||||
const detection = createMockHardwareInfo({ interface: undefined });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.interface).toBe('eth0');
|
||||
});
|
||||
|
||||
it('returns empty string when no interface info available', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.interface).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when detection has no interfaces', () => {
|
||||
const detection = createMockHardwareInfo({ interface: undefined, interfaces: [] });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.interface).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IP Address Handling', () => {
|
||||
it('does not auto-fill targetIp', () => {
|
||||
const result = getInitialValues(undefined, createMockHardwareInfo(), [], 'test-');
|
||||
expect(result.targetIp).toBe('');
|
||||
});
|
||||
|
||||
it('preserves initial targetIp', () => {
|
||||
const result = getInitialValues(
|
||||
{ targetIp: '192.168.1.200' },
|
||||
createMockHardwareInfo(),
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.targetIp).toBe('192.168.1.200');
|
||||
});
|
||||
|
||||
it('auto-fills currentIp from detection', () => {
|
||||
const detection = createMockHardwareInfo({ ip: '192.168.1.75' });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.currentIp).toBe('192.168.1.75');
|
||||
});
|
||||
|
||||
it('preserves initial currentIp over detection', () => {
|
||||
const detection = createMockHardwareInfo({ ip: '192.168.1.75' });
|
||||
const result = getInitialValues({ currentIp: '192.168.1.80' }, detection, [], 'test-');
|
||||
expect(result.currentIp).toBe('192.168.1.80');
|
||||
});
|
||||
|
||||
it('returns empty currentIp when no detection', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.currentIp).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchematicId Handling', () => {
|
||||
it('uses initial schematicId when provided', () => {
|
||||
const result = getInitialValues({ schematicId: 'custom-123' }, undefined, [], 'test-');
|
||||
expect(result.schematicId).toBe('custom-123');
|
||||
});
|
||||
|
||||
it('returns empty string when no initial schematicId', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.schematicId).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maintenance Mode', () => {
|
||||
it('defaults to true when not provided', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.maintenance).toBe(true);
|
||||
});
|
||||
|
||||
it('respects explicit true value', () => {
|
||||
const result = getInitialValues({ maintenance: true }, undefined, [], 'test-');
|
||||
expect(result.maintenance).toBe(true);
|
||||
});
|
||||
|
||||
it('respects explicit false value', () => {
|
||||
const result = getInitialValues({ maintenance: false }, undefined, [], 'test-');
|
||||
expect(result.maintenance).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combined Scenarios', () => {
|
||||
it('handles adding first control node with full detection', () => {
|
||||
const detection = createMockHardwareInfo();
|
||||
const result = getInitialValues(undefined, detection, [], 'prod-');
|
||||
|
||||
expect(result).toEqual({
|
||||
hostname: 'prod-control-1',
|
||||
role: 'controlplane',
|
||||
disk: '/dev/sda',
|
||||
targetIp: '',
|
||||
currentIp: '192.168.1.50',
|
||||
interface: 'eth0',
|
||||
schematicId: '',
|
||||
maintenance: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles configuring existing node (all initial values)', () => {
|
||||
const initial: Partial<NodeFormData> = {
|
||||
hostname: 'existing-control-1',
|
||||
role: 'controlplane',
|
||||
disk: '/dev/nvme0n1',
|
||||
targetIp: '192.168.1.105',
|
||||
currentIp: '192.168.1.60',
|
||||
interface: 'eth1',
|
||||
schematicId: 'existing-schematic-456',
|
||||
maintenance: false,
|
||||
};
|
||||
|
||||
const result = getInitialValues(initial, undefined, [], 'test-');
|
||||
|
||||
expect(result).toEqual(initial);
|
||||
});
|
||||
|
||||
it('handles adding second control node with partial detection', () => {
|
||||
const nodes = [createMockNode({ hostname: 'test-control-1', role: 'controlplane' })];
|
||||
const detection = createMockHardwareInfo({
|
||||
interfaces: ['enp0s1'],
|
||||
interface: 'enp0s1'
|
||||
});
|
||||
|
||||
const result = getInitialValues(undefined, detection, nodes, 'test-');
|
||||
|
||||
expect(result.hostname).toBe('test-control-2');
|
||||
expect(result.role).toBe('controlplane');
|
||||
expect(result.interface).toBe('enp0s1');
|
||||
});
|
||||
|
||||
it('handles missing detection data', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
|
||||
expect(result).toEqual({
|
||||
hostname: 'test-control-1',
|
||||
role: 'controlplane',
|
||||
disk: '',
|
||||
targetIp: '',
|
||||
currentIp: '',
|
||||
interface: '',
|
||||
schematicId: '',
|
||||
maintenance: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles partial detection data', () => {
|
||||
const detection: HardwareInfo = {
|
||||
ip: '192.168.1.50',
|
||||
};
|
||||
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
|
||||
expect(result.currentIp).toBe('192.168.1.50');
|
||||
expect(result.disk).toBe('');
|
||||
expect(result.interface).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
67
src/components/nodes/NodeFormDrawer.tsx
Normal file
67
src/components/nodes/NodeFormDrawer.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Drawer } from '../ui/drawer';
|
||||
import { HardwareDetectionDisplay } from './HardwareDetectionDisplay';
|
||||
import { NodeForm, type NodeFormData } from './NodeForm';
|
||||
import { NodeStatusBadge } from './NodeStatusBadge';
|
||||
import type { Node, HardwareInfo } from '../../services/api/types';
|
||||
|
||||
interface NodeFormDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'add' | 'configure';
|
||||
node?: Node;
|
||||
detection?: HardwareInfo;
|
||||
onSubmit: (data: NodeFormData) => Promise<void>;
|
||||
onApply?: (data: NodeFormData) => Promise<void>;
|
||||
instanceName?: string;
|
||||
}
|
||||
|
||||
export function NodeFormDrawer({
|
||||
open,
|
||||
onClose,
|
||||
mode,
|
||||
node,
|
||||
detection,
|
||||
onSubmit,
|
||||
onApply,
|
||||
instanceName,
|
||||
}: NodeFormDrawerProps) {
|
||||
const title = mode === 'add' ? 'Add Node to Cluster' : `Configure ${node?.hostname}`;
|
||||
|
||||
return (
|
||||
<Drawer open={open} onClose={onClose} title={title}>
|
||||
{detection && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Hardware Detection Results
|
||||
</h3>
|
||||
<HardwareDetectionDisplay detection={detection} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'configure' && node && (
|
||||
<div className="mb-6">
|
||||
<NodeStatusBadge node={node} showAction />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NodeForm
|
||||
initialValues={node ? {
|
||||
hostname: node.hostname,
|
||||
role: node.role,
|
||||
disk: node.disk,
|
||||
targetIp: node.target_ip,
|
||||
currentIp: node.current_ip,
|
||||
interface: node.interface,
|
||||
schematicId: node.schematic_id,
|
||||
maintenance: node.maintenance ?? true,
|
||||
} : undefined}
|
||||
detection={detection}
|
||||
onSubmit={onSubmit}
|
||||
onApply={onApply}
|
||||
submitLabel={mode === 'add' ? 'Add Node' : 'Save'}
|
||||
showApplyButton={mode === 'configure'}
|
||||
instanceName={instanceName}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
66
src/components/nodes/NodeStatusBadge.tsx
Normal file
66
src/components/nodes/NodeStatusBadge.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ClockIcon,
|
||||
ArrowPathIcon,
|
||||
DocumentCheckIcon,
|
||||
CheckCircleIcon,
|
||||
HeartIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
QuestionMarkCircleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { Node } from '../../services/api/types';
|
||||
import { deriveNodeStatus } from '../../utils/deriveNodeStatus';
|
||||
import { statusDesigns } from '../../config/nodeStatus';
|
||||
|
||||
interface NodeStatusBadgeProps {
|
||||
node: Node;
|
||||
showAction?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const iconComponents = {
|
||||
MagnifyingGlassIcon,
|
||||
ClockIcon,
|
||||
ArrowPathIcon,
|
||||
DocumentCheckIcon,
|
||||
CheckCircleIcon,
|
||||
HeartIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
QuestionMarkCircleIcon
|
||||
};
|
||||
|
||||
export function NodeStatusBadge({ node, showAction = false, compact = false }: NodeStatusBadgeProps) {
|
||||
const status = deriveNodeStatus(node);
|
||||
const design = statusDesigns[status];
|
||||
const IconComponent = iconComponents[design.icon as keyof typeof iconComponents];
|
||||
|
||||
const isSpinning = ['configuring', 'applying', 'provisioning', 'reprovisioning'].includes(status);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${design.color} ${design.bgColor}`}>
|
||||
<IconComponent className={`h-3.5 w-3.5 ${isSpinning ? 'animate-spin' : ''}`} />
|
||||
<span>{design.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`inline-flex flex-col gap-1 px-3 py-2 rounded-lg ${design.color} ${design.bgColor}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent className={`h-5 w-5 ${isSpinning ? 'animate-spin' : ''}`} />
|
||||
<span className="text-sm font-semibold">{design.label}</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-90">{design.description}</p>
|
||||
{showAction && design.nextAction && (
|
||||
<p className="text-xs font-medium mt-1">
|
||||
→ {design.nextAction}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/components/ui/alert.tsx
Normal file
76
src/components/ui/alert.tsx
Normal 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 };
|
||||
95
src/components/ui/drawer.tsx
Normal file
95
src/components/ui/drawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
161
src/config/nodeStatus.ts
Normal 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"
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
133
src/test/utils/nodeFormTestUtils.tsx
Normal file
133
src/test/utils/nodeFormTestUtils.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface CloudConfig {
|
||||
|
||||
export interface TalosConfig {
|
||||
version: string;
|
||||
schematicId?: string;
|
||||
}
|
||||
|
||||
export interface NodesConfig {
|
||||
|
||||
41
src/types/nodeStatus.ts
Normal file
41
src/types/nodeStatus.ts
Normal 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";
|
||||
}
|
||||
61
src/utils/deriveNodeStatus.ts
Normal file
61
src/utils/deriveNodeStatus.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user