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