Files
sencho/frontend/src/lib/fleet-topology-layout.ts
T
Anso d95e154aeb 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.
2026-04-20 16:16:02 -04:00

91 lines
2.6 KiB
TypeScript

import dagre from '@dagrejs/dagre';
import type { Edge, Node } from '@xyflow/react';
export interface FleetTopologyNode {
id: number;
name: string;
type: 'local' | 'remote';
status: 'online' | 'offline' | 'unknown';
cpuPercent: number;
memPercent: number;
diskPercent: number;
stackCount: number;
runningCount: number;
critical: boolean;
}
export interface FleetNodeData extends Record<string, unknown> {
node: FleetTopologyNode;
}
const NODE_WIDTH = 240;
const NODE_HEIGHT = 150;
// ReactFlow inline styles cannot resolve CSS custom properties, so raw oklch
// values are used. Values match the design tokens in frontend/src/index.css.
const EDGE_BRAND = 'oklch(0.78 0.11 195)';
const EDGE_WARNING = 'oklch(0.75 0.14 75)';
const EDGE_MUTED = 'oklch(0.55 0 0)';
function edgeStyle(remote: FleetTopologyNode): {
stroke: string;
strokeWidth: number;
strokeDasharray?: string;
} {
if (remote.status !== 'online') {
return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' };
}
if (remote.critical) {
return { stroke: EDGE_WARNING, strokeWidth: 1.5 };
}
return { stroke: EDGE_BRAND, strokeWidth: 1.5 };
}
export function layoutFleetGraph(
fleetNodes: FleetTopologyNode[],
): { nodes: Node[]; edges: Edge[] } {
if (fleetNodes.length === 0) return { nodes: [], edges: [] };
const local = fleetNodes.find(n => n.type === 'local') ?? null;
const remotes = fleetNodes.filter(n => n.type !== 'local');
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: 'LR', ranksep: 160, nodesep: 32, marginx: 20, marginy: 20 });
g.setDefaultEdgeLabel(() => ({}));
for (const n of fleetNodes) {
g.setNode(String(n.id), { width: NODE_WIDTH, height: NODE_HEIGHT });
}
if (local) {
for (const r of remotes) {
g.setEdge(String(local.id), String(r.id));
}
}
dagre.layout(g);
const flowNodes: Node[] = fleetNodes.map(n => {
const pos = g.node(String(n.id));
return {
id: String(n.id),
type: 'fleetNode',
position: { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 },
data: { node: n } satisfies FleetNodeData,
draggable: true,
};
});
const flowEdges: Edge[] = local
? remotes.map(r => ({
id: `edge-${local.id}-${r.id}`,
source: String(local.id),
target: String(r.id),
style: edgeStyle(r),
animated: false,
}))
: [];
return { nodes: flowNodes, edges: flowEdges };
}