mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +00:00
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:
@@ -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<string, MeshGlobalAlias>;
|
||||
};
|
||||
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 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user