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(null); const [detectError, setDetectError] = useState(null); const [discoverSuccess, setDiscoverSuccess] = useState(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(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' ? ( ) : ( ); }; 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 (

No Instance Selected

Please select or create an instance to manage nodes.

); } // Show error state if (error) { return (

Error Loading Nodes

{(error as Error)?.message || 'An error occurred'}

); } return (
{/* Educational Intro Section */}

What are Cluster Nodes?

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.

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.

{/* Bootstrap Alert */} {needsBootstrap && firstReadyControl && (

First Control Plane Node Ready!

Your first control plane node ({firstReadyControl.hostname}) is ready. Bootstrap the cluster to initialize etcd and start Kubernetes control plane components.

)}

Cluster Nodes

Connect machines to your wild-cloud

{isLoading ? (

Loading nodes...

) : ( <> {/* Error and Success Alerts */} {discoverError && ( setDiscoverError(null)} className="mb-4">
Discovery Failed

{discoverError}

)} {discoverSuccess && ( setDiscoverSuccess(null)} className="mb-4">
Discovery Successful

{discoverSuccess}

)} {detectError && ( setDetectError(null)} className="mb-4">
Auto-Detect Failed

{detectError}

)} {addError && ( {}} className="mb-4">
Failed to Add Node

{(addError as any)?.message || 'An error occurred'}

)} {deleteError && ( {}} className="mb-4">
Failed to Remove Node

{(deleteError as any)?.message || 'An error occurred'}

)} {/* DISCOVERY SECTION - Scan subnet for nodes */}

Discover Nodes on Network

Scan a specific subnet or leave empty to auto-detect all local networks

setDiscoverSubnet(e.target.value)} placeholder="192.168.1.0/24 (optional - leave empty to auto-detect)" className="flex-1" /> {(isDiscovering || discoveryStatus?.active) && ( )}
{discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (

Discovered {discoveryStatus.nodes_found.length} node(s)

{discoveryStatus.nodes_found.map((discovered) => (

{discovered.ip}

Maintenance mode{discovered.version ? ` • Talos ${discovered.version}` : ''}

{discovered.hostname && (

{discovered.hostname}

)}
))}
)}
{/* ADD NODE SECTION - Add single node by IP */}

Add Single Node

Add a node by IP address to detect hardware and configure

setAddNodeIp(e.target.value)} placeholder="192.168.8.128" className="flex-1" />

Cluster Nodes ({assignedNodes.length})

{assignedNodes.map((node) => (
{getRoleIcon(node.role)}

{node.hostname}

{node.role}
Target: {node.target_ip}
{node.disk && (
Disk: {node.disk}
)} {node.hardware && (
{node.hardware.cpu && ( {node.hardware.cpu} )} {node.hardware.memory && ( {node.hardware.memory} )} {node.hardware.disk && ( {node.hardware.disk} )}
)} {(node.version || node.schematic_id) && (
{node.version && Talos: {node.version}} {node.version && node.schematic_id && } {node.schematic_id && ( { navigator.clipboard.writeText(node.schematic_id!); }} className="cursor-pointer hover:text-primary hover:underline" > Schema: {node.schematic_id.substring(0, 8)}... )}
)}
{node.configured && !node.applied && ( )} {!node.maintenance && (node.configured || node.applied) && ( )}
))} {assignedNodes.length === 0 && (

No Nodes

Use the discover or auto-detect buttons above to find nodes on your network.

)}
)}
{/* Bootstrap Modal */} {showBootstrapModal && bootstrapNode && ( { setShowBootstrapModal(false); setBootstrapNode(null); refetch(); }} /> )} {/* Node Form Drawer */}
); }