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, Radio, RadioTower, AlertTriangle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { buildTunnelsGraph, buildAliasesGraph, type MeshNodeData, } from '@/lib/mesh-topology-layout'; import type { MeshAlias, MeshNodeStatus, MeshReachableMode } from '@/types/mesh'; 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)'; const MINIMAP_DESTRUCTIVE = 'oklch(0.65 0.2 28)'; export type MeshGraphEdgeMode = 'tunnels' | 'aliases'; interface MeshTopologyGraphProps { status: MeshNodeStatus[]; aliases: MeshAlias[]; edgeMode: MeshGraphEdgeMode; onNodeClick?: (nodeId: number) => void; } function statusDot(node: MeshNodeStatus): string { if (node.reachableMode === 'unreachable') return 'bg-destructive'; if (node.reachableMode === 'pilot' && !node.pilotConnected) return 'bg-warning'; if (!node.enabled) return 'bg-muted-foreground'; return 'bg-success'; } function reachableModeLabel(mode: MeshReachableMode): string { switch (mode) { case 'local': return 'local'; case 'pilot': return 'pilot'; case 'proxy': return 'proxy'; case 'unreachable': return 'unreachable'; } } function ModeIcon({ mode, connected }: { mode: MeshReachableMode; connected: boolean }) { if (mode === 'unreachable') return ; if (mode === 'pilot' && !connected) return ; if (mode === 'pilot') return ; return null; } function MeshNodeCard({ data, selected }: { data: MeshNodeData; selected?: boolean }) { const node = data.node; const isLocal = node.reachableMode === 'local'; const dimmed = node.reachableMode === 'unreachable'; const optedInCount = node.optedInStacks.length; const stackLabel = optedInCount === 1 ? 'stack' : 'stacks'; const streamLabel = node.activeStreamCount === 1 ? 'stream' : 'streams'; return (
{node.nodeName}
mode {reachableModeLabel(node.reachableMode)}
opted in {optedInCount} {stackLabel}
{data.aliasCount > 0 && (
publishes {data.aliasCount}
)}
{node.activeStreamCount} {streamLabel} {node.reachableMode === 'unreachable' && node.reachableReason ? ( ยท {node.reachableReason} ) : null}
); } const nodeTypes: NodeTypes = { meshNode: MeshNodeCard, }; function nodeStateEqual(a: MeshNodeStatus, b: MeshNodeStatus): boolean { return a.nodeId === b.nodeId && a.enabled === b.enabled && a.reachableMode === b.reachableMode && a.pilotConnected === b.pilotConnected && a.reachableReason === b.reachableReason && a.activeStreamCount === b.activeStreamCount && a.optedInStacks.length === b.optedInStacks.length; } export function MeshTopologyGraph({ status, aliases, edgeMode, onNodeClick }: MeshTopologyGraphProps) { // Mirror FleetTopology's onNodeClick ref pattern: handlers may change on every // parent render, but ReactFlow's onNodeClick is stable, so route through a ref // to avoid recreating the callback (and thus rebuilding the flow) on every render. const onNodeClickRef = useRef(onNodeClick); onNodeClickRef.current = onNodeClick; // Shape key: relayout only when nodes are added/removed or reachability flips. const shapeKey = useMemo( () => status .map((n) => `${n.nodeId}:${n.reachableMode}:${n.enabled ? 1 : 0}:${n.pilotConnected ? 1 : 0}`) .sort() .join('|'), [status], ); const [flowNodes, setFlowNodes, onNodesChange] = useNodesState([]); const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState([]); useEffect(() => { const result = edgeMode === 'tunnels' ? buildTunnelsGraph(status) : buildAliasesGraph(status, aliases); setFlowNodes(result.nodes); setFlowEdges(result.edges); // Intentionally exclude `status` and `aliases` raw refs so we only relayout when // the shape (or selected edgeMode) changes; per-poll metric tweaks fall through // the live-update effect below without resetting user-dragged positions. // eslint-disable-next-line react-hooks/exhaustive-deps }, [shapeKey, edgeMode, setFlowNodes, setFlowEdges]); // Refresh node card data on prop change without touching positions. The /mesh/status // poll yields fresh JSON objects every cycle, so a reference equality check would // replace data on every poll and defeat the purpose. Compare by value instead. useEffect(() => { setFlowNodes((current) => current.map((flowNode) => { const next = status.find((n) => String(n.nodeId) === flowNode.id); if (!next) return flowNode; const existing = (flowNode.data as MeshNodeData | undefined); const aliasCount = edgeMode === 'aliases' ? aliases.filter((a) => a.nodeId === next.nodeId).length : 0; if ( existing && existing.aliasCount === aliasCount && nodeStateEqual(existing.node, next) ) { return flowNode; } return { ...flowNode, data: { node: next, aliasCount, isOwnerView: false } satisfies MeshNodeData }; })); }, [status, aliases, edgeMode, 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 MeshNodeData | undefined; const topo = data?.node; if (!topo) return MINIMAP_MUTED; if (topo.reachableMode === 'unreachable') return MINIMAP_DESTRUCTIVE; if (topo.reachableMode === 'pilot' && !topo.pilotConnected) return MINIMAP_WARNING; if (!topo.enabled) return MINIMAP_MUTED; return MINIMAP_BRAND; }, []); return (
); }