mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
fix(mesh): poll topology data and tighten stack-membership equality (#1059)
Adds a visibility-aware 30s poll on the Routing tab so the graph reflects tunnel state, alias publishes, and remote-node disconnects without a manual reload. Polling pauses while the tab is hidden and wakes immediately on focus via the shared visibilityInterval helper. Tightens nodeStateEqual so a stack swap on the same node (opt out A, opt in B) is detected even when the count is unchanged. Extracts the helper, the minimap colour mapping, and the minimap colour literals to mesh-topology-layout so they can be unit-tested and to drop a now-unused isOwnerView field from MeshNodeData. Persists the Table/Graph and Tunnels/Aliases toggles to localStorage so a Routing tab session keeps the operator's last-used view. Adds a legend line to the per-stack topology sheet clarifying that consumer edges mean meshed peers that could reach the aliases via DNS; whether containers dial them depends on each consumer's own opt-in stacks. Adds unit tests for stacksKey, meshNodeStateEqual, and miniMapColorFor, plus troubleshooting entries and a refresh-cadence note to the mesh docs.
This commit is contained in:
@@ -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.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Stack topology sheet says 'No published mesh services'">
|
||||
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.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Graph reflects a stale node state">
|
||||
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.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Graph mode is hard to read with a large fleet">
|
||||
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.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
<p className="text-xs text-stat-subtitle leading-snug">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{stackAliasCount === 0 ? (
|
||||
<div className="rounded border border-dashed border-card-border bg-card/50 p-8 text-center">
|
||||
|
||||
@@ -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
|
||||
<div className="px-3 py-1.5 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
{node.activeStreamCount} {streamLabel}
|
||||
{node.reachableMode === 'unreachable' && node.reachableReason ? (
|
||||
<span className="ml-2 text-destructive truncate">· {node.reachableReason}</span>
|
||||
<span className="ml-2 text-destructive truncate">{' · '}{node.reachableReason}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<MeshNodeStatus[]>([]);
|
||||
const [aliases, setAliases] = useState<MeshAlias[]>([]);
|
||||
@@ -29,11 +52,11 @@ export function RoutingTab() {
|
||||
const [diagnosticsNode, setDiagnosticsNode] = useState<{ id: number; name: string } | null>(null);
|
||||
const [routeDetailAlias, setRouteDetailAlias] = useState<string | null>(null);
|
||||
const [activityOpen, setActivityOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<RoutingViewMode>('table');
|
||||
const [edgeMode, setEdgeMode] = useState<MeshGraphEdgeMode>('tunnels');
|
||||
const [viewMode, setViewMode] = useState<RoutingViewMode>(readStoredViewMode);
|
||||
const [edgeMode, setEdgeMode] = useState<MeshGraphEdgeMode>(readStoredEdgeMode);
|
||||
const [topologyStack, setTopologyStack] = useState<TopologyStackTarget | null>(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<void> => {
|
||||
try {
|
||||
const res = await apiFetch(`/mesh/aliases/${encodeURIComponent(alias)}/test`, {
|
||||
@@ -157,7 +199,7 @@ export function RoutingTab() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegmentedControl<RoutingViewMode>
|
||||
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' && (
|
||||
<SegmentedControl<MeshGraphEdgeMode>
|
||||
value={edgeMode}
|
||||
onChange={setEdgeMode}
|
||||
onChange={setEdgeModePersisted}
|
||||
ariaLabel="Mesh graph edge mode"
|
||||
options={[
|
||||
{ value: 'tunnels', label: 'Tunnels' },
|
||||
|
||||
@@ -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<MeshAlias> & Pick<MeshAlias, 'host' | 'nodeId'>
|
||||
};
|
||||
}
|
||||
|
||||
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([]);
|
||||
|
||||
@@ -5,7 +5,35 @@ import type { MeshAlias, MeshNodeStatus } from '@/types/mesh';
|
||||
export interface MeshNodeData extends Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user