Files
sencho/frontend/src/lib/mesh-topology-layout.test.ts
T
Anso 554f662563 fix(mesh): surface stopped-stack opt-ins on routing node cards (#1098)
Add `currentlyResolvable: boolean` per entry in `MeshNodeStatus.optedInStacks`,
derived from the existing alias cache so the new field stays consistent with
`/api/mesh/aliases` without any extra Dockerode or cross-node inspect on the
status path. The Routing tab renders an amber `suspended` pill for entries
whose stack is opted in but currently has no running services, plus a single
explanatory caption below the suspended list.

Resolves the contradictory `Mesh stacks: 1 / Aliases: 0 / No mesh services
on this node yet` copy on the node card when a meshed stack's container has
been stopped; the misleading line is now only shown when the node truly has
no opt-ins. The opt-in itself remains sticky: when the stack starts again,
its aliases reappear automatically on the next refresh.

Defensive de-dup in the UI filters suspended entries against the live alias
snapshot to handle the transient gap where `/mesh/status` and `/mesh/aliases`
return slightly inconsistent views from their separate fetches.

Tests:
- new `mesh-status-resolvability.test.ts` (6 cases) locks the resolvable /
  suspended / mixed / empty / per-node-scoping / stale-alias-no-phantom
  invariants for `getStatus`.
- `mesh-topology-layout.test.ts` gains a `stacksKey` resolvability-flip case
  and a `meshNodeStateEqual` case asserting a resolvability flip on an
  otherwise identical stack registers as a state change so the topology
  layout re-runs.

Operator docs gain one new troubleshooting accordion in
`/docs/features/sencho-mesh.mdx` explaining the suspended state.
2026-05-18 01:52:18 -04:00

295 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
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';
function makeNode(over: Partial<MeshNodeStatus> & Pick<MeshNodeStatus, 'nodeId' | 'nodeName'>): 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,
reverseCallbackStatus: over.reverseCallbackStatus ?? 'not_applicable',
optedInStacks: over.optedInStacks ?? [],
activeStreamCount: over.activeStreamCount ?? 0,
};
}
function makeAlias(over: Partial<MeshAlias> & Pick<MeshAlias, 'host' | 'nodeId'>): 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,
};
}
function entries(...names: string[]): MeshNodeStatus['optedInStacks'] {
return names.map((stackName) => ({ stackName, currentlyResolvable: true }));
}
describe('stacksKey', () => {
it('returns the same key for the same membership regardless of order', () => {
expect(stacksKey(entries('a', 'b', 'c'))).toBe(stacksKey(entries('c', 'a', 'b')));
});
it('differs when membership differs even with the same count', () => {
expect(stacksKey(entries('a', 'b'))).not.toBe(stacksKey(entries('a', 'c')));
});
it('returns empty string for empty list', () => {
expect(stacksKey([])).toBe('');
});
it('differs when a stack flips currentlyResolvable', () => {
const a = [{ stackName: 'svc', currentlyResolvable: true }];
const b = [{ stackName: 'svc', currentlyResolvable: false }];
expect(stacksKey(a)).not.toBe(stacksKey(b));
});
});
describe('meshNodeStateEqual', () => {
const base = makeNode({ nodeId: 1, nodeName: 'n', reachableMode: 'pilot', enabled: true, pilotConnected: true, optedInStacks: entries('a', 'b') });
it('returns true when scalar fields and stack membership match', () => {
const a = { ...base };
const b = { ...base, optedInStacks: entries('b', 'a') };
expect(meshNodeStateEqual(a, b)).toBe(true);
});
it('detects a stack swap that preserves the count', () => {
const a = { ...base };
const b = { ...base, optedInStacks: entries('a', 'c') };
expect(meshNodeStateEqual(a, b)).toBe(false);
});
it('detects a resolvability flip on a stack that otherwise stays identical', () => {
const a = { ...base };
const b = {
...base,
optedInStacks: [
{ stackName: 'a', currentlyResolvable: false },
{ stackName: 'b', currentlyResolvable: true },
],
};
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([]);
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');
});
});