mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 07:13:05 +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:
@@ -25,6 +25,16 @@ export function RoutingNodeCard({
|
||||
const [testingAlias, setTestingAlias] = useState<string | null>(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 && (
|
||||
<>
|
||||
<div className="border-t border-card-border pt-2 space-y-1">
|
||||
{nodeAliases.length === 0 && (
|
||||
{!hasOptIns && nodeAliases.length === 0 && (
|
||||
<div className="text-[11px] text-stat-subtitle">No mesh services on this node yet.</div>
|
||||
)}
|
||||
{nodeAliases.map((a) => {
|
||||
@@ -156,6 +166,25 @@ export function RoutingNodeCard({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{suspendedOptIns.map((s) => (
|
||||
<div
|
||||
key={`suspended-${s.stackName}`}
|
||||
className="flex items-center justify-between gap-2 rounded border border-card-border bg-card px-2 py-1.5"
|
||||
title="Stack is opted into the mesh but has no running services. Aliases will publish when the stack starts."
|
||||
>
|
||||
<span className="text-[11px] font-mono text-left truncate text-stat-subtitle">
|
||||
{s.stackName}
|
||||
</span>
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded-sm border border-amber-500/40 bg-amber-500/10 text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-amber-600 dark:text-amber-400">
|
||||
suspended
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{suspendedOptIns.length > 0 && (
|
||||
<div className="text-[11px] text-stat-subtitle pt-1">
|
||||
Stack stopped, alias resumes when services start.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline" size="sm" className="w-full"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -53,7 +53,14 @@ 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. `currentlyResolvable` is `true`
|
||||
* iff the central's alias cache currently carries at least one alias for
|
||||
* that (nodeId, stackName) pair. A suspended opt-in (stack stopped,
|
||||
* services not running) reports `currentlyResolvable: false`; the Routing
|
||||
* tab renders a `suspended` pill for those entries.
|
||||
*/
|
||||
optedInStacks: Array<{ stackName: string; currentlyResolvable: boolean }>;
|
||||
activeStreamCount: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user