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.
This commit is contained in:
Anso
2026-05-18 01:52:18 -04:00
committed by GitHub
parent 77782ce1ff
commit 554f662563
7 changed files with 237 additions and 11 deletions
+27 -5
View File
@@ -39,32 +39,54 @@ function makeAlias(over: Partial<MeshAlias> & Pick<MeshAlias, 'host' | 'nodeId'>
};
}
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(['a', 'b', 'c'])).toBe(stacksKey(['c', 'a', 'b']));
expect(stacksKey(entries('a', 'b', 'c'))).toBe(stacksKey(entries('c', 'a', 'b')));
});
it('differs when membership differs even with the same count', () => {
expect(stacksKey(['a', 'b'])).not.toBe(stacksKey(['a', 'c']));
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: ['a', 'b'] });
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: ['b', 'a'] };
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: ['a', 'c'] };
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);
});
+5 -2
View File
@@ -22,8 +22,11 @@ export function miniMapColorFor(node: MeshNodeStatus | undefined): string {
return MINIMAP_BRAND;
}
export function stacksKey(stacks: readonly string[]): string {
return [...stacks].sort().join(' ');
export function stacksKey(stacks: MeshNodeStatus['optedInStacks']): string {
return [...stacks]
.map((s) => `${s.stackName}:${s.currentlyResolvable ? '1' : '0'}`)
.sort()
.join(' ');
}
export function meshNodeStateEqual(a: MeshNodeStatus, b: MeshNodeStatus): boolean {