Repository Public
1'use client';2/** Topology graph. Built ONLY from ClusterOverview.nodes / edges; nothing is inferred or invented. */3import { useMemo } from 'react';4import { Background, Controls, Handle, Position, ReactFlow, type Edge, type Node, type NodeProps } from '@xyflow/react';5import '@xyflow/react/dist/style.css';6import type { ClusterNode, ClusterOverview } from '@cr/shared';7import { StatusDot } from '@/components/ui/badge';8import { DASH, fmtPct } from '@/lib/format';9import { cn } from '@/lib/cn';10import { useTheme } from '@/stores/theme';11import { effectiveHealth, memPct, tierOf, TIER_ORDER } from './util';1213type NodeData = { node: ClusterNode };14const W = 176, H = 76, GAP_X = 28, GAP_Y = 56, PER_ROW = 6;15const BORDER = { ONLINE: 'border-success-vivid/60', DEGRADED: 'border-warning-vivid/70', OFFLINE: 'border-error-vivid/70', UNKNOWN: 'border-line-3 border-dashed' } as const;1617function ClusterNodeCard({ data }: NodeProps<Node<NodeData>>) {18 const n = data.node;19 const h = effectiveHealth(n);20 return (21 <div className={cn('rounded-[12px] border bg-surface px-2.5 py-2 text-left shadow-card', BORDER[h], h === 'UNKNOWN' && 'opacity-80')} style={{ width: W, height: H }} data-testid="topology-node" data-health={h}>22 <Handle type="target" position={Position.Top} className="!size-1 !border-0 !bg-transparent" isConnectable={false} />23 <div className="flex items-center gap-1.5">24 <StatusDot health={h} />25 <span className="truncate text-xs font-semibold text-text">{n.id}</span>26 {n.isHost && <span className="rounded-[4px] bg-accent/15 px-1 text-[9px] font-semibold uppercase text-accent">host</span>}27 </div>28 <p className="mt-0.5 truncate text-[10.5px] text-muted">{[n.model, n.chip].filter(Boolean).join(' · ') || DASH}</p>29 <p className={cn('tnum mt-1 text-[10.5px]', n.stale ? 'text-muted' : 'text-text-2')}>30 {h === 'UNKNOWN' ? 'UNKNOWN' : `CPU ${fmtPct(n.cpuPct)} · Mem ${fmtPct(memPct(n))}`}31 </p>32 <Handle type="source" position={Position.Bottom} className="!size-1 !border-0 !bg-transparent" isConnectable={false} />33 </div>34 );35}36const nodeTypes = { cluster: ClusterNodeCard };37const EDGE_STYLE = {38 lan: { stroke: 'var(--color-line-3)', strokeWidth: 1.25 },39 tunnel: { stroke: 'var(--color-accent)', strokeOpacity: 0.65, strokeWidth: 1.25, strokeDasharray: '5 4' },40 service: { stroke: 'var(--color-warning-vivid)', strokeOpacity: 0.8, strokeWidth: 1.25, strokeDasharray: '1 4' },41} as const;4243export default function Topology({ overview, onSelect }: { overview: ClusterOverview; onSelect: (id: string) => void }) {44 const theme = useTheme((s) => s.theme);45 const { nodes, edges } = useMemo(() => {46 const out: Node<NodeData>[] = [];47 let y = 0;48 for (const tier of TIER_ORDER) {49 const group = overview.nodes.filter((n) => tierOf(n) === tier);50 for (let i = 0; i < group.length; i += PER_ROW) {51 const row = group.slice(i, i + PER_ROW);52 const width = row.length * W + (row.length - 1) * GAP_X;53 row.forEach((n, j) => out.push({ id: n.id, type: 'cluster', position: { x: -width / 2 + j * (W + GAP_X), y }, data: { node: n }, draggable: false, connectable: false }));54 y += H + GAP_Y;55 }56 }57 const ids = new Set(overview.nodes.map((n) => n.id));58 const es: Edge[] = overview.edges59 .filter((e) => ids.has(e.from) && ids.has(e.to))60 .map((e, i) => ({ id: `${e.from}-${e.to}-${e.kind}-${i}`, source: e.from, target: e.to, type: 'straight', style: EDGE_STYLE[e.kind], focusable: false, selectable: false }));61 return { nodes: out, edges: es };62 }, [overview]);63 return (64 <div className="relative h-full w-full" data-testid="topology">65 <ReactFlow66 nodes={nodes}67 edges={edges}68 nodeTypes={nodeTypes}69 fitView70 fitViewOptions={{ padding: 0.08, maxZoom: 1.5 }}71 minZoom={0.2}72 maxZoom={1.6}73 nodesConnectable={false}74 nodesDraggable={false}75 elementsSelectable76 onNodeClick={(_, n) => onSelect(n.id)}77 proOptions={{ hideAttribution: true }}78 colorMode={theme}79 >80 <Background color="var(--color-line-2)" gap={24} />81 <Controls showInteractive={false} position="bottom-right" />82 </ReactFlow>83 <ul className="pointer-events-none absolute left-3 top-3 flex flex-col gap-1 rounded-sm border border-line-2 bg-surface/90 px-2.5 py-2 text-[10.5px] text-text-2" aria-label="Edge legend">84 {(['lan', 'tunnel', 'service'] as const).map((k) => (85 <li key={k} className="flex items-center gap-2">86 <svg width="26" height="6" aria-hidden><line x1="0" y1="3" x2="26" y2="3" {...EDGE_STYLE[k]} strokeWidth={1.5} /></svg>87 {k === 'lan' ? 'LAN' : k === 'tunnel' ? 'Tunnel' : 'Service'}88 </li>89 ))}90 </ul>91 </div>92 );93}94