diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 26ae37ed..416a3fa7 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -29,10 +29,10 @@ The top of the page is a status masthead that summarises the state of the entire Below the masthead, switch between two layouts: - **Grid** (default): one card per node. The local node is pinned at the top with a cyan accent rail and a ★ Local badge so it is never confused with a remote. -- **Topology**: a connection diagram with the local node on the left and remotes radiating to the right. Connector lines colour by link health (cyan when online, amber when critical, dashed rose when offline), and each node chip shows its status dot, CPU and memory readings. Click a node to jump to its details. +- **Topology**: an interactive hub-and-spoke diagram with the local node on the left and remotes radiating to the right. Each node renders as a rack card with a status pill (Online, Critical, or Offline), a Local or Remote badge, CPU, memory, and disk bars, and a stack and running-container summary. The local node is framed with a cyan ring; critical nodes show a lightning icon; offline nodes are muted. Connector lines colour by link health (cyan when online, amber when critical, dashed when offline). Pan, zoom, drag nodes, and use the minimap to navigate large fleets. Click a node to jump to its details. - Fleet Topology view showing the local node on the left with a dashed connector to an offline remote + Fleet Topology view showing the local node connected to a remote, each rendered as a rack card with CPU, memory, and disk bars ### Tabs diff --git a/docs/images/fleet-view/fleet-topology.png b/docs/images/fleet-view/fleet-topology.png index 16c901ed..40d7ea60 100644 Binary files a/docs/images/fleet-view/fleet-topology.png and b/docs/images/fleet-view/fleet-topology.png differ diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 4ec4a628..ca46e450 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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]); diff --git a/frontend/src/components/fleet/FleetTopology.tsx b/frontend/src/components/fleet/FleetTopology.tsx index e7007131..36055018 100644 --- a/frontend/src/components/fleet/FleetTopology.tsx +++ b/frontend/src/components/fleet/FleetTopology.tsx @@ -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 ( -
-

No nodes to plot.

-
- ); - } - - return ( -
-
- - {local - ? remotes.map(r => ( - + + {label} + +
+
- )) - : null} - - - {local ? ( - - ) : null} - {remotes.map(r => ( - - ))} -
-
- ); +
+ + {Math.round(clamped)}% + +
+ ); } -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 ( +
+ + +
+
+ +
+ + {node.name} +
+ +
+ + + +
+ +
+ {node.stackCount} {stackLabel} · {node.runningCount} running +
+ + +
+ ); } -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 ( - - ); +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([]); + const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState([]); + + 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 ( +
+

No nodes to plot.

+
+ ); + } + + return ( +
+
+ + + + + +
+
+ ); } diff --git a/frontend/src/lib/fleet-topology-layout.ts b/frontend/src/lib/fleet-topology-layout.ts new file mode 100644 index 00000000..cda2ee99 --- /dev/null +++ b/frontend/src/lib/fleet-topology-layout.ts @@ -0,0 +1,90 @@ +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 { + 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 }; +}