mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user