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 { 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, deleteError, discover, isDiscovering, discoverError: discoverMutationError, getHardware, isGettingHardware, getHardwareError, cancelDiscovery, isCancellingDiscovery, updateNode, applyNode, isApplying, refetch } = useNodes(currentInstance); const { data: discoveryStatus } = useDiscoveryStatus(currentInstance); const { status: clusterStatus } = useCluster(currentInstance); const { data: clusterStatusData } = useClusterStatus(currentInstance || ''); 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 [deletingNodeHostname, setDeletingNodeHostname] = useState(null); 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, 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, target_ip: data.targetIp, 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 = async (hostname: string) => { if (!currentInstance) return; if (confirm(`Reset and remove node ${hostname}?\n\nThis will reset the node and remove it from the cluster. The node will reboot to maintenance mode and can be reconfigured.`)) { setDeletingNodeHostname(hostname); try { await deleteNode(hostname); } finally { setDeletingNodeHostname(null); } } }; const handleDiscover = () => { setDiscoverError(null); setDiscoverSuccess(null); // Always use auto-detect to scan all local networks discover(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 // Status is "not_bootstrapped" when kubeconfig doesn't exist // Any other status (ready, degraded, unreachable) means cluster is bootstrapped const hasBootstrapped = clusterStatus?.status !== 'not_bootstrapped'; 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'}

)} {/* ADD NODES SECTION - Discovery and manual add combined */}

Add Nodes to Cluster

Discover nodes on the network or manually add by IP address

{/* Discovery button */}
{(isDiscovering || discoveryStatus?.active) && ( )}
{/* Discovered nodes display */} {discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (
{discoveryStatus.nodes_found.map((discovered) => (

{discovered.ip}

{discovered.version && discovered.version !== 'maintenance' && (

{discovered.version}

)}
))}
)} {/* Manual add by IP - styled like a list item */}
setAddNodeIp(e.target.value)} placeholder="192.168.8.128" className="flex-1 font-mono" />

Add a node by IP address if not discovered automatically

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 && ( )}
))} {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 */}
); }