feat(fleet): interactive topology with ReactFlow hub-and-spoke layout (#713)

Replace the static SVG fleet topology with a ReactFlow canvas laid out
via dagre. Each node renders as a rack card with status pill, type
badge, CPU/MEM/DISK bars, and stack/running counts. Pan, zoom, drag,
and minimap are enabled; user-dragged positions persist across the
30-second poll so live metric updates no longer reset layout.
This commit is contained in:
Anso
2026-04-20 16:16:02 -04:00
committed by GitHub
parent 6f132b7ffe
commit d95e154aeb
5 changed files with 303 additions and 159 deletions
+3
View File
@@ -993,6 +993,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
status: n.status,
cpuPercent: getNodeCpu(n),
memPercent: getNodeMem(n),
diskPercent: getNodeDisk(n),
stackCount: n.stacks?.length ?? 0,
runningCount: n.stats?.active ?? 0,
critical: n.status === 'online' && isCritical(n),
})), [processedNodes]);
+208 -157
View File
@@ -1,172 +1,223 @@
import { useMemo } from 'react';
import { Server } from 'lucide-react';
interface TopologyNode {
id: number;
name: string;
type: 'local' | 'remote';
status: 'online' | 'offline' | 'unknown';
cpuPercent: number;
memPercent: number;
critical: boolean;
}
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 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
layoutFleetGraph,
type FleetNodeData,
type FleetTopologyNode,
} from '@/lib/fleet-topology-layout';
interface FleetTopologyProps {
nodes: TopologyNode[];
onNodeClick?: (nodeId: number) => void;
nodes: FleetTopologyNode[];
onNodeClick?: (nodeId: number) => void;
}
interface Positioned {
node: TopologyNode;
x: number;
y: number;
// 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)';
function dotClass(node: FleetTopologyNode): string {
if (node.status !== 'online') return 'bg-destructive';
if (node.critical) return 'bg-warning';
return 'bg-success';
}
const LOCAL_X = 140;
const LOCAL_Y = 260;
const REMOTE_X_START = 460;
const REMOTE_X_STEP = 220;
const REMOTE_Y_AMPLITUDE = 160;
const CANVAS_WIDTH = 1180;
const CANVAS_HEIGHT = 520;
function layoutRemotes(remotes: TopologyNode[]): Positioned[] {
if (remotes.length === 0) return [];
const positioned: Positioned[] = [];
const cols = Math.min(3, Math.max(1, Math.ceil(remotes.length / 3)));
const perCol = Math.ceil(remotes.length / cols);
for (let i = 0; i < remotes.length; i += 1) {
const col = Math.floor(i / perCol);
const row = i % perCol;
const x = REMOTE_X_START + col * REMOTE_X_STEP;
const yCenter = LOCAL_Y;
const offset = perCol === 1
? 0
: (row - (perCol - 1) / 2) * (REMOTE_Y_AMPLITUDE / Math.max(1, perCol - 1)) * 2;
positioned.push({ node: remotes[i], x, y: yCenter + offset });
}
return positioned;
function barColor(value: number): string {
if (value >= 85) return 'bg-destructive';
if (value >= 60) return 'bg-warning';
return 'bg-brand';
}
function linkClass(node: TopologyNode): string {
if (node.status !== 'online') return 'stroke-destructive/40';
if (node.critical) return 'stroke-warning/60';
return 'stroke-brand/40';
}
function dotClass(node: TopologyNode): string {
if (node.status !== 'online') return 'bg-destructive';
if (node.critical) return 'bg-warning';
return 'bg-success';
}
export function FleetTopology({ nodes, onNodeClick }: FleetTopologyProps) {
const local = useMemo(() => nodes.find(n => n.type === 'local') ?? null, [nodes]);
const remotes = useMemo(() => layoutRemotes(nodes.filter(n => n.type === 'remote')), [nodes]);
if (!local && remotes.length === 0) {
function MetricBar({ label, value, muted }: { label: string; value: number; muted: boolean }) {
const clamped = Math.max(0, Math.min(100, value));
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center">
<p className="text-sm text-stat-subtitle">No nodes to plot.</p>
</div>
);
}
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
<div className="relative overflow-hidden rounded-md" style={{ height: CANVAS_HEIGHT }}>
<svg
viewBox={`0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`}
className="absolute inset-0 h-full w-full"
preserveAspectRatio="xMidYMid meet"
>
{local
? remotes.map(r => (
<line
key={`link-${r.node.id}`}
x1={LOCAL_X + 48}
y1={LOCAL_Y}
x2={r.x}
y2={r.y}
strokeWidth={1}
strokeDasharray={r.node.status === 'online' ? undefined : '4 4'}
className={linkClass(r.node)}
<div className="flex items-center gap-2">
<span className="w-9 font-mono text-[9px] uppercase tracking-[0.2em] text-muted-foreground">
{label}
</span>
<div className="flex-1 h-1 rounded-full bg-muted/60 overflow-hidden">
<div
className={cn('h-full rounded-full transition-[width] duration-300', muted ? 'bg-muted-foreground/40' : barColor(value))}
style={{ width: `${clamped}%` }}
/>
))
: null}
</svg>
{local ? (
<NodeChip
node={local}
x={LOCAL_X}
y={LOCAL_Y}
size="lg"
canvasWidth={CANVAS_WIDTH}
canvasHeight={CANVAS_HEIGHT}
onClick={onNodeClick}
/>
) : null}
{remotes.map(r => (
<NodeChip
key={r.node.id}
node={r.node}
x={r.x}
y={r.y}
size="md"
canvasWidth={CANVAS_WIDTH}
canvasHeight={CANVAS_HEIGHT}
onClick={onNodeClick}
/>
))}
</div>
</div>
);
</div>
<span className="w-8 text-right font-mono text-[10px] tabular-nums text-stat-value">
{Math.round(clamped)}%
</span>
</div>
);
}
interface NodeChipProps {
node: TopologyNode;
x: number;
y: number;
size: 'md' | 'lg';
canvasWidth: number;
canvasHeight: number;
onClick?: (nodeId: number) => void;
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';
return (
<div
className={cn(
'w-[240px] rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors',
'hover:border-t-card-border-hover cursor-pointer',
isLocal && 'ring-1 ring-brand/40',
isOffline && 'opacity-70',
selected && 'ring-1 ring-brand',
)}
>
<Handle type="target" position={Position.Left} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
<span aria-hidden="true" className={cn('h-2 w-2 rounded-full shrink-0', dotClass(node))} />
<span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">
{isOffline ? 'Offline' : node.critical ? 'Critical' : 'Online'}
</span>
<span className="ml-auto flex items-center gap-1.5">
{node.critical && !isOffline ? (
<Zap className="h-3 w-3 text-warning" strokeWidth={2} aria-label="Critical" />
) : null}
<span className={cn(
'font-mono text-[9px] uppercase tracking-[0.22em]',
isLocal ? 'text-brand' : 'text-muted-foreground',
)}>
{isLocal ? 'Local' : 'Remote'}
</span>
</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-card-border">
<Server className="h-3.5 w-3.5 text-stat-icon shrink-0" strokeWidth={1.5} />
<span className="text-xs font-medium text-stat-value truncate">{node.name}</span>
</div>
<div className="px-3 py-2 space-y-1 border-b border-card-border">
<MetricBar label="CPU" value={node.cpuPercent} muted={isOffline} />
<MetricBar label="MEM" value={node.memPercent} muted={isOffline} />
<MetricBar label="DISK" value={node.diskPercent} muted={isOffline} />
</div>
<div className="px-3 py-1.5 font-mono text-[10px] tabular-nums text-muted-foreground">
{node.stackCount} {stackLabel} · {node.runningCount} running
</div>
<Handle type="source" position={Position.Right} className="!bg-muted-foreground !w-1.5 !h-1.5 !border-0" />
</div>
);
}
function NodeChip({ node, x, y, size, canvasWidth, canvasHeight, onClick }: NodeChipProps) {
const isLg = size === 'lg';
const width = isLg ? 200 : 180;
const height = isLg ? 96 : 80;
const isLocal = node.type === 'local';
return (
<button
type="button"
onClick={() => onClick?.(node.id)}
className={`absolute flex flex-col items-start gap-1 rounded-lg border border-card-border border-t-card-border-top bg-card px-3 py-2 text-left shadow-card-bevel transition-colors hover:border-t-card-border-hover ${isLocal ? 'ring-1 ring-brand/40' : ''}`}
style={{
left: `${((x - width / 2) / canvasWidth) * 100}%`,
top: `${((y - height / 2) / canvasHeight) * 100}%`,
width,
height,
}}
>
<div className="flex items-center gap-2">
<span aria-hidden="true" className={`h-2 w-2 rounded-full ${dotClass(node)}`} />
<Server className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
<span className="font-mono text-xs text-stat-value truncate">{node.name}</span>
{isLocal ? (
<span className="ml-auto font-mono text-[9px] uppercase tracking-[0.22em] text-brand">Local</span>
) : null}
</div>
<div className="flex items-baseline gap-2 font-mono text-[11px] tabular-nums text-stat-subtitle">
<span>CPU {node.cpuPercent.toFixed(0)}%</span>
<span className="text-stat-icon">·</span>
<span>MEM {node.memPercent.toFixed(0)}%</span>
</div>
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle/80">
{node.status === 'online' ? (node.critical ? 'critical' : 'online') : 'offline'}
</div>
</button>
);
const nodeTypes: NodeTypes = {
fleetNode: FleetNodeCard,
};
export function FleetTopology({ nodes: fleetNodes, onNodeClick }: FleetTopologyProps) {
const onNodeClickRef = useRef(onNodeClick);
onNodeClickRef.current = onNodeClick;
// Only re-layout when the topology *shape* changes (nodes added/removed,
// type or status flips). Metric value changes alone must not snap
// user-dragged nodes back to the dagre-computed positions on every poll.
const shapeKey = useMemo(
() => fleetNodes.map(n => `${n.id}:${n.type}:${n.status}`).sort().join('|'),
[fleetNodes],
);
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState<Node>([]);
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState<Edge>([]);
useEffect(() => {
const { nodes: nextNodes, edges: nextEdges } = layoutFleetGraph(fleetNodes);
setFlowNodes(nextNodes);
setFlowEdges(nextEdges);
// fleetNodes is intentionally excluded: we only relayout on shape changes.
// 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 existing = (flowNode.data as FleetNodeData | undefined)?.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) {
return flowNode;
}
return { ...flowNode, data: { node: next } 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 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 (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center">
<p className="text-sm text-muted-foreground">No nodes to plot.</p>
</div>
);
}
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
<div className="h-[560px] w-full">
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}
nodesConnectable={false}
className="bg-background"
>
<Background gap={20} size={1} className="opacity-30" />
<Controls
className="!bg-card !border-card-border !shadow-card-bevel [&>button]:!bg-card [&>button]:!border-card-border [&>button]:!text-foreground [&>button:hover]:!bg-muted"
showInteractive={false}
/>
<MiniMap
className="!bg-card !border-card-border !shadow-card-bevel"
nodeColor={miniMapNodeColor}
maskColor="oklch(0 0 0 / 0.2)"
pannable
zoomable
/>
</ReactFlow>
</div>
</div>
);
}