Files
wild-web-app/src/components/ClusterNodesComponent.tsx
2025-11-08 23:16:42 +00:00

703 lines
26 KiB
TypeScript

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, RotateCcw } from 'lucide-react';
import { useInstanceContext } from '../hooks/useInstanceContext';
import { useNodes, useDiscoveryStatus } from '../hooks/useNodes';
import { useCluster } from '../hooks/useCluster';
import { useClusterStatus } from '../services/api/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();
const {
nodes,
isLoading,
error,
addNode,
addError,
deleteNode,
isDeleting,
deleteError,
discover,
isDiscovering,
discoverError: discoverMutationError,
getHardware,
isGettingHardware,
getHardwareError,
cancelDiscovery,
isCancellingDiscovery,
updateNode,
applyNode,
isApplying,
resetNode,
isResetting,
refetch
} = useNodes(currentInstance);
const {
data: discoveryStatus
} = useDiscoveryStatus(currentInstance);
const {
status: clusterStatus
} = useCluster(currentInstance);
const { data: clusterStatusData } = useClusterStatus(currentInstance || '');
const [discoverSubnet, setDiscoverSubnet] = useState('');
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]);
useEffect(() => {
if (getHardwareError) {
const errorMsg = (getHardwareError as any)?.message || 'Failed to detect hardware';
setDetectError(errorMsg);
}
}, [getHardwareError]);
// Track previous discovery status to detect completion
const [prevDiscoveryActive, setPrevDiscoveryActive] = useState<boolean | null>(null);
// 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.`);
} else {
setDiscoverSuccess(`Discovery complete! Found ${count} node${count !== 1 ? 's' : ''}.`);
}
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' ? (
<Cpu className="h-4 w-4" />
) : (
<HardDrive className="h-4 w-4" />
);
};
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,
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 handleResetNode = (node: Node) => {
if (
confirm(
`Reset node ${node.hostname}?\n\nThis will wipe the node and return it to maintenance mode. The node will need to be reconfigured.`
)
) {
resetNode(node.hostname);
}
};
const handleDeleteNode = (hostname: string) => {
if (!currentInstance) return;
if (confirm(`Are you sure you want to remove node ${hostname}?`)) {
deleteNode(hostname);
}
};
const handleDiscover = () => {
setDiscoverError(null);
setDiscoverSuccess(null);
// Pass subnet only if it's not empty, otherwise auto-detect
discover(discoverSubnet || undefined);
};
// Derive status from backend state flags for each node
const assignedNodes = nodes.map(node => {
// Get runtime status from cluster status
const runtimeStatus = clusterStatusData?.node_statuses?.[node.hostname];
let status = 'pending';
if (node.maintenance) {
status = 'provisioning';
} else if (node.configured && !node.applied) {
status = 'connecting';
} else if (node.applied) {
status = 'ready';
}
return {
...node,
status,
isReachable: runtimeStatus?.ready,
inKubernetes: runtimeStatus?.ready, // Whether in cluster (from backend 'ready' field)
kubernetesReady: runtimeStatus?.kubernetes_ready, // Whether K8s Ready condition is true
};
});
// 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) {
return (
<Card className="p-8 text-center">
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground mb-4">
Please select or create an instance to manage nodes.
</p>
</Card>
);
}
// Show error state
if (error) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Nodes</h3>
<p className="text-muted-foreground mb-4">
{(error as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-950/20 dark:to-blue-950/20 border-cyan-200 dark:border-cyan-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-cyan-100 dark:bg-cyan-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-cyan-600 dark:text-cyan-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-cyan-900 dark:text-cyan-100 mb-2">
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"
(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
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">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about distributed computing
</Button>
</div>
</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">
<Network className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Cluster Nodes</h2>
<p className="text-muted-foreground">
Connect machines to your wild-cloud
</p>
</div>
</div>
{isLoading ? (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading nodes...</p>
</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 specific subnet or leave empty to auto-detect all local networks
</p>
<div className="flex gap-3 mb-4">
<Input
type="text"
value={discoverSubnet}
onChange={(e) => setDiscoverSubnet(e.target.value)}
placeholder="192.168.1.0/24 (optional - leave empty to auto-detect)"
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>
{assignedNodes.map((node) => (
<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)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.hostname}</h4>
<Badge variant="outline" className="text-xs">
{node.role}
</Badge>
</div>
<div className="text-sm text-muted-foreground mb-2">
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 mt-2">
{node.hardware.cpu && (
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.hardware.cpu}
</span>
)}
{node.hardware.memory && (
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.hardware.memory}
</span>
)}
{node.hardware.disk && (
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.hardware.disk}
</span>
)}
</div>
)}
{(node.version || node.schematic_id) && (
<div className="text-xs text-muted-foreground mt-1">
{node.version && <span>Talos: {node.version}</span>}
{node.version && node.schematic_id && <span> </span>}
{node.schematic_id && (
<span
title={node.schematic_id}
onClick={() => {
navigator.clipboard.writeText(node.schematic_id!);
}}
className="cursor-pointer hover:text-primary hover:underline"
>
Schema: {node.schematic_id.substring(0, 8)}...
</span>
)}
</div>
)}
</div>
<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>
)}
{!node.maintenance && (node.configured || node.applied) && (
<Button
size="sm"
variant="outline"
onClick={() => handleResetNode(node)}
disabled={isResetting}
className="border-orange-500 text-orange-500 hover:bg-orange-50 hover:text-orange-600"
>
<RotateCcw className="h-4 w-4 mr-1" />
Reset
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteNode(node.hostname)}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'}
</Button>
</div>
</div>
</Card>
))}
{assignedNodes.length === 0 && (
<Card className="p-8 text-center">
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Nodes</h3>
<p className="text-muted-foreground mb-4">
Use the discover or auto-detect buttons above to find nodes on your network.
</p>
</Card>
)}
</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>
);
}