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
@@ -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');