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
+25 -2
View File
@@ -190,7 +190,18 @@ export interface MeshNodeStatus {
reachableReason: string | null;
/** Peer→central reverse path state. `not_applicable` for non-proxy peers. */
reverseCallbackStatus: MeshReverseCallbackStatus;
optedInStacks: string[];
/**
* Stacks opted into the mesh on this node, with a per-stack resolvability
* flag. `currentlyResolvable` is `true` iff the alias cache currently
* carries at least one alias for that (nodeId, stackName) pair, i.e. the
* stack's services were inspectable and exposed at least one port the
* last time `refreshAliasCache()` ran (every 60 s on a timer, plus on
* opt-in / opt-out and pilot reconnect). A suspended opt-in (stack
* stopped, services not running) reports `currentlyResolvable: false` so
* the Routing tab can surface the asymmetry between the persistent
* registry and the live alias list.
*/
optedInStacks: Array<{ stackName: string; currentlyResolvable: boolean }>;
activeStreamCount: number;
}
@@ -2131,6 +2142,15 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
const localListening = this.isLocalForwarderActive();
const ptm = PilotTunnelManager.getInstance();
const dialer = MeshProxyTunnelDialer.getInstance();
// Precompute the (nodeId, stackName) pairs that currently have at least
// one alias in the cache. Reading the cache (refreshed every 60 s and
// on opt-in/opt-out) keeps the new `currentlyResolvable` field aligned
// with what `/api/mesh/aliases` reports, without paying the cost of a
// fresh Dockerode/cross-node inspect per status poll.
const resolvableKeys = new Set<string>();
for (const alias of this.aliasCache.values()) {
resolvableKeys.add(`${alias.nodeId}:${alias.stackName}`);
}
return nodes.map((node) => {
const reach = this.computeReachable(node, localNodeId);
const meshEnabled = db.getNodeMeshEnabled(node.id);
@@ -2148,7 +2168,10 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
reachableMode: reach.mode,
reachableReason: reach.reason,
reverseCallbackStatus: this.computeReverseCallbackStatus(node, meshEnabled, dialer),
optedInStacks: db.listMeshStacks(node.id).map((s) => s.stack_name),
optedInStacks: db.listMeshStacks(node.id).map((s) => ({
stackName: s.stack_name,
currentlyResolvable: resolvableKeys.has(`${node.id}:${s.stack_name}`),
})),
activeStreamCount: this.activeStreams.size,
};
});