import { useState } from 'react'; import { Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight, Layers, Wifi, WifiOff, AlertTriangle, Download, Loader2, MoreVertical, Ban, Pencil, Trash2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { ConfirmModal } from '@/components/ui/modal'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { formatBytes } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { formatVersion } from '@/lib/version'; import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; import { useNodes, type Node } from '@/context/NodeContext'; import { cordonNode, uncordonNode } from '@/lib/nodesApi'; import { UpdateStatusBadge } from './UpdateStatusBadge'; import { StackSection } from './NodeCardStackList'; import type { Label as StackLabel } from '../label-types'; import type { FleetNode, NodeUpdateStatus } from './types'; import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils'; // --- Types --- export interface NodeCardProps { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record; updateStatus?: NodeUpdateStatus; onUpdate?: (nodeId: number) => void; updatingNodeId?: number | null; onRetryUpdate?: (nodeId: number) => void; onDismissUpdate?: (nodeId: number) => void; onCordonChange?: () => void; onEdit?: (node: Node) => void; onDelete?: (node: Node) => void; } // --- Sub-Components --- function UsageBar({ percent, color }: { percent: number; color: string }) { return (
); } // --- Main Export --- export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete }: NodeCardProps) { const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); const [cordonModalOpen, setCordonModalOpen] = useState(false); const [cordonReason, setCordonReason] = useState(''); const [cordonSubmitting, setCordonSubmitting] = useState(false); const { isPaid, license } = useLicense(); const { isAdmin } = useAuth(); const { nodes: registryNodes } = useNodes(); const isAdmiral = isPaid && license?.variant === 'admiral'; const registryNode = registryNodes.find(n => n.id === node.id); const canEdit = Boolean(isAdmin && onEdit && registryNode); const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default); const canCordon = isAdmiral; const showMenu = canEdit || canDelete || canCordon; const isOnline = node.status === 'online'; const isLocal = node.type === 'local'; const formattedVersion = formatVersion(updateStatus?.version); const formattedLatest = formatVersion(updateStatus?.latestVersion); const cpuPercent = getNodeCpu(node); const memPercent = getNodeMem(node); const diskPercent = getNodeDisk(node); const openCordonModal = () => { setCordonReason(''); setCordonModalOpen(true); }; const handleCordonConfirm = async () => { setCordonSubmitting(true); try { if (node.cordoned) { await uncordonNode(node.id); toast.success(`Uncordoned ${node.name}`); } else { await cordonNode(node.id, cordonReason.trim() || null); toast.success(`Cordoned ${node.name}`); } setCordonModalOpen(false); onCordonChange?.(); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to update cordon state'; toast.error(message); } finally { setCordonSubmitting(false); } }; const handleExpand = async () => { const next = !expanded; setExpanded(next); if (next && stacks === null) { setLoadingStacks(true); try { const res = await apiFetch(`/fleet/node/${node.id}/stacks`, { localOnly: true }); if (res.ok) { setStacks(await res.json()); } else { toast.error('Failed to load stacks for ' + node.name); } } catch (error) { console.error('Failed to load stacks for', node.name, error); toast.error('Failed to load stacks for ' + node.name); setExpanded(false); } finally { setLoadingStacks(false); } } }; const localRailClasses = isLocal ? 'relative overflow-hidden ring-1 ring-brand/30 before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-brand before:rounded-l-xl after:pointer-events-none after:absolute after:inset-0 after:bg-gradient-to-r after:from-brand/[0.06] after:via-transparent after:to-transparent' : ''; return (
{/* Card Header */}
{isLocal && ( ★ Local )} {showMenu && (
{canEdit && registryNode && ( onEdit!(registryNode)}> Edit node )} {canDelete && registryNode && ( onDelete!(registryNode)} className="text-destructive focus:text-destructive" > Delete node )} {canCordon && ( {node.cordoned ? 'Uncordon node' : 'Cordon node'} )}
)}

{node.name}

{isOnline ? ( <> Online ) : ( <> Offline )} {node.type} {formattedVersion && ( {formattedVersion} )} {updateStatus?.updateStatus && ( onRetryUpdate(node.id) : undefined} onDismiss={onDismissUpdate ? () => onDismissUpdate(node.id) : undefined} /> )} {updateStatus?.updateAvailable && !updateStatus.updateStatus && ( Update available )} {isOnline && isCritical(node) && ( Critical )} {node.cordoned && ( Cordoned )}
{/* Container Stats */} {isOnline && node.stats && (
{node.stats.active}
Running
{node.stats.exited}
Stopped
{node.stacks?.length ?? '-'}
Stacks
)} {/* Resource Usage Bars */} {isOnline && node.systemStats && (
CPU {node.systemStats.cpu.usage}%
80 ? 'bg-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
RAM {formatBytes(node.systemStats.memory.used, 1)} / {formatBytes(node.systemStats.memory.total, 1)}
80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
{node.systemStats.disk && (
Disk {formatBytes(node.systemStats.disk.used, 1)} / {formatBytes(node.systemStats.disk.total, 1)}
90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} />
)}
)} {/* Update button */} {isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && onUpdate && (
)} {/* Offline placeholder */} {!isOnline && (
Node unreachable
)}
{ if (!cordonSubmitting) setCordonModalOpen(open); }} kicker="Federation" title={node.cordoned ? `Uncordon ${node.name}` : `Cordon ${node.name}`} description={node.cordoned ? 'Re-enable this node for new blueprint placements. Existing deployments are unchanged.' : 'Mark this node as unschedulable. New blueprint deployments will skip it. Existing deployments remain in place.'} confirmLabel={node.cordoned ? 'Uncordon node' : 'Cordon node'} confirming={cordonSubmitting} onConfirm={handleCordonConfirm} > {!node.cordoned && (
setCordonReason(e.target.value)} placeholder="e.g. draining for maintenance" className="w-full h-8 px-2 text-sm rounded-md border border-input bg-background" disabled={cordonSubmitting} />
)}
{/* Expandable Stack List with Container Drill-Down */} {isOnline && (
{expanded && (
{loadingStacks ? (
) : stacks && stacks.length > 0 ? (
{stacks.map(stack => ( ))}
) : (

No stacks found

)}
)}
)}
); }