mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +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:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user