mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
554f662563
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.
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
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');
|
||
});
|
||
});
|