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.
This commit is contained in:
Anso
2026-07-09 12:20:13 -04:00
committed by GitHub
parent 296ddff2a0
commit d113004359
48 changed files with 952 additions and 110 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ describe('getAuditSummary()', () => {
});
it('resolves wildcard match: POST /stacks/mystack/down', () => {
expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Stopped stack: mystack');
expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Took stack down: mystack');
});
it('resolves wildcard match: POST /stacks/mystack/rollback', () => {
@@ -1093,6 +1093,49 @@ describe('ComposeService - withRegistryAuth', () => {
// ── downStack ──────────────────────────────────────────────────────────
describe('ComposeService - runDown', () => {
it('runs plain docker compose down by default', async () => {
setupAutoCloseSpawn();
const svc = ComposeService.getInstance(1);
await svc.runDown('my-stack');
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'down'],
expect.any(Object)
);
});
it('runs docker compose down --volumes when removeVolumes is true', async () => {
setupAutoCloseSpawn();
const svc = ComposeService.getInstance(1);
await svc.runDown('my-stack', { removeVolumes: true });
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'down', '--volumes'],
expect.any(Object)
);
});
it('does not pass --volumes when removeVolumes is false', async () => {
setupAutoCloseSpawn();
const svc = ComposeService.getInstance(1);
await svc.runDown('my-stack', { removeVolumes: false });
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'down'],
expect.any(Object)
);
});
});
// ── downStack ──────────────────────────────────────────────────────────
describe('ComposeService - downStack', () => {
it('runs docker compose down with volumes and remove-orphans', async () => {
setupAutoCloseSpawn();
@@ -0,0 +1,122 @@
/**
* Gateway preflight for POST /stacks/:name/down?removeVolumes=true on remote nodes.
* Unsupported remotes must return 400 before the proxy forwards the request.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'http';
import bcrypt from 'bcrypt';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let viewerBearer: string;
let adminBearer: string;
let capServer: http.Server;
let noCapServer: http.Server;
let capNodeId: number;
let noCapNodeId: number;
const noCapPaths: string[] = [];
const capPaths: string[] = [];
function metaServer(capabilities: string[], seen: string[]): http.Server {
return http.createServer((req, res) => {
if (req.url) seen.push(req.url);
res.writeHead(200, { 'Content-Type': 'application/json' });
if (req.url?.startsWith('/api/meta')) {
res.end(JSON.stringify({ version: '0.93.0', capabilities }));
} else {
res.end(JSON.stringify({ status: 'Command started' }));
}
});
}
async function listen(server: http.Server): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return (server.address() as import('net').AddressInfo).port;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
db.addUser({ username: 'vol-viewer', password_hash: hash, role: 'viewer' });
const viewer = db.getUserByUsername('vol-viewer')!;
viewerBearer = jwt.sign({ username: 'vol-viewer', role: 'viewer', tv: viewer.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' });
adminBearer = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
capServer = metaServer(['cross-node-rbac', 'stack-down-remove-volumes'], capPaths);
noCapServer = metaServer(['cross-node-rbac'], noCapPaths);
const capPort = await listen(capServer);
const noCapPort = await listen(noCapServer);
capNodeId = db.addNode({
name: 'vol-cap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp',
is_default: false, api_url: `http://127.0.0.1:${capPort}`, api_token: 'cap-token',
});
noCapNodeId = db.addNode({
name: 'vol-nocap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp',
is_default: false, api_url: `http://127.0.0.1:${noCapPort}`, api_token: 'nocap-token',
});
});
afterAll(async () => {
await new Promise<void>((resolve) => capServer.close(() => resolve()));
await new Promise<void>((resolve) => noCapServer.close(() => resolve()));
cleanupTestDb(tmpDir);
});
describe('remote proxy stack-down volume gate', () => {
it('returns 400 for removeVolumes=true when remote lacks stack-down-remove-volumes (admin)', async () => {
noCapPaths.length = 0;
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Authorization', `Bearer ${adminBearer}`)
.set('x-node-id', String(noCapNodeId));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not supported/i);
expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(false);
expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(true);
});
it('returns 400 for removeVolumes=true when remote lacks capability (non-admin)', async () => {
noCapPaths.length = 0;
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(noCapNodeId));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not supported/i);
expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(false);
});
it('proxies removeVolumes=true when remote advertises stack-down-remove-volumes', async () => {
capPaths.length = 0;
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Authorization', `Bearer ${adminBearer}`)
.set('x-node-id', String(capNodeId));
expect(res.status).toBe(200);
expect(capPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(true);
});
it('does not preflight plain down without removeVolumes', async () => {
noCapPaths.length = 0;
const res = await request(app)
.post('/api/stacks/web/down')
.set('Authorization', `Bearer ${adminBearer}`)
.set('x-node-id', String(noCapNodeId));
expect(res.status).toBe(200);
expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(true);
expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(false);
});
});
@@ -10,6 +10,7 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import type { RemoteMeta } from '../services/CapabilityRegistry';
let remoteSupportsCrossNodeRbac: typeof import('../helpers/remoteCapabilities').remoteSupportsCrossNodeRbac;
let remoteAdvertisesCapability: typeof import('../helpers/remoteCapabilities').remoteAdvertisesCapability;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
let tmpDir: string;
@@ -17,7 +18,7 @@ const NODE_ID = 4242;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ remoteSupportsCrossNodeRbac } = await import('../helpers/remoteCapabilities'));
({ remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } = await import('../helpers/remoteCapabilities'));
({ NodeRegistry } = await import('../services/NodeRegistry'));
});
@@ -79,3 +80,23 @@ describe('remoteSupportsCrossNodeRbac', () => {
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('remoteAdvertisesCapability', () => {
it('returns true when the remote advertises the requested capability', async () => {
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
.mockResolvedValue({ version: '0.93.0', capabilities: ['stack-down-remove-volumes'], ...ONLINE });
expect(await remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes')).toBe(true);
});
it('dedupes concurrent probes per node+capability, not per node alone', async () => {
const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
.mockResolvedValue({ version: '0.93.0', capabilities: ['cross-node-rbac', 'stack-down-remove-volumes'], ...ONLINE });
const [rbac, volumes] = await Promise.all([
remoteAdvertisesCapability(NODE_ID, 'cross-node-rbac'),
remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes'),
]);
expect(rbac).toBe(true);
expect(volumes).toBe(true);
expect(spy).toHaveBeenCalledTimes(2);
});
});
@@ -18,10 +18,12 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
}));
@@ -36,6 +38,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
}),
},
@@ -72,8 +75,8 @@ describe('POST /api/stacks/:stackName/deploy on a nonexistent stack', () => {
});
describe('POST /api/stacks/:stackName/down on a nonexistent stack', () => {
it('returns 404 with "Stack not found" and never enters ComposeService.runCommand', async () => {
mockRunCommand.mockClear();
it('returns 404 with "Stack not found" and never enters ComposeService.runDown', async () => {
mockRunDown.mockClear();
const res = await request(app)
.post('/api/stacks/does-not-exist-f7/down')
@@ -81,7 +84,7 @@ describe('POST /api/stacks/:stackName/down on a nonexistent stack', () => {
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Stack not found' });
expect(mockRunCommand).not.toHaveBeenCalled();
expect(mockRunDown).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,7 @@ import { isDockerUnavailableError } from '../routes/stacks';
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockGetContainersByStack,
mockRestartContainer,
@@ -26,6 +27,7 @@ const {
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
mockRestartContainer: vi.fn(),
@@ -44,6 +46,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
}),
},
@@ -98,6 +101,7 @@ afterAll(() => {
beforeEach(() => {
mockDeployStack.mockReset();
mockRunCommand.mockReset();
mockRunDown.mockReset();
mockUpdateStack.mockReset();
mockGetContainersByStack.mockReset();
mockRestartContainer.mockReset();
@@ -211,8 +215,8 @@ describe('POST /api/stacks/:name/deploy with daemon down', () => {
});
describe('POST /api/stacks/:name/down with daemon down', () => {
it('returns 503 with code: docker_unavailable when runCommand surfaces the daemon error', async () => {
mockRunCommand.mockRejectedValue(composeDaemonDownError());
it('returns 503 with code: docker_unavailable when runDown surfaces the daemon error', async () => {
mockRunDown.mockRejectedValue(composeDaemonDownError());
const res = await request(app)
.post('/api/stacks/web/down')
@@ -0,0 +1,181 @@
/**
* Route tests for POST /api/stacks/:name/down and optional ?removeVolumes=true.
*/
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { generateApiToken } from '../utils/apiTokenFormat';
import {
disableCapability,
enableCapability,
STACK_DOWN_REMOVE_VOLUMES_CAPABILITY,
} from '../services/CapabilityRegistry';
const { mockRunDown } = vi.hoisted(() => ({
mockRunDown: vi.fn(),
}));
vi.mock('../services/ComposeService', async () => {
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
'../services/ComposeService',
);
return {
...actual,
ComposeService: {
...actual.ComposeService,
getInstance: () => ({
runDown: mockRunDown,
}),
},
};
});
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let dispatchAlertSpy: ReturnType<typeof vi.spyOn>;
function writeStack(name: string) {
const dir = path.join(process.env.COMPOSE_DIR!, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n');
}
function createDeployOnlyToken(): string {
const rawToken = generateApiToken();
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const db = DatabaseService.getInstance();
db.addApiToken({
token_hash: tokenHash,
name: `deploy-only-down-${Date.now()}`,
scope: 'deploy-only',
user_id: db.getUserByUsername('testadmin')!.id,
created_at: Date.now(),
expires_at: null,
});
return rawToken;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
writeStack('web');
const { NotificationService } = await import('../services/NotificationService');
dispatchAlertSpy = vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
});
afterAll(() => {
vi.restoreAllMocks();
enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
cleanupTestDb(tmpDir);
});
beforeEach(async () => {
mockRunDown.mockReset();
mockRunDown.mockResolvedValue(undefined);
enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
const { StackOpLockService } = await import('../services/StackOpLockService');
StackOpLockService.resetForTests();
});
describe('POST /api/stacks/:name/down removeVolumes', () => {
it('runs plain down when removeVolumes is omitted', async () => {
const res = await request(app)
.post('/api/stacks/web/down')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
});
it('dispatches stack_taken_down activity on plain down success', async () => {
dispatchAlertSpy.mockClear();
mockRunDown.mockResolvedValue(undefined);
const res = await request(app)
.post('/api/stacks/web/down')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(dispatchAlertSpy).toHaveBeenCalledWith(
'info',
'stack_taken_down',
'web taken down',
expect.objectContaining({ stackName: 'web', actor: 'testadmin' }),
);
});
it('notes volume removal in the activity message when removeVolumes=true', async () => {
dispatchAlertSpy.mockClear();
mockRunDown.mockResolvedValue(undefined);
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(dispatchAlertSpy).toHaveBeenCalledWith(
'info',
'stack_taken_down',
'web taken down (volumes removed)',
expect.objectContaining({ stackName: 'web' }),
);
});
it('runs plain down when removeVolumes=false', async () => {
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=false')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
});
it('does not enable volumes when removeVolumes=1 (only exact true)', async () => {
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=1')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
});
it('returns 400 when removeVolumes=true but capability is absent locally', async () => {
disableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Cookie', authCookie);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not supported/i);
expect(mockRunDown).not.toHaveBeenCalled();
});
it('passes removeVolumes=true to runDown when capability is present', async () => {
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined);
});
it('allows deploy-only API tokens to POST /down?removeVolumes=true', async () => {
const token = createDeployOnlyToken();
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Authorization', `Bearer ${token}`);
expect(res.body.code).not.toBe('SCOPE_DENIED');
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined);
});
});
@@ -17,6 +17,7 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockGetContainersByStack,
mockRestartContainer,
@@ -25,6 +26,7 @@ const {
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
mockRestartContainer: vi.fn(),
@@ -43,6 +45,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
}),
},
@@ -97,6 +100,7 @@ afterAll(() => {
beforeEach(async () => {
mockDeployStack.mockReset();
mockRunCommand.mockReset();
mockRunDown.mockReset();
mockUpdateStack.mockReset();
mockGetContainersByStack.mockReset();
mockRestartContainer.mockReset();
@@ -233,13 +237,13 @@ describe('Stack lifecycle mutex', () => {
it('blocks update while down is in flight', async () => {
const gate = deferred<void>();
mockRunCommand.mockImplementationOnce(() => gate.promise);
mockRunDown.mockImplementationOnce(() => gate.promise);
const down = request(app)
.post('/api/stacks/web/down')
.set('Cookie', authCookie)
.then(r => r);
await vi.waitFor(() => expect(mockRunCommand).toHaveBeenCalled());
await vi.waitFor(() => expect(mockRunDown).toHaveBeenCalled());
const update = await request(app)
.post('/api/stacks/web/update')
@@ -13,6 +13,7 @@ import { SELF_STACK_PROTECTED_CODE } from '../helpers/selfStackGuard';
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockDownStack,
mockGetContainersByStack,
@@ -22,6 +23,7 @@ const {
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockDownStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
@@ -41,6 +43,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
downStack: mockDownStack,
}),
@@ -146,6 +149,7 @@ describe('self stack lifecycle refusal', () => {
expect(mockDeployStack).not.toHaveBeenCalled();
expect(mockUpdateStack).not.toHaveBeenCalled();
expect(mockRunCommand).not.toHaveBeenCalled();
expect(mockRunDown).not.toHaveBeenCalled();
expect(mockDownStack).not.toHaveBeenCalled();
expect(mockStopContainer).not.toHaveBeenCalled();
});
@@ -19,6 +19,7 @@ import * as policyGate from '../helpers/policyGate';
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockGetContainersByStack,
mockRestartContainer,
@@ -33,6 +34,7 @@ const {
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
mockRestartContainer: vi.fn(),
@@ -57,6 +59,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
}),
},
@@ -140,6 +143,7 @@ afterAll(() => {
beforeEach(() => {
mockDeployStack.mockReset();
mockRunCommand.mockReset();
mockRunDown.mockReset();
mockUpdateStack.mockReset();
mockGetContainersByStack.mockReset();
mockRestartContainer.mockReset();
@@ -388,8 +392,8 @@ describe('post-deploy scan opt-out', () => {
});
describe('deploy_failure notification on /down error', () => {
it('dispatches deploy_failure alert when runCommand (down) throws', async () => {
mockRunCommand.mockRejectedValue(new Error('container removal error'));
it('dispatches deploy_failure alert when runDown throws', async () => {
mockRunDown.mockRejectedValue(new Error('container removal error'));
const res = await request(app)
.post('/api/stacks/myapp/down')
+26 -28
View File
@@ -2,53 +2,51 @@ import { NodeRegistry } from '../services/NodeRegistry';
import { CROSS_NODE_RBAC_CAPABILITY } from '../services/CapabilityRegistry';
import { getErrorMessage } from '../utils/errors';
// In-flight probes deduped per node so a burst of concurrent gated requests
// shares one /api/meta round-trip. The entry is dropped as soon as it settles,
// so the NEXT request re-probes. The verdict is deliberately NOT cached across
// requests: a remote can be replaced by older code at the same URL (a rollback
// or image pin), and a stale "supported" verdict would reopen the cross-node
// escalation, so each gated action re-verifies against the live remote.
const inFlight = new Map<number, Promise<boolean>>();
// 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 that it enforces cross-node RBAC: the
* forwarded actor role on HTTP requests and the exact-stack allowlist on
* stop-by-label. Probes the remote's live /api/meta on every call (concurrent
* calls for the same node share one probe).
* 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: a remote that does not advertise the capability, that cannot be
* read (offline/unreachable, which yields empty capabilities), or that errors
* is treated as unsupported, so the caller denies rather than risk escalating a
* non-admin request or over-stopping. Because the probe is live, a remote
* downgraded to older code is detected on the next gated action rather than
* trusted until a cache expires.
* Fails closed: unsupported, offline, or unreachable remotes return false.
*/
export async function remoteSupportsCrossNodeRbac(nodeId: number): Promise<boolean> {
const existing = inFlight.get(nodeId);
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);
// Check the advertised capability directly. An offline/unreadable remote
// yields OFFLINE_META with empty capabilities, so this already fails
// closed; keying off the capability (not the version) also correctly
// trusts a reachable remote whose version string is non-semver, e.g. a
// 0.0.0-dev image, but that genuinely advertises the capability.
return meta.capabilities.includes(CROSS_NODE_RBAC_CAPABILITY);
return meta.capabilities.includes(capability);
} catch (err) {
console.warn(
`[CrossNodeRBAC] Could not verify capability for node ${nodeId}; treating as unsupported:`,
`[RemoteCapability] Could not verify "${capability}" for node ${nodeId}; treating as unsupported:`,
getErrorMessage(err, 'unknown'),
);
return false;
}
})();
inFlight.set(nodeId, probe);
inFlight.set(key, probe);
try {
return await probe;
} finally {
inFlight.delete(nodeId);
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);
}
+33 -26
View File
@@ -4,7 +4,8 @@ import { NodeRegistry } from '../services/NodeRegistry';
import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
import { remoteSupportsCrossNodeRbac } from '../helpers/remoteCapabilities';
import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities';
import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '../services/CapabilityRegistry';
import { getErrorMessage } from '../utils/errors';
import { DatabaseService } from '../services/DatabaseService';
import { redactSensitiveText } from '../utils/safeLog';
@@ -146,31 +147,37 @@ export function createRemoteProxyMiddleware(): RequestHandler {
return;
}
// Mixed-version RBAC gate. The forwarded actor role is enforced only by a
// remote that advertises cross-node-rbac; an older remote ignores the
// header and runs the proxied request as admin. So a non-admin must not be
// forwarded to a remote that does not advertise the capability. Admins are
// unaffected (they are admin on the remote regardless), and the check is
// skipped for them so it never adds latency to the admin path. Fails closed
// when the capability cannot be determined. Using `?.` so an unresolved user
// (not reachable past authGate, but defensive) is gated, never waved through.
if (req.user?.role !== 'admin') {
remoteSupportsCrossNodeRbac(req.nodeId)
.then((supported) => {
if (!supported) {
res.status(403).json({
error: `Remote node "${node.name}" is running a version that does not enforce per-user permissions. Upgrade it before non-admin users can act on it.`,
});
return;
}
req.proxyTarget = target;
proxy(req, res, next);
})
.catch(next);
return;
}
const runGatedProxy = async (): Promise<void> => {
if (isStackDownWithRemoveVolumes(req)) {
const supported = await remoteAdvertisesCapability(req.nodeId, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
if (!supported) {
res.status(400).json({ error: 'Volume removal is not supported on this node' });
return;
}
}
req.proxyTarget = target;
proxy(req, res, next);
// Mixed-version RBAC gate (non-admin only).
if (req.user?.role !== 'admin') {
const rbacSupported = await remoteSupportsCrossNodeRbac(req.nodeId);
if (!rbacSupported) {
res.status(403).json({
error: `Remote node "${node.name}" is running a version that does not enforce per-user permissions. Upgrade it before non-admin users can act on it.`,
});
return;
}
}
req.proxyTarget = target;
proxy(req, res, next);
};
runGatedProxy().catch(next);
};
}
/** POST /stacks/:stackName/down with ?removeVolumes=true (path is post-/api strip). */
function isStackDownWithRemoveVolumes(req: Request): boolean {
if (req.method !== 'POST') return false;
if (!/^\/stacks\/[^/]+\/down$/.test(req.path)) return false;
return req.query.removeVolumes === 'true';
}
+15 -3
View File
@@ -56,6 +56,7 @@ import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/e
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
import { isSelfStack, refuseIfSelfStack, selfStackProtectedBulkResult } from '../helpers/selfStackGuard';
import { getActiveCapabilities, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '../services/CapabilityRegistry';
// Authenticated users with edit permission can write arbitrarily large compose
// files. Refuse to YAML.parse anything beyond this bound so a malformed (or
@@ -86,7 +87,7 @@ function notifyActionSuccess(category: NotificationCategory, message: string, st
const STACK_OP_PRESENT_PARTICIPLE: Record<StackOpAction, string> = {
deploy: 'deploying',
down: 'stopping',
down: 'taking down',
restart: 'restarting',
stop: 'stopping',
start: 'starting',
@@ -1664,11 +1665,22 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => {
if (!tryAcquireStackOpLock(req, res, stackName, 'down')) return;
const t0 = Date.now();
let ok = false;
const removeVolumes = req.query.removeVolumes === 'true';
try {
if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId });
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs(req.get(DEPLOY_SESSION_HEADER)));
if (removeVolumes && !getActiveCapabilities().includes(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY)) {
res.status(400).json({ error: 'Volume removal is not supported on this node' });
return;
}
if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId, removeVolumes });
await ComposeService.getInstance(req.nodeId).runDown(stackName, { removeVolumes }, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)));
invalidateNodeCaches(req.nodeId);
dlog(`[Stacks] Down completed: ${sanitizeForLog(stackName)}`);
notifyActionSuccess(
'stack_taken_down',
`${stackName} taken down${removeVolumes ? ' (volumes removed)' : ''}`,
stackName,
req.user?.username ?? 'system',
);
ok = true;
res.json({ status: 'Command started' });
} catch (error: unknown) {
@@ -54,6 +54,7 @@ export const CAPABILITIES = [
'project-env-files',
'compose-storage',
'cross-node-rbac',
'stack-down-remove-volumes',
] as const;
/**
@@ -67,6 +68,9 @@ export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac';
export type Capability = (typeof CAPABILITIES)[number];
/** Capability for optional `?removeVolumes=true` on POST /stacks/:name/down. */
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
/** Returns true when the string is a usable semver version. */
export function isValidVersion(v: string | null | undefined): v is string {
return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v);
+7
View File
@@ -390,6 +390,13 @@ export class ComposeService {
await this.execute('docker', await this.authoredComposeArgs(stackName, [action]), stackDir, ws);
}
/** Interactive compose down (Take down UI / POST /down). Plain `down` by default. */
async runDown(stackName: string, options?: { removeVolumes?: boolean }, ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const args = options?.removeVolumes ? ['down', '--volumes'] : ['down'];
await this.execute('docker', await this.authoredComposeArgs(stackName, args), stackDir, ws);
}
/**
* Opt-in guard: when `env_block_deploy_on_missing_required` is enabled, refuse a
* deploy whose required `${VAR:?err}` variables are unset OR empty, before any
+2 -1
View File
@@ -19,6 +19,7 @@ export type NotificationCategory =
| 'stack_started'
| 'stack_stopped'
| 'stack_restarted'
| 'stack_taken_down'
| 'image_update_available'
| 'image_update_applied'
| 'autoheal_triggered'
@@ -43,7 +44,7 @@ export type NotificationCategory =
export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped',
'stack_restarted', 'image_update_available', 'image_update_applied',
'stack_restarted', 'stack_taken_down', 'image_update_available', 'image_update_applied',
'autoheal_triggered', 'monitor_alert', 'scan_finding',
'blueprint_deployed', 'blueprint_deployment_failed',
'blueprint_drift_detected', 'blueprint_drift_correction_failed',
+1 -1
View File
@@ -14,7 +14,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
// Stack lifecycle
'POST /stacks/*/deploy': 'Deployed stack',
'POST /stacks/*/down': 'Stopped stack',
'POST /stacks/*/down': 'Took stack down',
'POST /stacks/*/start': 'Started stack',
'POST /stacks/*/stop': 'Stopped stack',
'POST /stacks/*/restart': 'Restarted stack',
+1 -1
View File
@@ -150,7 +150,7 @@ Authorises every `GET`, plus exactly six `POST` patterns that operate on a stack
| Method | Path pattern | Action |
|--------|--------------|--------|
| `POST` | `/api/stacks/:name/deploy` | Create or update and bring up. |
| `POST` | `/api/stacks/:name/down` | Tear down. |
| `POST` | `/api/stacks/:name/down` | Tear down containers and compose-created networks. Add `?removeVolumes=true` to also remove compose volumes when the node supports it. |
| `POST` | `/api/stacks/:name/restart` | Restart in place. |
| `POST` | `/api/stacks/:name/stop` | Stop without removing. |
| `POST` | `/api/stacks/:name/start` | Start a stopped stack. |
+1 -1
View File
@@ -160,7 +160,7 @@ Of those, **Deploy**, **Update**, **Install**, and Git **Apply** route through `
The [health gate](#health-gate) activates only after **Deploy** and **Update**; it does not fire for Restart, Stop, Install, Git Apply, or Scanning.
The HTTP API also exposes a `down` action (compose-level teardown) that streams its output the same way Deploy and Update do, but no UI control currently triggers it; the `down` endpoint is reachable from automation and from Sencho's own internal cleanup paths.
The HTTP API exposes **Take down** as `POST /api/stacks/:name/down`. The stack header, sidebar context menu, and confirmation dialog call this endpoint. Compose output streams through the same progress modal as Deploy and Update. Optional `?removeVolumes=true` removes compose volumes when the node advertises support.
## Troubleshooting
+2 -1
View File
@@ -25,12 +25,13 @@ The top card on the left holds the stack's identity and primary controls.
### Stack action bar
The action bar runs every state transition for the whole stack. The primary buttons (**Start**, **Restart**, **Stop**, **Update**) require the `stack:deploy` permission; the **Delete** entry in the kebab dropdown requires the `stack:delete` permission. The bar still appears when only **Delete** is authorised so the operator has a way to remove the stack.
The action bar runs every state transition for the whole stack. The primary buttons (**Start**, **Restart**, **Stop**, **Take down** when running, **Update**) require the `stack:deploy` permission; the **Delete** entry in the kebab dropdown requires the `stack:delete` permission. The bar still appears when only **Delete** is authorised so the operator has a way to remove the stack.
| Button | Behavior |
|--------|----------|
| **Start** / **Restart** | A single button that becomes **Restart** when at least one container is running and **Start** otherwise. |
| **Stop** | Stops every container in the stack. Hidden when nothing is running. |
| **Take down** | Shown when at least one container is running. Opens a confirmation dialog, then runs `docker compose down`. Removes containers and compose-created networks while keeping the stack directory. Optional volume removal is offered when the active node supports it. On stopped stacks, use the sidebar context menu or `⌘↓` / `Ctrl+↓`. |
| **Update** | Pulls fresh images and reapplies the compose file. |
| **More actions** (`⋮`) | Opens a dropdown with secondary actions. |
+1 -1
View File
@@ -30,7 +30,7 @@ Each row is one of the permission keys the backend checks. The matrix below is t
| Permission | Admin | Node Admin | Deployer | Auditor | Viewer |
|------------|:-----:|:----------:|:--------:|:-------:|:------:|
| View stacks, logs, stats (`stack:read`) | Yes | Yes | Yes | Yes | Yes |
| Deploy, restart, stop, start (`stack:deploy`) | Yes | Yes | Yes | No | No |
| Deploy, restart, stop, start, take down (`stack:deploy`) | Yes | Yes | Yes | No | No |
| Edit compose and `.env` files (`stack:edit`) | Yes | Yes | No | No | No |
| Create stacks (`stack:create`) | Yes | Yes | No | No | No |
| Delete stacks (`stack:delete`) | Yes | Yes | No | No | No |
+1
View File
@@ -119,6 +119,7 @@ Shortcuts fire on the currently selected stack. They are blocked while a text in
| <kbd>Ctrl</kbd>+<kbd>.</kbd> | Stop | Stack is running |
| <kbd>Ctrl</kbd>+<kbd>R</kbd> | Restart | Stack is running |
| <kbd>Ctrl</kbd>+<kbd>↑</kbd> | Update images | Stack is running |
| <kbd>Ctrl</kbd>+<kbd>↓</kbd> | Take down | Stack has been deployed (opens confirmation) |
| <kbd>Ctrl</kbd>+<kbd>Backspace</kbd> | Delete | Stack exists |
On macOS, use <kbd>Cmd</kbd> in place of <kbd>Ctrl</kbd>.
+6
View File
@@ -347,6 +347,7 @@ The stack header groups actions by frequency of use. The most common action is t
|-----------|--------|---------|--------------|
| Primary | **Restart** | `docker compose restart` | Restarts all containers in the stack. |
| Secondary | **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. |
| Secondary | **Take down** | `docker compose down` | Removes containers and compose-created networks. The stack definition stays on disk so you can deploy again later. Optional volume removal is available in the confirmation dialog. |
| Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. |
| Overflow | **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists. |
| Overflow | **Scan config** | Trivy config scan | Scans the compose file for misconfigurations (admin role). |
@@ -360,8 +361,12 @@ The stack header groups actions by frequency of use. The most common action is t
| Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. |
| Overflow | **Delete** | Removes files | Deletes the stack directory. |
Use the sidebar context menu or `⌘↓` / `Ctrl+↓` for **Take down** on a stopped stack.
<Warning>
**Delete** is irreversible. It removes the stack directory, including `compose.yaml`, `.env`, and any bind-mounted files stored there. Back up important files before deleting.
**Take down** keeps your compose files on disk. It removes running containers and compose-created networks. When you opt in to volume removal, named and anonymous compose volumes are removed as well. Use **Stop** when you want containers to stay in place for a quick resume.
</Warning>
<Note>
@@ -426,6 +431,7 @@ Click the kebab on a stack row in the sidebar to open its context menu. The menu
**lifecycle**
- **Stop** (`⌘.`): shown when running.
- **Take down** (`⌘↓`): shown when the stack has been deployed (running, partial, or exited). Opens a confirmation dialog; optional volume removal is offered when the active node supports it.
- **Restart** (`⌘R`): shown when running.
- **Update** (`⌘↑`): pulls the latest image tags and redeploys.
- **Schedule task**: open the scheduler pre-filled for this stack; pick **Auto-update Stack** to set up unattended image updates on your own cadence. See [Scheduled Operations](/features/scheduled-operations) and [Auto-Update Policies](/features/auto-update-policies).
+13 -1
View File
@@ -1420,10 +1420,16 @@ paths:
operationId: downStack
tags: [Stacks]
summary: Tear down stack
description: Runs `docker compose down` for the stack, removing containers and networks. Requires `stack:deploy` permission.
description: Runs `docker compose down` for the stack, removing containers and networks. The stack definition remains on disk. Requires `stack:deploy` permission. Pass `removeVolumes=true` to also remove compose volumes when the node advertises the `stack-down-remove-volumes` capability.
parameters:
- $ref: "#/components/parameters/stackName"
- $ref: "#/components/parameters/nodeId"
- name: removeVolumes
in: query
required: false
schema:
type: boolean
description: When `true`, runs `docker compose down --volumes`. Only honored when the target node advertises `stack-down-remove-volumes`.
responses:
"200":
description: Command started.
@@ -1436,6 +1442,12 @@ paths:
status:
type: string
example: Command started
"400":
description: Volume removal requested on a node that does not support it.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
$ref: "#/components/responses/Forbidden"
"500":
+8 -1
View File
@@ -30,6 +30,7 @@ import {
import { SENCHO_OPEN_LOGS_EVENT, SENCHO_OPEN_STACK_EVENT } from '@/lib/events';
import type { SenchoOpenLogsDetail, SenchoOpenStackDetail } from '@/lib/events';
import { useNodes } from '@/context/NodeContext';
import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '@/lib/capabilities';
import { useAuth } from '@/context/AuthContext';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
@@ -145,7 +146,9 @@ export default function EditorLayout() {
stacksLoadNodeId,
} = stackListState;
const { nodes, activeNode, setActiveNode, hasCapability, isLoading: nodesLoading } = useNodes();
const { nodes, activeNode, setActiveNode, hasCapability, activeNodeMeta, isLoading: nodesLoading } = useNodes();
const canOfferVolumeRemoval =
activeNodeMeta?.capabilities.includes(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY) === true;
// Mirror activeNode.id in a ref so async handlers (e.g. CreateStackDialog's
// post-create handoff) can detect a node switch that happened mid-flight.
@@ -230,6 +233,7 @@ export default function EditorLayout() {
getLastDeployOutputLine,
diffPreviewEnabled,
hasUpdateGuard: hasCapability('update-guard'),
canOfferVolumeRemoval,
});
// Wire the ref now that stackActions is available
@@ -554,6 +558,8 @@ export default function EditorLayout() {
setEditingCompose={setEditingCompose}
setGitSourceOpen={setGitSourceOpen}
requestDeleteStack={stackActions.requestDeleteStack}
requestTakeDownStack={stackActions.requestTakeDownStack}
showTakeDown={selectedFile ? stackActions.getStackMenuVisibility(selectedFile).showTakeDown : false}
isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false}
recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined}
onRefreshState={async () => {
@@ -863,6 +869,7 @@ export default function EditorLayout() {
gitSourceOpen={gitSourceOpen}
setGitSourceOpen={setGitSourceOpen}
canSelfUpdate={hasCapability('self-update')}
canOfferVolumeRemoval={canOfferVolumeRemoval}
onOpenFleetNodeUpdates={() => {
if (isMobile) {
navigateMobileAware('fleet');
@@ -67,7 +67,8 @@ export type StackAction =
| 'restart'
| 'update'
| 'delete'
| 'rollback';
| 'rollback'
| 'down';
/**
* Stack operations the recovery panel can offer safe next steps for. A failed
@@ -184,6 +185,8 @@ export interface EditorViewProps {
// Composed action: wraps setStackToDelete + setDeleteDialogOpen
requestDeleteStack: () => void;
requestTakeDownStack: (stackName: string) => void;
showTakeDown: boolean;
/** True when this stack is the running Sencho instance on the active node. */
isSelfStack?: boolean;
@@ -264,6 +267,8 @@ export function EditorView(props: EditorViewProps) {
setEditingCompose,
setGitSourceOpen,
requestDeleteStack,
requestTakeDownStack,
showTakeDown,
isSelfStack,
recoveryResult,
onRefreshState,
@@ -390,6 +395,8 @@ export function EditorView(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
requestTakeDownStack={requestTakeDownStack}
showTakeDown={showTakeDown}
isSelfStack={isSelfStack}
stackMuteActions={stackMuteActions}
/>
@@ -75,6 +75,8 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
setEditingCompose: vi.fn(),
setGitSourceOpen: vi.fn(),
requestDeleteStack: vi.fn(),
requestTakeDownStack: vi.fn(),
showTakeDown: false,
onMobileBack: vi.fn(),
onCloseEditor: vi.fn(),
hasUnsavedChanges: () => false,
@@ -67,6 +67,8 @@ export function MobileStackDetail(props: EditorViewProps) {
setEditingCompose,
setGitSourceOpen,
requestDeleteStack,
requestTakeDownStack,
showTakeDown,
isSelfStack = false,
onMobileBack,
onCloseEditor,
@@ -148,6 +150,8 @@ export function MobileStackDetail(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
requestTakeDownStack={requestTakeDownStack}
showTakeDown={showTakeDown}
isSelfStack={isSelfStack}
stackMuteActions={stackMuteActions}
/>
@@ -4,6 +4,7 @@ import { PreDeployScanDialog } from '../stack/PreDeployScanDialog';
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog';
import { DeleteStackDialog } from './DeleteStackDialog';
import { TakeDownStackDialog } from './TakeDownStackDialog';
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
import { StackAlertSheet } from '../StackAlertSheet';
import { GitSourcePanel } from '../stack/GitSourcePanel';
@@ -25,6 +26,7 @@ interface ShellOverlaysProps {
gitSourceOpen: boolean;
setGitSourceOpen: (open: boolean) => void;
canSelfUpdate: boolean;
canOfferVolumeRemoval: boolean;
onOpenFleetNodeUpdates: () => void;
}
@@ -39,10 +41,12 @@ export function ShellOverlays({
gitSourceOpen,
setGitSourceOpen,
canSelfUpdate,
canOfferVolumeRemoval,
onOpenFleetNodeUpdates,
}: ShellOverlaysProps) {
const {
deleteDialogOpen, closeDeleteDialog, stackToDelete,
takeDownDialogOpen, closeTakeDownDialog, stackToTakeDown,
pendingUnsavedLoad, pendingLeaveAction,
bashModalOpen, selectedContainer,
logViewerOpen, logContainer,
@@ -64,6 +68,14 @@ export function ShellOverlays({
onConfirm={stackActions.deleteStack}
/>
<TakeDownStackDialog
open={takeDownDialogOpen}
onOpenChange={(open) => { if (!open) closeTakeDownDialog(); }}
stackName={stackToTakeDown}
showVolumeOption={canOfferVolumeRemoval}
onConfirm={stackActions.takeDownStack}
/>
<SelfStackProtectedDialog
open={selfStackProtectedOpen}
onOpenChange={setSelfStackProtectedOpen}
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
import { ConfirmModal } from '../ui/modal';
import { Checkbox } from '../ui/checkbox';
export interface TakeDownStackDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stackName: string | null;
showVolumeOption: boolean;
onConfirm: (removeVolumes: boolean) => void | Promise<void>;
}
export function TakeDownStackDialog({
open,
onOpenChange,
stackName,
showVolumeOption,
onConfirm,
}: TakeDownStackDialogProps) {
const [removeVolumes, setRemoveVolumes] = useState(false);
// Parent closes via overlay state (not always through handleOpenChange); reset so
// a prior volume opt-in cannot leak into the next dialog session.
useEffect(() => {
if (!open) setRemoveVolumes(false);
}, [open]);
return (
<ConfirmModal
open={open}
onOpenChange={onOpenChange}
variant="destructive"
data-testid="take-down-dialog"
kicker={`${(stackName ?? 'STACK').toUpperCase()} · TAKE DOWN${removeVolumes ? '' : ' · REVERSIBLE'}`}
title={
stackName ? (
<>
Take down <em className="font-display italic text-destructive">{stackName}</em>?
</>
) : (
'Take down stack?'
)
}
description="This removes running containers and compose-created networks. The stack configuration stays on disk so you can deploy again later."
hint={removeVolumes ? 'VOLUMES REMOVED' : 'VOLUMES KEPT'}
confirmLabel="Take down"
onConfirm={() => onConfirm(removeVolumes)}
>
{showVolumeOption && (
<div className="flex items-center gap-2">
<Checkbox
id="take-down-remove-volumes"
data-testid="take-down-remove-volumes"
checked={removeVolumes}
onCheckedChange={(v) => setRemoveVolumes(v === true)}
/>
<label
htmlFor="take-down-remove-volumes"
className="text-sm text-muted-foreground cursor-pointer select-none"
>
Also remove compose volumes (named and anonymous)
</label>
</div>
)}
</ConfirmModal>
);
}
@@ -75,6 +75,8 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
setEditingCompose: vi.fn(),
setGitSourceOpen: vi.fn(),
requestDeleteStack: vi.fn(),
requestTakeDownStack: vi.fn(),
showTakeDown: false,
onRefreshState: vi.fn(),
onDismissRecovery: vi.fn(),
panelStartedAt: null,
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'react';
import { StackIdentityHeader } from '../editor-view-blocks';
import type { ContainerInfo } from '../EditorView';
@@ -50,6 +51,8 @@ function renderHeader(over: Partial<ComponentProps<typeof StackIdentityHeader>>
rollbackStack={vi.fn()}
scanStackConfig={vi.fn()}
requestDeleteStack={vi.fn()}
requestTakeDownStack={vi.fn()}
showTakeDown={false}
{...over}
/>,
);
@@ -59,13 +62,45 @@ describe('StackIdentityHeader', () => {
it('renders stack identity and stack-wide actions without a header image line', () => {
renderHeader();
expect(screen.getByText('plex')).toBeInTheDocument();
expect(screen.getByText(/running · healthy/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Restart' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
expect(screen.getByText('plex')).toBeTruthy();
expect(screen.getByText(/running · healthy/i)).toBeTruthy();
expect(screen.getByRole('button', { name: 'Restart' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Update' })).toBeTruthy();
expect(screen.queryByText(/^image$/i)).not.toBeInTheDocument();
expect(screen.queryByText('nginx:alpine')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Copy digest' })).not.toBeInTheDocument();
expect(screen.queryByText(/^image$/i)).toBeNull();
expect(screen.queryByText('nginx:alpine')).toBeNull();
expect(screen.queryByRole('button', { name: 'Copy digest' })).toBeNull();
});
it('shows Take down when running and showTakeDown is true', () => {
renderHeader({ showTakeDown: true });
expect(screen.getByTestId('stack-take-down-button')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Take down' })).toBeTruthy();
});
it('hides Take down when showTakeDown is false', () => {
renderHeader({ showTakeDown: false, isRunning: true });
expect(screen.queryByTestId('stack-take-down-button')).toBeNull();
});
it('calls requestTakeDownStack with the stack name when Take down is clicked', async () => {
const user = userEvent.setup();
const requestTakeDownStack = vi.fn();
renderHeader({ showTakeDown: true, requestTakeDownStack });
await user.click(screen.getByTestId('stack-take-down-button'));
expect(requestTakeDownStack).toHaveBeenCalledWith('plex');
});
it('does not show Take down in the overflow menu when running', async () => {
const user = userEvent.setup();
renderHeader({ showTakeDown: true, isRunning: true, backupInfo: { exists: true, timestamp: Date.now() } });
await user.click(screen.getByRole('button', { name: 'More actions' }));
expect(screen.queryByRole('menuitem', { name: /Take down/i })).toBeNull();
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'react';
import { TakeDownStackDialog } from '../TakeDownStackDialog';
function renderDialog(
open: boolean,
overrides: Partial<ComponentProps<typeof TakeDownStackDialog>> = {},
) {
return render(
<TakeDownStackDialog
open={open}
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={vi.fn()}
{...overrides}
/>,
);
}
describe('TakeDownStackDialog', () => {
it('resets removeVolumes after parent-driven close and reopen', async () => {
const user = userEvent.setup();
const { rerender } = renderDialog(true);
const checkbox = screen.getByTestId('take-down-remove-volumes');
await user.click(checkbox);
expect(checkbox.getAttribute('data-state')).toBe('checked');
// Parent closes after async success without routing through onOpenChange(false).
rerender(
<TakeDownStackDialog
open={false}
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={vi.fn()}
/>,
);
rerender(
<TakeDownStackDialog
open
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={vi.fn()}
/>,
);
expect(screen.getByTestId('take-down-remove-volumes').getAttribute('data-state')).toBe('unchecked');
});
it('passes removeVolumes=false on confirm after parent-driven reopen', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
const { rerender } = render(
<TakeDownStackDialog
open
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={onConfirm}
/>,
);
await user.click(screen.getByTestId('take-down-remove-volumes'));
rerender(
<TakeDownStackDialog
open={false}
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={onConfirm}
/>,
);
rerender(
<TakeDownStackDialog
open
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={onConfirm}
/>,
);
await user.click(screen.getByRole('button', { name: 'Take down' }));
expect(onConfirm).toHaveBeenCalledWith(false);
});
});
@@ -16,6 +16,7 @@ import {
ArrowUpRight,
Copy,
CloudDownload,
ArrowDownToLine,
Layers,
List,
Maximize2,
@@ -133,6 +134,8 @@ export interface StackIdentityHeaderProps {
rollbackStack: () => Promise<void>;
scanStackConfig: () => Promise<void>;
requestDeleteStack: () => void;
requestTakeDownStack: (stackName: string) => void;
showTakeDown: boolean;
/** True when this stack is the running Sencho instance on the active node. */
isSelfStack?: boolean;
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
@@ -158,6 +161,8 @@ export function StackIdentityHeader({
rollbackStack,
scanStackConfig,
requestDeleteStack,
requestTakeDownStack,
showTakeDown,
isSelfStack = false,
stackMuteActions,
}: StackIdentityHeaderProps) {
@@ -219,6 +224,20 @@ export function StackIdentityHeader({
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
</Button>
)}
{isRunning && showTakeDown && (
<Button
type="button"
size="sm"
variant="outline"
data-testid="stack-take-down-button"
className="rounded-lg max-md:h-11 border-warning/40 text-warning hover:bg-warning/10"
onClick={() => requestTakeDownStack(stackName)}
disabled={loadingAction !== null || selfProtected}
>
<ArrowDownToLine className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'down' ? 'Taking down...' : 'Take down'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={updateStack} disabled={loadingAction !== null || selfProtected}>
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
@@ -39,6 +39,17 @@ export function useOverlayState() {
setStackToDelete(null);
}, []);
const [takeDownDialogOpen, setTakeDownDialogOpen] = useState(false);
const [stackToTakeDown, setStackToTakeDown] = useState<string | null>(null);
const openTakeDownDialog = useCallback((stackName: string) => {
setStackToTakeDown(stackName);
setTakeDownDialogOpen(true);
}, []);
const closeTakeDownDialog = useCallback(() => {
setTakeDownDialogOpen(false);
setStackToTakeDown(null);
}, []);
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
const [pendingUnsavedNode, setPendingUnsavedNode] = useState<Node | null>(null);
// A deferred "leave the dirty editor" navigation (back to the list, Home, a
@@ -130,6 +141,7 @@ export function useOverlayState() {
return {
createDialogOpen, setCreateDialogOpen,
deleteDialogOpen, stackToDelete, openDeleteDialog, closeDeleteDialog,
takeDownDialogOpen, stackToTakeDown, openTakeDownDialog, closeTakeDownDialog,
pendingUnsavedLoad, setPendingUnsavedLoad,
pendingUnsavedNode, setPendingUnsavedNode,
pendingLeaveAction, setPendingLeaveAction,
@@ -62,6 +62,7 @@ export function useSidebarContextMenu({
isBusy: stackListState.isStackBusy(file),
isAdmin,
canDelete: can('stack:delete', 'stack', sName),
canDeploy: can('stack:deploy', 'stack', sName),
canEditLabels: can('stack:edit', 'stack', sName),
// POST /api/labels (the inline "New label" entry) is guarded by the
// unscoped requirePermission('stack:edit'); a user with only per-stack
@@ -79,6 +80,7 @@ export function useSidebarContextMenu({
stop: () => stackActions.executeStackActionByFile(file, 'stop', 'stop'),
restart: () => stackActions.executeStackActionByFile(file, 'restart', 'restart'),
update: () => stackActions.executeStackActionByFile(file, 'update', 'update'),
takeDown: () => stackActions.requestTakeDownStack(sName),
remove: () => {
if (stackListState.stackSelfFlags[file]) {
overlayState.openSelfStackProtected();
@@ -868,14 +868,21 @@ describe('useStackActions.getStackMenuVisibility', () => {
it('gives a partial stack the running-stack lifecycle actions', () => {
const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'partial' } as never } });
expect(result.current.getStackMenuVisibility('web.yml')).toEqual({
showDeploy: false, showStop: true, showRestart: true, showUpdate: true,
showDeploy: false, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true,
});
});
it('offers Take down for a running non-self stack', () => {
const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'running' } as never } });
expect(result.current.getStackMenuVisibility('web.yml')).toEqual({
showDeploy: false, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true,
});
});
it('shows deploy (not stop/restart/update) for an exited stack', () => {
const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'exited' } as never } });
expect(result.current.getStackMenuVisibility('web.yml')).toEqual({
showDeploy: true, showStop: false, showRestart: false, showUpdate: false,
showDeploy: true, showStop: false, showRestart: false, showUpdate: false, showTakeDown: true,
});
});
@@ -887,7 +894,7 @@ describe('useStackActions.getStackMenuVisibility', () => {
},
});
expect(result.current.getStackMenuVisibility('sencho.yml')).toEqual({
showDeploy: false, showStop: false, showRestart: true, showUpdate: false,
showDeploy: false, showStop: false, showRestart: true, showUpdate: false, showTakeDown: false,
});
});
@@ -94,7 +94,7 @@ interface StackOpInProgressInfo {
const STACK_OP_PRESENT_PARTICIPLE: Record<StackOpAction, string> = {
deploy: 'deploying',
down: 'stopping',
down: 'taking down',
restart: 'restarting',
stop: 'stopping',
start: 'starting',
@@ -129,6 +129,8 @@ interface UseStackActionsOptions {
// the pre-update readiness dialog. Defaults to false: without the
// capability, updates run directly with no dialog.
hasUpdateGuard?: boolean;
/** Fail-closed: true only when active node meta explicitly lists stack-down-remove-volumes. */
canOfferVolumeRemoval?: boolean;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -245,6 +247,7 @@ export function useStackActions(options: UseStackActionsOptions) {
getLastDeployOutputLine,
diffPreviewEnabled,
hasUpdateGuard = false,
canOfferVolumeRemoval = false,
} = options;
const pendingStackLoadRef = useRef<string | null>(null);
@@ -289,6 +292,7 @@ export function useStackActions(options: UseStackActionsOptions) {
showStop: !isSelf && status === 'running',
showRestart: status === 'running',
showUpdate: !isSelf && status === 'running',
showTakeDown: !isSelf && (raw === 'running' || raw === 'partial' || raw === 'exited'),
};
};
@@ -1226,6 +1230,85 @@ export function useStackActions(options: UseStackActionsOptions) {
}
};
const requestTakeDownStack = (stackName: string) => {
if (openSelfStackProtectedIfNeeded(
stackListState.files.find(f => f.replace(/\.(yml|yaml)$/, '') === stackName) ?? stackName,
)) return;
overlayState.openTakeDownDialog(stackName);
};
const takeDownStack = async (removeVolumes: boolean) => {
const stackToTakeDown = overlayState.stackToTakeDown;
if (!stackToTakeDown) return;
if (removeVolumes && !canOfferVolumeRemoval) {
toast.error('Volume removal is not supported on this node');
overlayState.closeTakeDownDialog();
return;
}
const stackFile =
stackListState.files.find(
f => f === stackToTakeDown || f.replace(/\.(yml|yaml)$/, '') === stackToTakeDown,
) ?? stackToTakeDown;
if (stackListState.isStackBusy(stackFile)) return;
if (openSelfStackProtectedIfNeeded(stackFile)) return;
const previousStatus = stackListState.stackStatuses[stackFile];
const startedAt = Date.now();
const opNodeId = activeNode?.id ?? null;
stackListState.setStackAction(stackFile, 'down');
stackListState.setOptimisticStatus(stackFile, 'exited');
try {
await runWithLog({ stackName: stackToTakeDown, action: 'down', nodeId: opNodeId }, async (started, ds) => {
await started;
try {
const url = removeVolumes
? `/stacks/${stackToTakeDown}/down?removeVolumes=true`
: `/stacks/${stackToTakeDown}/down`;
const response = await apiFetch(url, withDeploySession(ds, { method: 'POST', nodeId: opNodeId }));
if (!response.ok) {
const errText = await response.text();
if (response.status === 409) {
const inProgress = parseStackOpInProgress(errText);
if (inProgress) {
const message = stackOpInProgressMessage(stackToTakeDown, inProgress);
toast.error(message);
return { ok: false as const, errorMessage: message };
}
}
if (isSelfStackProtectedResponse(errText, response.status)) {
overlayState.openSelfStackProtected();
overlayState.closeTakeDownDialog();
return { ok: false as const, errorMessage: 'Protected stack' };
}
const actionError = parseStackActionError(errText, 'Take down failed', response.status);
recordActionFailureFor(stackFile, stackToTakeDown, 'down', startedAt, actionError.message, false, actionError.failure);
await refreshSelectedContainers(stackToTakeDown, stackFile);
return { ok: false as const, errorMessage: actionError.message };
}
toast.success('Stack taken down successfully!');
await refreshSelectedContainers(stackToTakeDown, stackFile);
stackListState.recordActionSuccess(stackFile);
overlayState.closeTakeDownDialog();
return { ok: true as const };
} catch (err) {
const message = (err as Error).message || 'Take down failed';
recordActionFailureFor(stackFile, stackToTakeDown, 'down', startedAt, message, false);
await refreshSelectedContainers(stackToTakeDown, stackFile);
return { ok: false as const, errorMessage: message };
}
});
} catch (error) {
console.error('Failed to take down stack:', error);
if (previousStatus !== undefined) {
stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
}
toast.error((error as Error).message || 'Failed to take down stack');
} finally {
stackListState.clearStackAction(stackFile);
stackListState.refreshStacks(true);
}
};
// Guard a navigation that would leave (and discard) a dirty editor: back to
// the list, Home, or any bottom-tab / hamburger / command-palette
// destination. When the editor is dirty the navigation is stashed and the
@@ -1483,6 +1566,8 @@ export function useStackActions(options: UseStackActionsOptions) {
cancelPendingUnsavedLoad,
discardAndLoadPending,
requestDeleteStack,
requestTakeDownStack,
takeDownStack,
executeStackActionByFile,
checkUpdatesForStack,
getDisplayName,
@@ -49,6 +49,7 @@ export type NotificationCategory =
| 'stack_started'
| 'stack_stopped'
| 'stack_restarted'
| 'stack_taken_down'
| 'image_update_available'
| 'image_update_applied'
| 'autoheal_triggered'
@@ -29,12 +29,13 @@ export interface StackMenuCtx {
isBusy: boolean;
isAdmin: boolean;
canDelete: boolean;
canDeploy: boolean;
canEditLabels: boolean;
canCreateLabels: boolean;
isPinned: boolean;
labels: Label[];
assignedLabelIds: number[];
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean };
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean; showTakeDown: boolean };
openAlertSheet: () => void;
openAutoHeal: () => void;
checkUpdates: () => void;
@@ -43,6 +44,7 @@ export interface StackMenuCtx {
stop: () => void;
restart: () => void;
update: () => void;
takeDown: () => void;
remove: () => void;
pin: () => void;
unpin: () => void;
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle,
TriangleAlert, CircleCheck, HeartPulse, HeartCrack,
TriangleAlert, CircleCheck, HeartPulse, HeartCrack, ArrowDownToLine,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -41,6 +41,7 @@ const CATEGORY_ICON: Record<string, LucideIcon> = {
deploy_success: Rocket,
stack_restarted: RefreshCcw,
stack_stopped: CircleStop,
stack_taken_down: ArrowDownToLine,
stack_started: Play,
image_update_applied: ArrowUp,
drift_detected: TriangleAlert,
@@ -10,7 +10,7 @@ export type ActionVerb = 'deploy' | 'update' | 'down' | 'restart' | 'stop' | 'in
export const VERB_LABELS: Record<ActionVerb, { present: string; past: string }> = {
deploy: { present: 'Deploying', past: 'Deployed' },
update: { present: 'Updating', past: 'Updated' },
down: { present: 'Stopping', past: 'Stopped' },
down: { present: 'Taking down', past: 'Took down' },
restart: { present: 'Restarting', past: 'Restarted' },
stop: { present: 'Stopping', past: 'Stopped' },
install: { present: 'Installing', past: 'Installed' },
@@ -12,12 +12,13 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
isBusy: false,
isAdmin: true,
canDelete: true,
canDeploy: true,
canEditLabels: true,
canCreateLabels: true,
isPinned: false,
labels: [],
assignedLabelIds: [],
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false },
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false, showTakeDown: true },
openAlertSheet: vi.fn(),
openAutoHeal: vi.fn(),
checkUpdates: vi.fn(),
@@ -26,6 +27,7 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
stop: vi.fn(),
restart: vi.fn(),
update: vi.fn(),
takeDown: vi.fn(),
remove: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
@@ -162,17 +164,52 @@ describe('useStackMenuItems', () => {
it('lifecycle items follow menuVisibility flags', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true },
menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true, showTakeDown: false },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
const ids = lifecycle.items.map(i => i.id);
expect(ids).toEqual(['deploy', 'update', 'schedule']);
});
it('shows Take down in lifecycle when showTakeDown and canDeploy', () => {
const takeDown = vi.fn();
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
takeDown,
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false, showTakeDown: true },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
const item = lifecycle.items.find(i => i.id === 'take-down');
expect(item?.label).toBe('Take down');
expect(item?.shortcut).toBe('⌘↓');
item?.onSelect();
expect(takeDown).toHaveBeenCalled();
});
it('hides deploy lifecycle items when canDeploy is false', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
canDeploy: false,
menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
const ids = lifecycle.items.map(i => i.id);
expect(ids).not.toContain('deploy');
expect(ids).not.toContain('take-down');
expect(ids).toEqual(['schedule']);
});
it('disables take down for the self stack', () => {
const { result } = renderHook(() => useStackMenuItems('sencho.yml', makeCtx({
isSelfStack: true,
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false, showTakeDown: true },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
expect(lifecycle.items.find(i => i.id === 'take-down')?.disabled).toBe(true);
});
it('disables action lifecycle items when isBusy but leaves schedule enabled', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
isBusy: true,
menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true },
menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true, showTakeDown: false },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
const actionItems = lifecycle.items.filter(i => i.id !== 'schedule');
@@ -22,26 +22,30 @@ export function useStackKeyboardShortcuts(
const cmdOrCtrl = e.metaKey || e.ctrlKey;
const key = e.key.toLowerCase();
const isCmdKey = cmdOrCtrl && ['enter', '.', 'r', 'arrowup', 'backspace'].includes(key);
const isCmdKey = cmdOrCtrl && ['enter', '.', 'r', 'arrowup', 'arrowdown', 'backspace'].includes(key);
const isSingleKey = !cmdOrCtrl && ['a', 'h', 'u', 'p'].includes(key);
if (!isCmdKey && !isSingleKey) return;
const ctx = buildMenuCtxRef.current(file);
const { showDeploy, showStop, showRestart, showUpdate } = ctx.menuVisibility;
const { showDeploy, showStop, showRestart, showUpdate, showTakeDown } = ctx.menuVisibility;
const canDeploy = (show: boolean) => ctx.canDeploy && show && !ctx.isBusy;
if (cmdOrCtrl) {
if (key === 'enter' && showDeploy && !ctx.isBusy) {
if (key === 'enter' && canDeploy(showDeploy)) {
e.preventDefault();
ctx.deploy();
} else if (key === '.' && showStop && !ctx.isBusy) {
} else if (key === '.' && canDeploy(showStop)) {
e.preventDefault();
ctx.stop();
} else if (key === 'r' && showRestart && !ctx.isBusy) {
} else if (key === 'r' && canDeploy(showRestart)) {
e.preventDefault();
ctx.restart();
} else if (key === 'arrowup' && showUpdate && !ctx.isBusy) {
} else if (key === 'arrowup' && canDeploy(showUpdate)) {
e.preventDefault();
ctx.update();
} else if (key === 'arrowdown' && canDeploy(showTakeDown)) {
e.preventDefault();
ctx.takeDown();
} else if (key === 'backspace' && ctx.canDelete && !ctx.isBusy) {
e.preventDefault();
ctx.remove();
+14 -10
View File
@@ -14,18 +14,19 @@ import {
Square,
Tag,
Trash2,
ArrowDownToLine,
} from 'lucide-react';
import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sidebar-types';
export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] {
const {
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel,
menuVisibility, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
} = ctx;
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
const { showDeploy, showStop, showRestart, showUpdate, showTakeDown } = menuVisibility;
return useMemo(() => {
const groups: MenuGroup[] = [];
@@ -78,10 +79,13 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
groups.push({ id: 'organize', items: organize });
const lifecycle: MenuItem[] = [];
if (showDeploy) lifecycle.push({ id: 'deploy', label: 'Deploy', icon: Play, shortcut: '⌘↵', onSelect: deploy, disabled: isBusy });
if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy });
if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy });
if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘', onSelect: update, disabled: isBusy });
if (canDeploy) {
if (showDeploy) lifecycle.push({ id: 'deploy', label: 'Deploy', icon: Play, shortcut: '⌘', onSelect: deploy, disabled: isBusy });
if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy });
if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy });
if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy });
if (showTakeDown) lifecycle.push({ id: 'take-down', label: 'Take down', icon: ArrowDownToLine, shortcut: '⌘↓', onSelect: takeDown, disabled: isBusy || isSelfStack });
}
if (isAdmin) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask });
if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle });
@@ -102,10 +106,10 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
return groups;
}, [
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate,
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate, showTakeDown,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
]);
}
+4
View File
@@ -32,6 +32,10 @@ export const CAPABILITIES = [
'project-env-files',
'compose-storage',
'cross-node-rbac',
'stack-down-remove-volumes',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Must stay in sync with backend CapabilityRegistry.STACK_DOWN_REMOVE_VOLUMES_CAPABILITY */
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
@@ -6,6 +6,7 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
stack_started: 'Stack started',
stack_stopped: 'Stack stopped',
stack_restarted: 'Stack restarted',
stack_taken_down: 'Stack taken down',
image_update_available: 'Update available',
image_update_applied: 'Update applied',
autoheal_triggered: 'Auto-heal',
@@ -5,6 +5,7 @@ const PANEL_HIDDEN_CATEGORIES = new Set<NotificationCategory>([
'stack_started',
'stack_stopped',
'stack_restarted',
'stack_taken_down',
'image_update_applied',
]);