=> {
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,
};
}