diff --git a/docs/features/sencho-mesh.mdx b/docs/features/sencho-mesh.mdx index 9851b67f..0fbf9a60 100644 --- a/docs/features/sencho-mesh.mdx +++ b/docs/features/sencho-mesh.mdx @@ -105,7 +105,11 @@ A second toggle picks what the edges encode: 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. +In the stack diagram, *consumer nodes* are meshed peers that could reach this stack's aliases via DNS. Whether a container on a consumer actually dials an alias depends on the consumer's own opt-in stacks. + +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. The Routing tab also refreshes both feeds every 30 seconds while the browser tab is in the foreground, so tunnel state changes and alias additions appear without a manual reload. Polling pauses automatically when the tab is hidden. + +The graph is designed for fleet sizes typical of self-hosted Compose setups (up to roughly 50 nodes). Larger fleets render but become visually dense; the Table view is the more readable surface for inventory at scale. ## V1 limitations @@ -160,4 +164,16 @@ A few things are deliberately out of scope for the first release: - `TLS handshake failed` — the remote serves a certificate Node's default trust store does not accept. Use a certificate issued by a trusted authority on the remote. - `api_url not set` or `api token missing` — the node was added without credentials. Edit the node in **Settings → Nodes** and supply the URL and token. + + + The stack is opted into the mesh but exposes no service ports that became aliases. The stack joins `sencho_mesh` (other meshed containers can talk to it directly by container name) but no fleet-wide hostname is published. To publish an alias, declare a port on a service in the stack's compose file and redeploy. + + + + The Routing tab polls `/mesh/status` and `/mesh/aliases` every 30 seconds while the browser tab is focused. To force an immediate refresh, leave and return to the Routing tab, or toggle any stack's mesh state to trigger an action-driven refresh. Polling pauses when the tab is hidden, so a long-dormant tab catches up on the first poll after it regains focus. + + + + The diagram suits typical fleet sizes of up to roughly 50 nodes. Larger fleets render but the layout becomes dense. Use the Table view for inventory at scale and reach for Graph mode for spot checks of tunnel state and alias publication. + diff --git a/frontend/src/components/fleet/MeshStackTopologySheet.tsx b/frontend/src/components/fleet/MeshStackTopologySheet.tsx index 2fae9491..d2b74922 100644 --- a/frontend/src/components/fleet/MeshStackTopologySheet.tsx +++ b/frontend/src/components/fleet/MeshStackTopologySheet.tsx @@ -192,6 +192,11 @@ export function MeshStackTopologySheet({ Aliases this stack publishes and the meshed nodes that can reach them. Edge styling reflects each consumer's tunnel state.

+

+ Consumer nodes are meshed peers that could reach this stack's aliases via DNS. + Whether a container on a consumer actually dials an alias depends on that consumer's + own opt-in stacks. +

{stackAliasCount === 0 ? (
diff --git a/frontend/src/components/fleet/MeshTopologyGraph.tsx b/frontend/src/components/fleet/MeshTopologyGraph.tsx index 08bc4b90..2b2b0533 100644 --- a/frontend/src/components/fleet/MeshTopologyGraph.tsx +++ b/frontend/src/components/fleet/MeshTopologyGraph.tsx @@ -18,15 +18,12 @@ import { cn } from '@/lib/utils'; import { buildTunnelsGraph, buildAliasesGraph, + meshNodeStateEqual, + miniMapColorFor, 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 { @@ -120,7 +117,7 @@ function MeshNodeCard({ data, selected }: { data: MeshNodeData; selected?: boole
{node.activeStreamCount} {streamLabel} {node.reachableMode === 'unreachable' && node.reachableReason ? ( - · {node.reachableReason} + {' · '}{node.reachableReason} ) : null}
@@ -133,24 +130,10 @@ 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}`) @@ -168,15 +151,12 @@ export function MeshTopologyGraph({ status, aliases, edgeMode, onNodeClick }: Me : 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. + // Intentionally exclude status/aliases raw refs so we only relayout when + // shape (or 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); @@ -188,11 +168,11 @@ export function MeshTopologyGraph({ status, aliases, edgeMode, onNodeClick }: Me if ( existing && existing.aliasCount === aliasCount - && nodeStateEqual(existing.node, next) + && meshNodeStateEqual(existing.node, next) ) { return flowNode; } - return { ...flowNode, data: { node: next, aliasCount, isOwnerView: false } satisfies MeshNodeData }; + return { ...flowNode, data: { node: next, aliasCount } satisfies MeshNodeData }; })); }, [status, aliases, edgeMode, setFlowNodes]); @@ -205,12 +185,7 @@ export function MeshTopologyGraph({ status, aliases, edgeMode, onNodeClick }: Me 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 miniMapColorFor(data?.node); }, []); return ( diff --git a/frontend/src/components/fleet/RoutingTab.tsx b/frontend/src/components/fleet/RoutingTab.tsx index fb0b9801..0fabe64b 100644 --- a/frontend/src/components/fleet/RoutingTab.tsx +++ b/frontend/src/components/fleet/RoutingTab.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; +import { visibilityInterval } from '@/lib/utils'; import { toast } from '@/components/ui/toast-store'; import { Button } from '@/components/ui/button'; import { ArrowLeftRight, Loader2, ScrollText, Table2, Network } from 'lucide-react'; @@ -21,6 +22,28 @@ interface TopologyStackTarget { stack: string; } +const MESH_REFRESH_INTERVAL_MS = 30000; +const VIEW_MODE_KEY = 'sencho-routing-view-mode'; +const EDGE_MODE_KEY = 'sencho-routing-edge-mode'; + +function readStoredViewMode(): RoutingViewMode { + try { + const v = localStorage.getItem(VIEW_MODE_KEY); + return v === 'graph' ? 'graph' : 'table'; + } catch { + return 'table'; + } +} + +function readStoredEdgeMode(): MeshGraphEdgeMode { + try { + const v = localStorage.getItem(EDGE_MODE_KEY); + return v === 'aliases' ? 'aliases' : 'tunnels'; + } catch { + return 'tunnels'; + } +} + export function RoutingTab() { const [status, setStatus] = useState([]); const [aliases, setAliases] = useState([]); @@ -29,11 +52,11 @@ 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 [viewMode, setViewMode] = useState(readStoredViewMode); + const [edgeMode, setEdgeMode] = useState(readStoredEdgeMode); const [topologyStack, setTopologyStack] = useState(null); - const refresh = useCallback(async () => { + const refresh = useCallback(async (opts: { silent?: boolean } = {}) => { try { const [statusRes, aliasesRes] = await Promise.all([ apiFetch('/mesh/status', { localOnly: true }), @@ -48,7 +71,11 @@ export function RoutingTab() { setAliases(body.aliases); } } catch (err) { - toast.error(`Failed to load mesh state: ${(err as Error).message}`); + if (opts.silent) { + console.warn('[mesh] background refresh failed:', (err as Error).message); + } else { + toast.error(`Failed to load mesh state: ${(err as Error).message}`); + } } finally { setLoading(false); } @@ -56,6 +83,21 @@ export function RoutingTab() { useEffect(() => { void refresh(); }, [refresh]); + useEffect( + () => visibilityInterval(() => { void refresh({ silent: true }); }, MESH_REFRESH_INTERVAL_MS), + [refresh], + ); + + const setViewModePersisted = useCallback((mode: RoutingViewMode) => { + setViewMode(mode); + try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* localStorage unavailable */ } + }, []); + + const setEdgeModePersisted = useCallback((mode: MeshGraphEdgeMode) => { + setEdgeMode(mode); + try { localStorage.setItem(EDGE_MODE_KEY, mode); } catch { /* localStorage unavailable */ } + }, []); + const testUpstream = useCallback(async (alias: string): Promise => { try { const res = await apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/test`, { @@ -157,7 +199,7 @@ export function RoutingTab() {
value={viewMode} - onChange={setViewMode} + onChange={setViewModePersisted} ariaLabel="Routing view mode" options={[ { value: 'table', label: 'Table', icon: Table2 }, @@ -167,7 +209,7 @@ export function RoutingTab() { {viewMode === 'graph' && ( value={edgeMode} - onChange={setEdgeMode} + onChange={setEdgeModePersisted} ariaLabel="Mesh graph edge mode" options={[ { value: 'tunnels', label: 'Tunnels' }, diff --git a/frontend/src/lib/mesh-topology-layout.test.ts b/frontend/src/lib/mesh-topology-layout.test.ts index d8ae73da..15b523d8 100644 --- a/frontend/src/lib/mesh-topology-layout.test.ts +++ b/frontend/src/lib/mesh-topology-layout.test.ts @@ -3,6 +3,13 @@ import { buildTunnelsGraph, buildAliasesGraph, buildStackTopologyGraph, + meshNodeStateEqual, + miniMapColorFor, + stacksKey, + MINIMAP_BRAND, + MINIMAP_WARNING, + MINIMAP_MUTED, + MINIMAP_DESTRUCTIVE, } from './mesh-topology-layout'; import type { MeshAlias, MeshNodeStatus } from '@/types/mesh'; @@ -31,6 +38,74 @@ function makeAlias(over: Partial & Pick }; } +describe('stacksKey', () => { + it('returns the same key for the same membership regardless of order', () => { + expect(stacksKey(['a', 'b', 'c'])).toBe(stacksKey(['c', 'a', 'b'])); + }); + + it('differs when membership differs even with the same count', () => { + expect(stacksKey(['a', 'b'])).not.toBe(stacksKey(['a', 'c'])); + }); + + it('returns empty string for empty list', () => { + expect(stacksKey([])).toBe(''); + }); +}); + +describe('meshNodeStateEqual', () => { + const base = makeNode({ nodeId: 1, nodeName: 'n', reachableMode: 'pilot', enabled: true, pilotConnected: true, optedInStacks: ['a', 'b'] }); + + it('returns true when scalar fields and stack membership match', () => { + const a = { ...base }; + const b = { ...base, optedInStacks: ['b', 'a'] }; + expect(meshNodeStateEqual(a, b)).toBe(true); + }); + + it('detects a stack swap that preserves the count', () => { + const a = { ...base }; + const b = { ...base, optedInStacks: ['a', 'c'] }; + expect(meshNodeStateEqual(a, b)).toBe(false); + }); + + it('detects a reachable-mode flip', () => { + const a = { ...base }; + const b = { ...base, reachableMode: 'unreachable' as const }; + expect(meshNodeStateEqual(a, b)).toBe(false); + }); + + it('detects a pilot disconnect', () => { + const a = { ...base }; + const b = { ...base, pilotConnected: false }; + expect(meshNodeStateEqual(a, b)).toBe(false); + }); +}); + +describe('miniMapColorFor', () => { + it('returns destructive for unreachable', () => { + const node = makeNode({ nodeId: 1, nodeName: 'n', reachableMode: 'unreachable' }); + expect(miniMapColorFor(node)).toBe(MINIMAP_DESTRUCTIVE); + }); + + it('returns warning for pilot that is not connected', () => { + const node = makeNode({ nodeId: 1, nodeName: 'n', reachableMode: 'pilot', enabled: true, pilotConnected: false }); + expect(miniMapColorFor(node)).toBe(MINIMAP_WARNING); + }); + + it('returns muted for an enabled-false pilot that is connected', () => { + const node = makeNode({ nodeId: 1, nodeName: 'n', reachableMode: 'pilot', enabled: false, pilotConnected: true }); + expect(miniMapColorFor(node)).toBe(MINIMAP_MUTED); + }); + + it('returns brand for a healthy meshed remote', () => { + const node = makeNode({ nodeId: 1, nodeName: 'n', reachableMode: 'pilot', enabled: true, pilotConnected: true }); + expect(miniMapColorFor(node)).toBe(MINIMAP_BRAND); + }); + + it('returns muted when node is undefined', () => { + expect(miniMapColorFor(undefined)).toBe(MINIMAP_MUTED); + }); +}); + describe('buildTunnelsGraph', () => { it('returns empty result when no nodes', () => { const result = buildTunnelsGraph([]); diff --git a/frontend/src/lib/mesh-topology-layout.ts b/frontend/src/lib/mesh-topology-layout.ts index f9aa04ba..3aad5e98 100644 --- a/frontend/src/lib/mesh-topology-layout.ts +++ b/frontend/src/lib/mesh-topology-layout.ts @@ -5,7 +5,35 @@ import type { MeshAlias, MeshNodeStatus } from '@/types/mesh'; export interface MeshNodeData extends Record { node: MeshNodeStatus; aliasCount: number; - isOwnerView: boolean; +} + +// MiniMap colour literals. ReactFlow cannot resolve CSS vars inside inline +// SVG fills, so the minimap takes raw oklch strings. +export const MINIMAP_BRAND = 'oklch(0.78 0.11 195)'; +export const MINIMAP_WARNING = 'oklch(0.75 0.14 75)'; +export const MINIMAP_MUTED = 'oklch(0.55 0 0)'; +export const MINIMAP_DESTRUCTIVE = 'oklch(0.65 0.2 28)'; + +export function miniMapColorFor(node: MeshNodeStatus | undefined): string { + if (!node) return MINIMAP_MUTED; + if (node.reachableMode === 'unreachable') return MINIMAP_DESTRUCTIVE; + if (node.reachableMode === 'pilot' && !node.pilotConnected) return MINIMAP_WARNING; + if (!node.enabled) return MINIMAP_MUTED; + return MINIMAP_BRAND; +} + +export function stacksKey(stacks: readonly string[]): string { + return [...stacks].sort().join(' '); +} + +export function meshNodeStateEqual(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 + && stacksKey(a.optedInStacks) === stacksKey(b.optedInStacks); } export interface MeshEdgeData extends Record { @@ -105,7 +133,7 @@ function makeNode( id: String(node.nodeId), type: 'meshNode', position, - data: { node, aliasCount, isOwnerView: false } satisfies MeshNodeData, + data: { node, aliasCount } satisfies MeshNodeData, draggable: true, }; }