From 554f6625637c60562b0c5fc3858aa965acd60bb5 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 18 May 2026 01:52:18 -0400 Subject: [PATCH] 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. --- .../mesh-status-resolvability.test.ts | 138 ++++++++++++++++++ backend/src/services/MeshService.ts | 27 +++- docs/features/sencho-mesh.mdx | 4 + .../src/components/fleet/RoutingNodeCard.tsx | 31 +++- frontend/src/lib/mesh-topology-layout.test.ts | 32 +++- frontend/src/lib/mesh-topology-layout.ts | 7 +- frontend/src/types/mesh.ts | 9 +- 7 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 backend/src/__tests__/mesh-status-resolvability.test.ts diff --git a/backend/src/__tests__/mesh-status-resolvability.test.ts b/backend/src/__tests__/mesh-status-resolvability.test.ts new file mode 100644 index 00000000..00b2d431 --- /dev/null +++ b/backend/src/__tests__/mesh-status-resolvability.test.ts @@ -0,0 +1,138 @@ +/** + * `MeshService.getStatus().optedInStacks[i].currentlyResolvable` contract. + * + * The Routing tab needs to distinguish two states for an opted-in stack: + * - resolvable: the alias cache currently carries at least one alias for + * `(nodeId, stackName)` (services are running and inspectable). + * - suspended: the `mesh_stacks` row still exists, but the alias cache has + * nothing for that pair (stack stopped, ports gone). + * + * Reading `this.aliasCache` directly keeps the new field aligned with what + * `/api/mesh/aliases` reports without paying an extra Dockerode / cross-node + * inspect on every status poll. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { MeshService, type MeshGlobalAlias } from '../services/MeshService'; +import { DatabaseService } from '../services/DatabaseService'; + +let tmpDir: string; +beforeAll(async () => { tmpDir = await setupTestDb(); }); +afterAll(() => cleanupTestDb(tmpDir)); + +function uniqueSuffix(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function seedProxyNode(meshEnabled: boolean): number { + const db = DatabaseService.getInstance(); + const id = db.addNode({ + name: `peer-resolv-${uniqueSuffix()}`, + type: 'remote', + mode: 'proxy', + api_url: `https://peer-${uniqueSuffix()}.example.com`, + api_token: `tok-${uniqueSuffix()}`, + compose_dir: '/tmp', + is_default: false, + }); + if (meshEnabled) db.setNodeMeshEnabled(id, true); + return id; +} + +function setAliasCache(entries: MeshGlobalAlias[]): void { + const svc = MeshService.getInstance() as unknown as { + aliasCache: Map; + }; + svc.aliasCache = new Map(entries.map((a) => [a.host, a])); +} + +beforeEach(() => { + DatabaseService.getInstance().getDb().prepare('DELETE FROM nodes WHERE is_default = 0').run(); + DatabaseService.getInstance().getDb().prepare('DELETE FROM mesh_stacks').run(); + setAliasCache([]); +}); + +describe('MeshService.getStatus optedInStacks.currentlyResolvable', () => { + it('reports currentlyResolvable=true when the alias cache has an alias for the stack', async () => { + const id = seedProxyNode(true); + const db = DatabaseService.getInstance(); + db.insertMeshStack(id, 'whoami', 'admin'); + setAliasCache([ + { host: 'whoami.whoami.peer.sencho', nodeId: id, nodeName: 'peer', stackName: 'whoami', serviceName: 'whoami', port: 80 }, + ]); + + const statuses = await MeshService.getInstance().getStatus(); + const entry = statuses.find((s) => s.nodeId === id); + expect(entry?.optedInStacks).toEqual([{ stackName: 'whoami', currentlyResolvable: true }]); + }); + + it('reports currentlyResolvable=false when the alias cache is empty for the stack', async () => { + const id = seedProxyNode(true); + DatabaseService.getInstance().insertMeshStack(id, 'whoami', 'admin'); + setAliasCache([]); + + const statuses = await MeshService.getInstance().getStatus(); + const entry = statuses.find((s) => s.nodeId === id); + expect(entry?.optedInStacks).toEqual([{ stackName: 'whoami', currentlyResolvable: false }]); + }); + + it('handles a mix of resolvable and suspended opt-ins on the same node', async () => { + const id = seedProxyNode(true); + const db = DatabaseService.getInstance(); + db.insertMeshStack(id, 'whoami', 'admin'); + db.insertMeshStack(id, 'paperless', 'admin'); + setAliasCache([ + { host: 'whoami.whoami.peer.sencho', nodeId: id, nodeName: 'peer', stackName: 'whoami', serviceName: 'whoami', port: 80 }, + ]); + + const statuses = await MeshService.getInstance().getStatus(); + const entry = statuses.find((s) => s.nodeId === id); + const byName = new Map(entry?.optedInStacks.map((s) => [s.stackName, s.currentlyResolvable]) ?? []); + expect(byName.get('whoami')).toBe(true); + expect(byName.get('paperless')).toBe(false); + expect(entry?.optedInStacks).toHaveLength(2); + }); + + it('returns an empty optedInStacks array for a node with no opt-ins (shape regression)', async () => { + const id = seedProxyNode(true); + + const statuses = await MeshService.getInstance().getStatus(); + const entry = statuses.find((s) => s.nodeId === id); + expect(entry?.optedInStacks).toEqual([]); + }); + + it('does not invent a phantom opt-in from a stale alias whose stack is not in mesh_stacks', async () => { + // Invariant: `optedInStacks` is derived from the persistent + // `mesh_stacks` table; the alias cache only adjusts the + // `currentlyResolvable` flag for existing rows. A stale alias entry + // (e.g. cached for a stack that was just deleted) must NOT surface as + // a phantom opt-in. + const id = seedProxyNode(true); + // No `insertMeshStack` call — node has no opt-ins. + setAliasCache([ + { host: 'ghost.ghost.peer.sencho', nodeId: id, nodeName: 'peer', stackName: 'ghost', serviceName: 'ghost', port: 80 }, + ]); + + const statuses = await MeshService.getInstance().getStatus(); + const entry = statuses.find((s) => s.nodeId === id); + expect(entry?.optedInStacks).toEqual([]); + }); + + it('scopes resolvability per node: an alias on node A does not make stack X resolvable on node B', async () => { + const idA = seedProxyNode(true); + const idB = seedProxyNode(true); + const db = DatabaseService.getInstance(); + db.insertMeshStack(idA, 'shared', 'admin'); + db.insertMeshStack(idB, 'shared', 'admin'); + // Alias exists only for node A. + setAliasCache([ + { host: 'shared.shared.peer-a.sencho', nodeId: idA, nodeName: 'peer-a', stackName: 'shared', serviceName: 'shared', port: 80 }, + ]); + + const statuses = await MeshService.getInstance().getStatus(); + const a = statuses.find((s) => s.nodeId === idA); + const b = statuses.find((s) => s.nodeId === idB); + expect(a?.optedInStacks).toEqual([{ stackName: 'shared', currentlyResolvable: true }]); + expect(b?.optedInStacks).toEqual([{ stackName: 'shared', currentlyResolvable: false }]); + }); +}); diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index f40db049..14f65266 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -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(); + 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, }; }); diff --git a/docs/features/sencho-mesh.mdx b/docs/features/sencho-mesh.mdx index bfea30d9..73a785ee 100644 --- a/docs/features/sencho-mesh.mdx +++ b/docs/features/sencho-mesh.mdx @@ -180,6 +180,10 @@ A few things are deliberately out of scope for the first release: The stack is opted into the mesh but exposes no service ports that became aliases. The stack joins `sencho_mesh` (other meshed containers can talk to it directly by container name) but no fleet-wide hostname is published. To publish an alias, declare a port on a service in the stack's compose file and redeploy. + + The stack is opted into the mesh, but its services are not currently running, so there are no aliases to publish. The opt-in is sticky: when the stack starts again, its aliases reappear automatically on the next refresh (within roughly one minute) without needing a manual opt-out and re-opt-in. To clear the suspended state, start the stack from its **Overview** page. To remove the opt-in entirely, open the node card and use the opt-out action. + + The Routing tab polls `/mesh/status` and `/mesh/aliases` every 30 seconds while the browser tab is focused. To force an immediate refresh, leave and return to the Routing tab, or toggle any stack's mesh state to trigger an action-driven refresh. Polling pauses when the tab is hidden, so a long-dormant tab catches up on the first poll after it regains focus. diff --git a/frontend/src/components/fleet/RoutingNodeCard.tsx b/frontend/src/components/fleet/RoutingNodeCard.tsx index 4e0b224e..551b30a6 100644 --- a/frontend/src/components/fleet/RoutingNodeCard.tsx +++ b/frontend/src/components/fleet/RoutingNodeCard.tsx @@ -25,6 +25,16 @@ export function RoutingNodeCard({ const [testingAlias, setTestingAlias] = useState(null); const nodeAliases = aliases.filter((a) => a.nodeId === status.nodeId); + const hasOptIns = status.optedInStacks.length > 0; + // Defensive de-dup: `/mesh/status` and `/mesh/aliases` are fetched + // separately, so a transient inconsistency between the two snapshots + // could otherwise render both a live alias row and a suspended row for + // the same stack. Drop suspended entries that the alias snapshot still + // covers; the alias row already represents the live state. + const stackNamesWithAliases = new Set(nodeAliases.map((a) => a.stackName)); + const suspendedOptIns = status.optedInStacks.filter( + (s) => !s.currentlyResolvable && !stackNamesWithAliases.has(s.stackName), + ); const toggleEnabled = async (next: boolean) => { setToggling(true); @@ -122,7 +132,7 @@ export function RoutingNodeCard({ {status.enabled && ( <>
- {nodeAliases.length === 0 && ( + {!hasOptIns && nodeAliases.length === 0 && (
No mesh services on this node yet.
)} {nodeAliases.map((a) => { @@ -156,6 +166,25 @@ export function RoutingNodeCard({
); })} + {suspendedOptIns.map((s) => ( +
+ + {s.stackName} + + + suspended + +
+ ))} + {suspendedOptIns.length > 0 && ( +
+ Stack stopped, alias resumes when services start. +
+ )}