Files
sencho/backend/src/helpers/remoteCapabilities.ts
T
Anso d113004359 feat: add confirmed Take down stack action with optional volume removal (#1599)
* feat: add confirmed Take down stack action with optional volume removal

Expose Take down in the stack header and sidebar with a confirmation dialog
that runs compose down while keeping the stack definition on disk. Optional
volume removal is gated by node capability and stack:deploy permission, with
remote gateway preflight before proxying removeVolumes requests.

Closes #1582

* fix: reset take-down volume checkbox when dialog closes

* test: align getStackMenuVisibility assertions with showTakeDown key

getStackMenuVisibility now returns a fifth lifecycle flag, showTakeDown,
but three exhaustive toEqual assertions still listed only the prior four
keys and failed. Add the expected showTakeDown value to each: true for
the partial and exited running-stack cases, false for the self stack.

* test: cover Take down visibility for running non-self stacks

The getStackMenuVisibility assertions exercised the partial and exited
branches and the self-stack guard, but not the raw === 'running' literal
that drives showTakeDown for a normal running stack. Add a case so a
regression dropping 'running' from that check is caught.

* fix: drop Take down from header overflow and wire activity shortcut

Remove duplicate Take down from More actions.

Keep inline button when running, sidebar menu, and Cmd+ArrowDown.

Record stack_taken_down in activity on successful POST /down.
2026-07-09 12:20:13 -04:00

53 lines
1.8 KiB
TypeScript

import { NodeRegistry } from '../services/NodeRegistry';
import { CROSS_NODE_RBAC_CAPABILITY } from '../services/CapabilityRegistry';
import { getErrorMessage } from '../utils/errors';
// In-flight probes deduped per node+capability so concurrent checks for
// different capabilities on the same node cannot share the wrong boolean.
const inFlight = new Map<string, Promise<boolean>>();
function probeKey(nodeId: number, capability: string): string {
return `${nodeId}:${capability}`;
}
/**
* Whether a remote node advertises a given capability. Probes the remote's live
* /api/meta on every call (concurrent calls for the same node+capability share
* one probe).
*
* Fails closed: unsupported, offline, or unreachable remotes return false.
*/
export async function remoteAdvertisesCapability(nodeId: number, capability: string): Promise<boolean> {
const key = probeKey(nodeId, capability);
const existing = inFlight.get(key);
if (existing) return existing;
const probe = (async (): Promise<boolean> => {
try {
const meta = await NodeRegistry.getInstance().fetchMetaForNode(nodeId);
return meta.capabilities.includes(capability);
} catch (err) {
console.warn(
`[RemoteCapability] Could not verify "${capability}" for node ${nodeId}; treating as unsupported:`,
getErrorMessage(err, 'unknown'),
);
return false;
}
})();
inFlight.set(key, probe);
try {
return await probe;
} finally {
inFlight.delete(key);
}
}
/**
* Whether a remote node advertises cross-node RBAC enforcement for proxied
* requests. Thin wrapper over {@link remoteAdvertisesCapability}.
*/
export async function remoteSupportsCrossNodeRbac(nodeId: number): Promise<boolean> {
return remoteAdvertisesCapability(nodeId, CROSS_NODE_RBAC_CAPABILITY);
}