SPB Git

Repository Public

topology.tsx 4.8 KB 94 lines tsx

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
Files main
Menu

Theme