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.'}
)}
{isOffline ? 'Offline' : node.critical ? 'Critical' : 'Online'}
{pilotStale && (
{node.pilotLastSeen
? `Pilot heartbeat ${formatRelative(node.pilotLastSeen)}`
: 'Pilot heartbeat stale'}
)}
{node.critical && !isOffline ? (
) : null}
{isLocal ? 'Local' : 'Remote'}
{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 (
);
}
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.
)}
);
}