import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ReactFlow, Background, Controls, MiniMap, useNodesState, useEdgesState, Handle, Position, type Node, type Edge, type NodeTypes, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { Server, Zap, Network, Layers, Move, Ban, Clock, Activity } from 'lucide-react'; import { cn } from '@/lib/utils'; import { layoutFleetGraph, type FleetNodeData, type FleetTopologyNode, type LayoutMode, type SavedPositions, } from '@/lib/fleet-topology-layout'; import { NodeLabelPill } from '@/components/blueprints/NodeLabelPill'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; interface FleetTopologyProps { nodes: FleetTopologyNode[]; onNodeClick?: (nodeId: number) => void; mode: LayoutMode; onModeChange: (mode: LayoutMode) => void; savedPositions: SavedPositions; onPositionsChange: (positions: SavedPositions) => void; } // Raw oklch values for MiniMap coloring (ReactFlow cannot resolve CSS vars // inside inline styles / SVG fills). const MINIMAP_BRAND = 'oklch(0.78 0.11 195)'; const MINIMAP_WARNING = 'oklch(0.75 0.14 75)'; const MINIMAP_MUTED = 'oklch(0.55 0 0)'; const MAX_INLINE_LABELS = 3; const PILOT_STALE_MS = 60_000; function dotClass(node: FleetTopologyNode): string { if (node.status !== 'online') return 'bg-destructive'; if (node.critical) return 'bg-warning'; return 'bg-success'; } function barColor(value: number): string { if (value >= 85) return 'bg-destructive'; if (value >= 60) return 'bg-warning'; return 'bg-brand'; } function MetricBar({ label, value, muted }: { label: string; value: number; muted: boolean }) { const clamped = Math.max(0, Math.min(100, value)); return (
{label}
{Math.round(clamped)}%
); } function formatRelative(timestamp: number): string { const diff = Date.now() - timestamp; if (diff < 60_000) return `${Math.round(diff / 1000)}s ago`; if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`; if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`; return `${Math.round(diff / 86_400_000)}d ago`; } function FleetNodeCard({ data, selected }: { data: FleetNodeData; selected?: boolean }) { const node = data.node; const isLocal = node.type === 'local'; const isOffline = node.status !== 'online'; const stackLabel = node.stackCount === 1 ? 'stack' : 'stacks'; const labels = node.labels ?? []; const visibleLabels = labels.slice(0, MAX_INLINE_LABELS); const overflowLabels = labels.length - visibleLabels.length; const cordoned = Boolean(node.cordoned); const pilotStale = !isOffline && node.nodeMode === 'pilot_agent' && typeof node.pilotLastSeen === 'number' && Date.now() - node.pilotLastSeen > PILOT_STALE_MS; const showLatency = !isLocal && !isOffline && typeof node.latencyMs === 'number'; return (
{cordoned && (
Cordoned
{node.cordonedReason || 'Cordoned: scheduling paused.'}
)}
{node.name}
{labels.length > 0 && (
{visibleLabels.map(l => ( ))} {overflowLabels > 0 && ( +{overflowLabels} {labels.slice(MAX_INLINE_LABELS).join(', ')} )}
)}
{node.stackCount} {stackLabel} · {node.runningCount} running {showLatency && ( {Math.round(node.latencyMs!)}ms )}
); } const nodeTypes: NodeTypes = { fleetNode: FleetNodeCard, }; interface ModeButtonSpec { mode: LayoutMode; label: string; description: string; icon: typeof Network; } const MODE_BUTTONS: ModeButtonSpec[] = [ { mode: 'hub', label: 'Hub', description: 'Local node at the centre, remotes radiating out.', icon: Network }, { mode: 'grouped', label: 'Grouped', description: 'Cluster nodes by their primary label.', icon: Layers }, { mode: 'free', label: 'Free', description: 'Drag nodes anywhere; positions persist in this browser.', icon: Move }, ]; function TopologyToolbar({ mode, onModeChange, }: { mode: LayoutMode; onModeChange: (mode: LayoutMode) => void; }) { return (
Layout {MODE_BUTTONS.map(spec => { const Icon = spec.icon; const active = mode === spec.mode; return ( {spec.description} ); })}
); } export function FleetTopology({ nodes: fleetNodes, onNodeClick, mode, onModeChange, savedPositions, onPositionsChange, }: FleetTopologyProps) { const onNodeClickRef = useRef(onNodeClick); onNodeClickRef.current = onNodeClick; const onPositionsChangeRef = useRef(onPositionsChange); onPositionsChangeRef.current = onPositionsChange; const savedPositionsRef = useRef(savedPositions); savedPositionsRef.current = savedPositions; const effectiveMode: LayoutMode = mode; // Only re-layout when the topology *shape* changes (nodes added/removed, // type/status/label flips, mode flips). Metric value changes alone must // not snap user-dragged nodes back to dagre-computed positions on every // poll. const shapeKey = useMemo( () => effectiveMode + '|' + fleetNodes .map(n => `${n.id}:${n.type}:${n.status}:${(n.labels ?? []).slice().sort().join(',')}`) .sort() .join('|'), [fleetNodes, effectiveMode], ); const [flowNodes, setFlowNodes, onNodesChange] = useNodesState([]); const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState([]); useEffect(() => { const { nodes: nextNodes, edges: nextEdges } = layoutFleetGraph(fleetNodes, { mode: effectiveMode, savedPositions: savedPositionsRef.current, }); setFlowNodes(nextNodes); setFlowEdges(nextEdges); // fleetNodes is referenced via shapeKey; savedPositions is read from // a ref so persisted drags don't trigger a relayout cycle. // eslint-disable-next-line react-hooks/exhaustive-deps }, [shapeKey, setFlowNodes, setFlowEdges]); // Update live metrics on every poll without resetting positions. useEffect(() => { setFlowNodes(current => current.map(flowNode => { const next = fleetNodes.find(n => String(n.id) === flowNode.id); if (!next) return flowNode; const existingData = flowNode.data as FleetNodeData | undefined; const existing = existingData?.node; if (existing && existing.cpuPercent === next.cpuPercent && existing.memPercent === next.memPercent && existing.diskPercent === next.diskPercent && existing.stackCount === next.stackCount && existing.runningCount === next.runningCount && existing.critical === next.critical && existing.cordoned === next.cordoned && existing.cordonedReason === next.cordonedReason && existing.latencyMs === next.latencyMs && existing.pilotLastSeen === next.pilotLastSeen) { return flowNode; } return { ...flowNode, data: { node: next, clusterLabel: existingData?.clusterLabel } satisfies FleetNodeData, }; })); }, [fleetNodes, setFlowNodes]); const handleNodeClick = useCallback((_event: React.MouseEvent, flowNode: Node) => { const id = Number(flowNode.id); if (!Number.isNaN(id)) { onNodeClickRef.current?.(id); } }, []); const handleNodeDragStop = useCallback((_event: MouseEvent | TouchEvent, _node: Node, allDragged: Node[]) => { if (effectiveMode !== 'free') return; setFlowNodes(current => { const dragged = new Map(allDragged.map(n => [n.id, n.position])); const validIds = new Set(current.map(n => n.id)); const merged: SavedPositions = {}; for (const n of current) { const pos = dragged.get(n.id) ?? n.position; merged[n.id] = { x: pos.x, y: pos.y }; } // Drop any stale saved entries for nodes no longer present. for (const key of Object.keys(savedPositionsRef.current)) { if (!validIds.has(key)) delete merged[key]; } onPositionsChangeRef.current(merged); return current; }); }, [effectiveMode, setFlowNodes]); const miniMapNodeColor = useCallback((n: Node) => { const data = n.data as FleetNodeData | undefined; const topo = data?.node; if (!topo || topo.status !== 'online') return MINIMAP_MUTED; if (topo.critical) return MINIMAP_WARNING; return MINIMAP_BRAND; }, []); if (fleetNodes.length === 0) { return (

No nodes to plot.

); } const allRemotesUnlabeled = effectiveMode === 'grouped' && fleetNodes.filter(n => n.type !== 'local').every(n => (n.labels ?? []).length === 0); return (
{allRemotesUnlabeled && (
No node labels assigned. Add labels in Settings · Nodes to see remotes cluster.
)}
); }