diff --git a/docs/features/sencho-mesh.mdx b/docs/features/sencho-mesh.mdx index 55e5b9e3..9851b67f 100644 --- a/docs/features/sencho-mesh.mdx +++ b/docs/features/sencho-mesh.mdx @@ -94,6 +94,19 @@ This is the first place to look when a connection isn't behaving as expected. The masthead has a **Mesh activity** button that opens the fleet-wide event log. Every route resolution, tunnel state change, opt-in, opt-out, and probe is recorded there. Filter by alias, source, type, or message. Useful for understanding what just happened when something flips state. +## Topology view + +The Routing tab has a **Table** / **Graph** toggle in its header. Graph mode draws the fleet as a node-and-edge diagram so it is clear at a glance which nodes are meshed, which tunnels are live, and where aliases are published. + +A second toggle picks what the edges encode: + +- **Tunnels** shows one edge per remote node, coloured by tunnel state. A solid brand edge labelled `pilot · ok` or `proxy` means traffic is ready to flow. A dashed muted edge labelled `pilot · idle` means the Pilot agent is offline. A dashed red edge labelled `unreachable` means the credentials or remote build cannot carry mesh traffic; the node card shows the specific reason. +- **Aliases** keeps the same node layout and labels each edge with the number of aliases the remote node publishes. A remote that publishes nothing reads `no aliases`. + +Click any node card to open the opt-in sheet for that node. On each opted-in stack row the sheet shows a **Topology** button that opens a focused diagram for that one stack: the stack at the centre, every alias it publishes branching out, and a column of meshed consumer nodes with their tunnel state. Use it to confirm what a stack exposes and which peers can reach it. + +The graph reads the same `/mesh/status` and `/mesh/aliases` data the Table view does, so any opt-in or opt-out refreshes both views. + ## V1 limitations A few things are deliberately out of scope for the first release: diff --git a/frontend/src/components/fleet/MeshOptInSheet.tsx b/frontend/src/components/fleet/MeshOptInSheet.tsx index ab54bc90..babf01f6 100644 --- a/frontend/src/components/fleet/MeshOptInSheet.tsx +++ b/frontend/src/components/fleet/MeshOptInSheet.tsx @@ -5,7 +5,7 @@ import { SystemSheet } from '@/components/ui/system-sheet'; import { Button } from '@/components/ui/button'; import { ConfirmModal } from '@/components/ui/modal'; import type { MeshStackEntry } from '@/types/mesh'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Workflow } from 'lucide-react'; interface Props { open: boolean; @@ -13,9 +13,10 @@ interface Props { nodeId: number; nodeName: string; onChanged: () => void; + onViewTopology?: (stack: string) => void; } -export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged }: Props) { +export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, onViewTopology }: Props) { const [stacks, setStacks] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -120,13 +121,25 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged {pendingStack === stack.name ? ( ) : ( - +
+ {stack.optedIn && onViewTopology && ( + + )} + +
)} ))} diff --git a/frontend/src/components/fleet/MeshStackTopologySheet.test.tsx b/frontend/src/components/fleet/MeshStackTopologySheet.test.tsx new file mode 100644 index 00000000..3bb4b1cb --- /dev/null +++ b/frontend/src/components/fleet/MeshStackTopologySheet.test.tsx @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MeshStackTopologySheet } from './MeshStackTopologySheet'; +import type { MeshAlias, MeshNodeStatus } from '@/types/mesh'; + +vi.mock('@xyflow/react', () => ({ + ReactFlow: () => null, + Background: () => null, + Handle: () => null, + Position: { Left: 'left', Right: 'right' }, + useNodesState: () => [[] as T[], () => {}, () => {}], + useEdgesState: () => [[] as T[], () => {}, () => {}], +})); + +function makeNode(over: Partial & Pick): MeshNodeStatus { + return { + nodeId: over.nodeId, + nodeName: over.nodeName, + enabled: over.enabled ?? false, + localForwarderListening: over.localForwarderListening ?? null, + pilotConnected: over.pilotConnected ?? false, + reachableMode: over.reachableMode ?? 'unreachable', + reachableReason: over.reachableReason ?? null, + optedInStacks: over.optedInStacks ?? [], + activeStreamCount: over.activeStreamCount ?? 0, + }; +} + +function makeAlias(over: Partial & Pick): MeshAlias { + return { + host: over.host, + nodeId: over.nodeId, + nodeName: over.nodeName ?? `node-${over.nodeId}`, + stackName: over.stackName ?? 'stack-a', + serviceName: over.serviceName ?? 'svc', + port: over.port ?? 80, + }; +} + +const baseStatus: MeshNodeStatus[] = [ + makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }), + makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'pilot', enabled: true, pilotConnected: true }), +]; + +describe('MeshStackTopologySheet', () => { + it('renders the empty-state message when the stack publishes no aliases', () => { + render( + {}} + nodeId={1} + nodeName="local" + stackName="api" + status={baseStatus} + aliases={[]} + />, + ); + + expect(screen.getByText(/No published mesh services/i)).toBeInTheDocument(); + expect(screen.getByText(/0 aliases/i)).toBeInTheDocument(); + }); + + it('shows the alias and consumer counts in the meta band', () => { + const aliases = [ + makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' }), + makeAlias({ host: 'b.mesh', nodeId: 1, stackName: 'api' }), + ]; + render( + {}} + nodeId={1} + nodeName="local" + stackName="api" + status={baseStatus} + aliases={aliases} + />, + ); + + expect(screen.getByText(/2 aliases · 1 consumer/i)).toBeInTheDocument(); + expect(screen.queryByText(/No published mesh services/i)).not.toBeInTheDocument(); + }); + + it('counts only meshed remote nodes as consumers', () => { + const aliases = [makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' })]; + const status: MeshNodeStatus[] = [ + makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }), + makeNode({ nodeId: 2, nodeName: 'meshed', reachableMode: 'pilot', enabled: true, pilotConnected: true }), + makeNode({ nodeId: 3, nodeName: 'unmeshed', reachableMode: 'pilot', enabled: false }), + ]; + render( + {}} + nodeId={1} + nodeName="local" + stackName="api" + status={status} + aliases={aliases} + />, + ); + + expect(screen.getByText(/1 alias · 1 consumer/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/fleet/MeshStackTopologySheet.tsx b/frontend/src/components/fleet/MeshStackTopologySheet.tsx new file mode 100644 index 00000000..2fae9491 --- /dev/null +++ b/frontend/src/components/fleet/MeshStackTopologySheet.tsx @@ -0,0 +1,227 @@ +import { useEffect, useMemo } from 'react'; +import { + ReactFlow, + Background, + Handle, + Position, + useNodesState, + useEdgesState, + type Node, + type Edge, + type NodeTypes, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { Boxes, Globe, Server, AlertTriangle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { SystemSheet } from '@/components/ui/system-sheet'; +import { buildStackTopologyGraph } from '@/lib/mesh-topology-layout'; +import type { MeshAlias, MeshNodeStatus } from '@/types/mesh'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + nodeId: number | null; + nodeName: string | null; + stackName: string | null; + status: MeshNodeStatus[]; + aliases: MeshAlias[]; +} + +interface StackCenterData extends Record { + stackName: string; + nodeId: number; +} +interface StackAliasData extends Record { + host: string; + port: number; + service: string; +} +interface StackConsumerData extends Record { + node: MeshNodeStatus; +} + +function consumerDot(node: MeshNodeStatus): string { + if (node.reachableMode === 'unreachable') return 'bg-destructive'; + if (node.reachableMode === 'pilot' && !node.pilotConnected) return 'bg-warning'; + return 'bg-success'; +} + +function consumerStateLabel(node: MeshNodeStatus): string { + if (node.reachableMode === 'unreachable') return 'unreachable'; + if (node.reachableMode === 'pilot' && !node.pilotConnected) return 'pilot · idle'; + if (node.reachableMode === 'pilot') return 'pilot · ok'; + if (node.reachableMode === 'proxy') return 'proxy'; + return node.reachableMode; +} + +function StackCenterCard({ data }: { data: StackCenterData }) { + return ( +
+ +
+ + stack +
+
+
{data.stackName}
+
+
+ ); +} + +function StackAliasCard({ data }: { data: StackAliasData }) { + return ( +
+ + +
+ + alias +
+
+
{data.host}
+
+ {data.service} · :{data.port} +
+
+
+ ); +} + +function StackConsumerCard({ data }: { data: StackConsumerData }) { + const node = data.node; + const dimmed = node.reachableMode === 'unreachable'; + return ( +
+ +
+
+
+ + {node.nodeName} +
+
+ {consumerStateLabel(node)} +
+
+ ); +} + +const nodeTypes: NodeTypes = { + stackCenter: StackCenterCard, + stackAlias: StackAliasCard, + stackConsumer: StackConsumerCard, +}; + +export function MeshStackTopologySheet({ + open, + onOpenChange, + nodeId, + nodeName, + stackName, + status, + aliases, +}: Props) { + const [flowNodes, setFlowNodes, onNodesChange] = useNodesState([]); + const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState([]); + + // Shape-key: only relayout when the structural identity of the graph changes + // (different stack selected, an alias appears/disappears, a consumer node goes + // in/out of mesh, or a consumer's tunnel state flips). Scalar field changes on + // the same nodes fall through and do not reset user-dragged positions. + const shapeKey = useMemo(() => { + if (nodeId === null || stackName === null) return 'empty'; + const aliasKey = aliases + .filter((a) => a.nodeId === nodeId && a.stackName === stackName) + .map((a) => a.host) + .sort() + .join(','); + const consumerKey = status + .filter((s) => s.enabled && s.nodeId !== nodeId) + .map((s) => `${s.nodeId}:${s.reachableMode}:${s.pilotConnected ? 1 : 0}`) + .sort() + .join('|'); + return `${nodeId}/${stackName}/${aliasKey}/${consumerKey}`; + }, [nodeId, stackName, status, aliases]); + + useEffect(() => { + if (nodeId === null || stackName === null) { + setFlowNodes([]); + setFlowEdges([]); + return; + } + const next = buildStackTopologyGraph({ nodeId, stackName, status, aliases }); + setFlowNodes(next.nodes); + setFlowEdges(next.edges); + // Intentionally exclude status/aliases raw refs: relayout only on shapeKey changes, + // so polls that don't change reachability don't snap user-dragged nodes back. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shapeKey, setFlowNodes, setFlowEdges]); + + const stackAliasCount = useMemo(() => { + if (nodeId === null || stackName === null) return 0; + return aliases.filter((a) => a.nodeId === nodeId && a.stackName === stackName).length; + }, [nodeId, stackName, aliases]); + + const consumerCount = useMemo(() => { + if (nodeId === null) return 0; + return status.filter((s) => s.enabled && s.nodeId !== nodeId).length; + }, [nodeId, status]); + + const meta = `${stackAliasCount} ${stackAliasCount === 1 ? 'alias' : 'aliases'} · ${consumerCount} ${consumerCount === 1 ? 'consumer' : 'consumers'}`; + const crumbName = stackName ?? ''; + const ownerName = nodeName ?? ''; + + return ( + +
+

+ Aliases this stack publishes and the meshed nodes that can reach them. Edge styling + reflects each consumer's tunnel state. +

+ + {stackAliasCount === 0 ? ( +
+ +
No published mesh services
+
+ This stack is in the mesh but exposes no service ports for other meshed stacks to reach. +
+
+ ) : ( +
+
+ + + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/fleet/MeshTopologyGraph.tsx b/frontend/src/components/fleet/MeshTopologyGraph.tsx new file mode 100644 index 00000000..08bc4b90 --- /dev/null +++ b/frontend/src/components/fleet/MeshTopologyGraph.tsx @@ -0,0 +1,248 @@ +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 ( +
+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/fleet/RoutingTab.tsx b/frontend/src/components/fleet/RoutingTab.tsx index 2fbcc7d0..fb0b9801 100644 --- a/frontend/src/components/fleet/RoutingTab.tsx +++ b/frontend/src/components/fleet/RoutingTab.tsx @@ -2,14 +2,25 @@ import { useCallback, useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { Button } from '@/components/ui/button'; -import { ArrowLeftRight, Loader2, ScrollText } from 'lucide-react'; +import { ArrowLeftRight, Loader2, ScrollText, Table2, Network } from 'lucide-react'; import { RoutingNodeCard } from './RoutingNodeCard'; import { MeshOptInSheet } from './MeshOptInSheet'; import { MeshRouteDetailSheet } from './MeshRouteDetailSheet'; import { MeshDiagnosticsSheet } from './MeshDiagnosticsSheet'; import { MeshActivitySheet } from './MeshActivitySheet'; +import { MeshTopologyGraph, type MeshGraphEdgeMode } from './MeshTopologyGraph'; +import { MeshStackTopologySheet } from './MeshStackTopologySheet'; +import { SegmentedControl } from '@/components/ui/segmented-control'; import type { MeshAlias, MeshNodeStatus, MeshProbeResult } from '@/types/mesh'; +type RoutingViewMode = 'table' | 'graph'; + +interface TopologyStackTarget { + nodeId: number; + nodeName: string; + stack: string; +} + export function RoutingTab() { const [status, setStatus] = useState([]); const [aliases, setAliases] = useState([]); @@ -18,6 +29,9 @@ export function RoutingTab() { const [diagnosticsNode, setDiagnosticsNode] = useState<{ id: number; name: string } | null>(null); const [routeDetailAlias, setRouteDetailAlias] = useState(null); const [activityOpen, setActivityOpen] = useState(false); + const [viewMode, setViewMode] = useState('table'); + const [edgeMode, setEdgeMode] = useState('tunnels'); + const [topologyStack, setTopologyStack] = useState(null); const refresh = useCallback(async () => { try { @@ -58,6 +72,12 @@ export function RoutingTab() { } }, []); + const handleGraphNodeClick = useCallback((nodeId: number) => { + const target = status.find((s) => s.nodeId === nodeId); + if (!target) return; + setOptInNode({ id: target.nodeId, name: target.nodeName }); + }, [status]); + const totalAliases = aliases.length; const meshedNodes = status.filter((s) => s.enabled).length; // A node is "reachable for mesh" when it is local, a pilot with an @@ -121,6 +141,10 @@ export function RoutingTab() { diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode} routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias} activityOpen={activityOpen} setActivityOpen={setActivityOpen} + topologyStack={topologyStack} + setTopologyStack={setTopologyStack} + status={status} + aliases={aliases} onChanged={() => { void refresh(); }} /> @@ -130,25 +154,60 @@ export function RoutingTab() { return (
setActivityOpen(true)} /> -
- {status.map((s) => ( - setOptInNode({ id: s.nodeId, name: s.nodeName })} - onShowDiagnostics={() => setDiagnosticsNode({ id: s.nodeId, name: s.nodeName })} - onShowAlias={(alias) => setRouteDetailAlias(alias)} - onTestUpstream={testUpstream} - onChanged={() => { void refresh(); }} +
+ + value={viewMode} + onChange={setViewMode} + ariaLabel="Routing view mode" + options={[ + { value: 'table', label: 'Table', icon: Table2 }, + { value: 'graph', label: 'Graph', icon: Network }, + ]} + /> + {viewMode === 'graph' && ( + + value={edgeMode} + onChange={setEdgeMode} + ariaLabel="Mesh graph edge mode" + options={[ + { value: 'tunnels', label: 'Tunnels' }, + { value: 'aliases', label: 'Aliases', badge: totalAliases }, + ]} /> - ))} + )}
+ {viewMode === 'table' ? ( +
+ {status.map((s) => ( + setOptInNode({ id: s.nodeId, name: s.nodeName })} + onShowDiagnostics={() => setDiagnosticsNode({ id: s.nodeId, name: s.nodeName })} + onShowAlias={(alias) => setRouteDetailAlias(alias)} + onTestUpstream={testUpstream} + onChanged={() => { void refresh(); }} + /> + ))} +
+ ) : ( + + )} { void refresh(); }} />
@@ -190,17 +249,26 @@ function SheetsRoot(props: { setRouteDetailAlias: (v: string | null) => void; activityOpen: boolean; setActivityOpen: (v: boolean) => void; + topologyStack: TopologyStackTarget | null; + setTopologyStack: (v: TopologyStackTarget | null) => void; + status: MeshNodeStatus[]; + aliases: MeshAlias[]; onChanged: () => void; }) { + const optInNode = props.optInNode; return ( <> - {props.optInNode && ( + {optInNode && ( { if (!open) props.setOptInNode(null); }} - nodeId={props.optInNode.id} - nodeName={props.optInNode.name} + nodeId={optInNode.id} + nodeName={optInNode.name} onChanged={props.onChanged} + onViewTopology={(stack) => { + props.setTopologyStack({ nodeId: optInNode.id, nodeName: optInNode.name, stack }); + props.setOptInNode(null); + }} /> )} + { if (!open) props.setTopologyStack(null); }} + nodeId={props.topologyStack?.nodeId ?? null} + nodeName={props.topologyStack?.nodeName ?? null} + stackName={props.topologyStack?.stack ?? null} + status={props.status} + aliases={props.aliases} + /> ); } diff --git a/frontend/src/lib/mesh-topology-layout.test.ts b/frontend/src/lib/mesh-topology-layout.test.ts new file mode 100644 index 00000000..d8ae73da --- /dev/null +++ b/frontend/src/lib/mesh-topology-layout.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { + buildTunnelsGraph, + buildAliasesGraph, + buildStackTopologyGraph, +} from './mesh-topology-layout'; +import type { MeshAlias, MeshNodeStatus } from '@/types/mesh'; + +function makeNode(over: Partial & Pick): MeshNodeStatus { + return { + nodeId: over.nodeId, + nodeName: over.nodeName, + enabled: over.enabled ?? false, + localForwarderListening: over.localForwarderListening ?? null, + pilotConnected: over.pilotConnected ?? false, + reachableMode: over.reachableMode ?? 'unreachable', + reachableReason: over.reachableReason ?? null, + optedInStacks: over.optedInStacks ?? [], + activeStreamCount: over.activeStreamCount ?? 0, + }; +} + +function makeAlias(over: Partial & Pick): MeshAlias { + return { + host: over.host, + nodeId: over.nodeId, + nodeName: over.nodeName ?? `node-${over.nodeId}`, + stackName: over.stackName ?? 'stack-a', + serviceName: over.serviceName ?? 'svc', + port: over.port ?? 80, + }; +} + +describe('buildTunnelsGraph', () => { + it('returns empty result when no nodes', () => { + const result = buildTunnelsGraph([]); + expect(result.nodes).toHaveLength(0); + expect(result.edges).toHaveLength(0); + }); + + it('returns nodes but no edges when only the local node is present', () => { + const result = buildTunnelsGraph([ + makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }), + ]); + expect(result.nodes).toHaveLength(1); + expect(result.edges).toHaveLength(0); + }); + + it('returns one edge from local to every other node', () => { + const result = buildTunnelsGraph([ + makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }), + makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'pilot', enabled: true, pilotConnected: true }), + makeNode({ nodeId: 3, nodeName: 'peer-b', reachableMode: 'proxy', enabled: true }), + ]); + expect(result.nodes).toHaveLength(3); + expect(result.edges).toHaveLength(2); + expect(result.edges.every((e) => e.source === '1')).toBe(true); + }); + + it('labels pilot edges with state suffixes', () => { + const result = buildTunnelsGraph([ + makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }), + makeNode({ nodeId: 2, nodeName: 'on', reachableMode: 'pilot', enabled: true, pilotConnected: true }), + makeNode({ nodeId: 3, nodeName: 'off', reachableMode: 'pilot', enabled: true, pilotConnected: false }), + ]); + const onEdge = result.edges.find((e) => e.target === '2'); + const offEdge = result.edges.find((e) => e.target === '3'); + expect(onEdge?.label).toBe('pilot · ok'); + expect(offEdge?.label).toBe('pilot · idle'); + }); + + it('dashes unreachable edges', () => { + const result = buildTunnelsGraph([ + makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }), + makeNode({ nodeId: 2, nodeName: 'down', reachableMode: 'unreachable', enabled: false, reachableReason: 'auth failed' }), + ]); + const edge = result.edges[0]; + expect(edge).toBeDefined(); + expect((edge?.style as { strokeDasharray?: string } | undefined)?.strokeDasharray).toBe('4 4'); + expect(edge?.label).toBe('unreachable'); + }); +}); + +describe('buildAliasesGraph', () => { + const status: MeshNodeStatus[] = [ + makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }), + makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'pilot', enabled: true, pilotConnected: true }), + makeNode({ nodeId: 3, nodeName: 'peer-b', reachableMode: 'proxy', enabled: true }), + ]; + + it('produces one edge per remote node like the tunnels graph', () => { + const aliases = [ + makeAlias({ host: 'a.mesh', nodeId: 2 }), + makeAlias({ host: 'b.mesh', nodeId: 2 }), + makeAlias({ host: 'c.mesh', nodeId: 3 }), + ]; + const result = buildAliasesGraph(status, aliases); + expect(result.edges).toHaveLength(2); + const labels = result.edges.map((e) => e.label); + expect(labels).toContain('2 aliases'); + expect(labels).toContain('1 alias'); + }); + + it('labels edges with "no aliases" when remote owns none', () => { + const result = buildAliasesGraph(status, [makeAlias({ host: 'a.mesh', nodeId: 2 })]); + const noAliasEdge = result.edges.find((e) => e.target === '3'); + expect(noAliasEdge?.label).toBe('no aliases'); + }); + + it('encodes aliasCount on node data when in aliases mode', () => { + const aliases = [ + makeAlias({ host: 'a.mesh', nodeId: 2 }), + makeAlias({ host: 'b.mesh', nodeId: 2 }), + ]; + const result = buildAliasesGraph(status, aliases); + const peerA = result.nodes.find((n) => n.id === '2'); + const data = peerA?.data as { aliasCount: number } | undefined; + expect(data?.aliasCount).toBe(2); + }); +}); + +describe('buildStackTopologyGraph', () => { + const status: MeshNodeStatus[] = [ + makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }), + makeNode({ nodeId: 2, nodeName: 'peer-a', reachableMode: 'pilot', enabled: true, pilotConnected: true }), + makeNode({ nodeId: 3, nodeName: 'peer-b', reachableMode: 'proxy', enabled: true }), + ]; + + it('returns empty when stack publishes nothing and no consumers', () => { + const result = buildStackTopologyGraph({ + nodeId: 1, + stackName: 'empty', + status: [makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true })], + aliases: [], + }); + expect(result.nodes).toHaveLength(0); + expect(result.edges).toHaveLength(0); + }); + + it('builds a center + alias + consumer fanout for a stack with 2 aliases and 2 consumers', () => { + const aliases = [ + makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' }), + makeAlias({ host: 'b.mesh', nodeId: 1, stackName: 'api' }), + makeAlias({ host: 'other.mesh', nodeId: 1, stackName: 'other' }), + ]; + const result = buildStackTopologyGraph({ + nodeId: 1, + stackName: 'api', + status, + aliases, + }); + const centerNodes = result.nodes.filter((n) => n.type === 'stackCenter'); + const aliasNodes = result.nodes.filter((n) => n.type === 'stackAlias'); + const consumerNodes = result.nodes.filter((n) => n.type === 'stackConsumer'); + expect(centerNodes).toHaveLength(1); + expect(aliasNodes).toHaveLength(2); + expect(consumerNodes).toHaveLength(2); + // 2 center→alias edges + 2 aliases × 2 consumers = 4 alias→consumer edges = 6 total. + expect(result.edges).toHaveLength(6); + }); + + it('filters aliases by both nodeId and stackName', () => { + const aliases = [ + makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' }), + makeAlias({ host: 'wrong-stack.mesh', nodeId: 1, stackName: 'other' }), + makeAlias({ host: 'wrong-node.mesh', nodeId: 2, stackName: 'api' }), + ]; + const result = buildStackTopologyGraph({ + nodeId: 1, + stackName: 'api', + status, + aliases, + }); + const aliasNodes = result.nodes.filter((n) => n.type === 'stackAlias'); + expect(aliasNodes).toHaveLength(1); + expect(aliasNodes[0]?.id).toBe('alias-1-api-a.mesh'); + }); + + it('only treats meshed (enabled) remote nodes as consumers', () => { + const aliases = [makeAlias({ host: 'a.mesh', nodeId: 1, stackName: 'api' })]; + const mixedStatus = [ + makeNode({ nodeId: 1, nodeName: 'local', reachableMode: 'local', enabled: true }), + makeNode({ nodeId: 2, nodeName: 'meshed', reachableMode: 'pilot', enabled: true, pilotConnected: true }), + makeNode({ nodeId: 3, nodeName: 'unmeshed', reachableMode: 'pilot', enabled: false }), + ]; + const result = buildStackTopologyGraph({ + nodeId: 1, + stackName: 'api', + status: mixedStatus, + aliases, + }); + const consumerNodes = result.nodes.filter((n) => n.type === 'stackConsumer'); + expect(consumerNodes).toHaveLength(1); + expect(consumerNodes[0]?.id).toBe('consumer-1-api-2'); + }); +}); diff --git a/frontend/src/lib/mesh-topology-layout.ts b/frontend/src/lib/mesh-topology-layout.ts new file mode 100644 index 00000000..f9aa04ba --- /dev/null +++ b/frontend/src/lib/mesh-topology-layout.ts @@ -0,0 +1,317 @@ +import dagre from '@dagrejs/dagre'; +import type { Edge, Node } from '@xyflow/react'; +import type { MeshAlias, MeshNodeStatus } from '@/types/mesh'; + +export interface MeshNodeData extends Record { + node: MeshNodeStatus; + aliasCount: number; + isOwnerView: boolean; +} + +export interface MeshEdgeData extends Record { + kind: 'tunnels' | 'aliases'; + aliasCount: number; + remoteNodeId: number; +} + +const NODE_WIDTH = 240; +const NODE_HEIGHT = 150; + +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)'; +const EDGE_DESTRUCTIVE = 'oklch(0.65 0.2 28)'; + +type EdgeVisual = { + stroke: string; + strokeWidth: number; + strokeDasharray?: string; +}; + +function tunnelEdgeStyle(remote: MeshNodeStatus): EdgeVisual { + if (remote.reachableMode === 'unreachable') { + return { stroke: EDGE_DESTRUCTIVE, strokeWidth: 1, strokeDasharray: '4 4' }; + } + if (remote.reachableMode === 'pilot' && !remote.pilotConnected) { + return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' }; + } + if (!remote.enabled) { + return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' }; + } + return { stroke: EDGE_BRAND, strokeWidth: 1.5 }; +} + +function tunnelEdgeLabel(remote: MeshNodeStatus): string { + if (remote.reachableMode === 'unreachable') return 'unreachable'; + if (remote.reachableMode === 'pilot') { + return remote.pilotConnected ? 'pilot · ok' : 'pilot · idle'; + } + if (remote.reachableMode === 'proxy') return 'proxy'; + return ''; +} + +function aliasesEdgeStyle(remote: MeshNodeStatus, aliasCount: number): EdgeVisual { + if (aliasCount === 0) { + return { stroke: EDGE_MUTED, strokeWidth: 1, strokeDasharray: '4 4' }; + } + if (remote.reachableMode === 'unreachable' || (remote.reachableMode === 'pilot' && !remote.pilotConnected)) { + return { stroke: EDGE_WARNING, strokeWidth: 1.5, strokeDasharray: '4 4' }; + } + return { stroke: EDGE_BRAND, strokeWidth: 1.5 }; +} + +interface GraphLayoutResult { + nodes: Node[]; + edges: Edge[]; +} + +function emptyResult(): GraphLayoutResult { + return { nodes: [], edges: [] }; +} + +function layoutNodesLR(meshNodes: MeshNodeStatus[]): Map { + const g = new dagre.graphlib.Graph(); + g.setGraph({ rankdir: 'LR', ranksep: 160, nodesep: 32, marginx: 20, marginy: 20 }); + g.setDefaultEdgeLabel(() => ({})); + + for (const n of meshNodes) { + g.setNode(String(n.nodeId), { width: NODE_WIDTH, height: NODE_HEIGHT }); + } + + const local = meshNodes.find((n) => n.reachableMode === 'local') ?? null; + if (local) { + for (const r of meshNodes) { + if (r.nodeId === local.nodeId) continue; + g.setEdge(String(local.nodeId), String(r.nodeId)); + } + } + + dagre.layout(g); + + const positions = new Map(); + for (const n of meshNodes) { + const pos = g.node(String(n.nodeId)); + positions.set(n.nodeId, { x: pos.x - pos.width / 2, y: pos.y - pos.height / 2 }); + } + return positions; +} + +function makeNode( + node: MeshNodeStatus, + aliasCount: number, + position: { x: number; y: number }, +): Node { + return { + id: String(node.nodeId), + type: 'meshNode', + position, + data: { node, aliasCount, isOwnerView: false } satisfies MeshNodeData, + draggable: true, + }; +} + +export function buildTunnelsGraph(status: MeshNodeStatus[]): GraphLayoutResult { + if (status.length === 0) return emptyResult(); + + const positions = layoutNodesLR(status); + const local = status.find((n) => n.reachableMode === 'local') ?? null; + + const flowNodes: Node[] = status.map((n) => { + const pos = positions.get(n.nodeId) ?? { x: 0, y: 0 }; + return makeNode(n, 0, pos); + }); + + if (!local) return { nodes: flowNodes, edges: [] }; + + const flowEdges: Edge[] = status + .filter((n) => n.nodeId !== local.nodeId) + .map((remote) => { + const style = tunnelEdgeStyle(remote); + const label = tunnelEdgeLabel(remote); + return { + id: `tunnel-${local.nodeId}-${remote.nodeId}`, + source: String(local.nodeId), + target: String(remote.nodeId), + label, + style, + labelStyle: { fill: 'oklch(0.85 0 0)', fontSize: 10 }, + labelBgStyle: { fill: 'oklch(0.18 0 0)', fillOpacity: 0.9 }, + labelBgPadding: [4, 2] as [number, number], + data: { + kind: 'tunnels', + aliasCount: 0, + remoteNodeId: remote.nodeId, + } satisfies MeshEdgeData, + animated: false, + }; + }); + + return { nodes: flowNodes, edges: flowEdges }; +} + +export function buildAliasesGraph( + status: MeshNodeStatus[], + aliases: MeshAlias[], +): GraphLayoutResult { + if (status.length === 0) return emptyResult(); + + const positions = layoutNodesLR(status); + const local = status.find((n) => n.reachableMode === 'local') ?? null; + + const aliasCountByOwner = new Map(); + for (const a of aliases) { + aliasCountByOwner.set(a.nodeId, (aliasCountByOwner.get(a.nodeId) ?? 0) + 1); + } + + const flowNodes: Node[] = status.map((n) => { + const pos = positions.get(n.nodeId) ?? { x: 0, y: 0 }; + return makeNode(n, aliasCountByOwner.get(n.nodeId) ?? 0, pos); + }); + + if (!local) return { nodes: flowNodes, edges: [] }; + + const flowEdges: Edge[] = status + .filter((n) => n.nodeId !== local.nodeId) + .map((remote) => { + const remoteOwns = aliasCountByOwner.get(remote.nodeId) ?? 0; + const style = aliasesEdgeStyle(remote, remoteOwns); + const label = remoteOwns === 0 + ? 'no aliases' + : `${remoteOwns} ${remoteOwns === 1 ? 'alias' : 'aliases'}`; + return { + id: `aliases-${local.nodeId}-${remote.nodeId}`, + source: String(local.nodeId), + target: String(remote.nodeId), + label, + style, + labelStyle: { fill: 'oklch(0.85 0 0)', fontSize: 10 }, + labelBgStyle: { fill: 'oklch(0.18 0 0)', fillOpacity: 0.9 }, + labelBgPadding: [4, 2] as [number, number], + data: { + kind: 'aliases', + aliasCount: remoteOwns, + remoteNodeId: remote.nodeId, + } satisfies MeshEdgeData, + animated: false, + }; + }); + + return { nodes: flowNodes, edges: flowEdges }; +} + +export interface StackTopologyInput { + nodeId: number; + stackName: string; + status: MeshNodeStatus[]; + aliases: MeshAlias[]; +} + +export interface StackTopologyVertex { + stack: { id: string; name: string }; + aliases: Array<{ id: string; host: string; port: number; service: string }>; + consumers: Array<{ id: string; node: MeshNodeStatus }>; +} + +// Per-stack sheet uses a fixed three-column layout (stack centre, alias column, +// consumer column) instead of Dagre because the graph is always small and a +// deterministic layout reads better than auto-routed edges for ~10 vertices. +const STACK_CENTER_X = 0; +const STACK_CENTER_Y = 0; +const STACK_ALIAS_X = 220; +const STACK_CONSUMER_X = 460; +const STACK_VERTICAL_GAP = 80; + +function stackVertexLayout( + aliasCount: number, + consumerCount: number, +): { center: { x: number; y: number }; aliasPositions: Array<{ x: number; y: number }>; consumerPositions: Array<{ x: number; y: number }> } { + const aliasStartY = -((aliasCount - 1) * STACK_VERTICAL_GAP) / 2; + const consumerStartY = -((consumerCount - 1) * STACK_VERTICAL_GAP) / 2; + + return { + center: { x: STACK_CENTER_X, y: STACK_CENTER_Y }, + aliasPositions: Array.from({ length: aliasCount }, (_, i) => ({ + x: STACK_ALIAS_X, + y: aliasStartY + i * STACK_VERTICAL_GAP, + })), + consumerPositions: Array.from({ length: consumerCount }, (_, i) => ({ + x: STACK_CONSUMER_X, + y: consumerStartY + i * STACK_VERTICAL_GAP, + })), + }; +} + +export function buildStackTopologyGraph(input: StackTopologyInput): GraphLayoutResult { + const { nodeId, stackName, status, aliases } = input; + + const stackAliases = aliases.filter((a) => a.nodeId === nodeId && a.stackName === stackName); + const consumers = status.filter((s) => s.enabled && s.nodeId !== nodeId); + + if (stackAliases.length === 0 && consumers.length === 0) { + return emptyResult(); + } + + const layout = stackVertexLayout(stackAliases.length, consumers.length); + const stackVertexId = `stack-${nodeId}-${stackName}`; + + const flowNodes: Node[] = []; + + flowNodes.push({ + id: stackVertexId, + type: 'stackCenter', + position: layout.center, + data: { stackName, nodeId }, + draggable: true, + }); + + const aliasVertexId = (host: string) => `alias-${nodeId}-${stackName}-${host}`; + const consumerVertexId = (id: number) => `consumer-${nodeId}-${stackName}-${id}`; + + stackAliases.forEach((alias, i) => { + flowNodes.push({ + id: aliasVertexId(alias.host), + type: 'stackAlias', + position: layout.aliasPositions[i] ?? { x: 0, y: 0 }, + data: { + host: alias.host, + port: alias.port, + service: alias.serviceName, + }, + draggable: true, + }); + }); + + consumers.forEach((node, i) => { + flowNodes.push({ + id: consumerVertexId(node.nodeId), + type: 'stackConsumer', + position: layout.consumerPositions[i] ?? { x: 0, y: 0 }, + data: { node }, + draggable: true, + }); + }); + + const flowEdges: Edge[] = []; + + for (const alias of stackAliases) { + flowEdges.push({ + id: `e-stack-${aliasVertexId(alias.host)}`, + source: stackVertexId, + target: aliasVertexId(alias.host), + style: { stroke: EDGE_BRAND, strokeWidth: 1.5 }, + animated: false, + }); + for (const node of consumers) { + const style = aliasesEdgeStyle(node, 1); + flowEdges.push({ + id: `e-${aliasVertexId(alias.host)}-to-${node.nodeId}`, + source: aliasVertexId(alias.host), + target: consumerVertexId(node.nodeId), + style, + animated: false, + }); + } + } + + return { nodes: flowNodes, edges: flowEdges }; +}