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');
@@ -22,26 +22,30 @@ export function useStackKeyboardShortcuts(
const cmdOrCtrl = e.metaKey || e.ctrlKey;
const key = e.key.toLowerCase();
const isCmdKey = cmdOrCtrl && ['enter', '.', 'r', 'arrowup', 'backspace'].includes(key);
const isCmdKey = cmdOrCtrl && ['enter', '.', 'r', 'arrowup', 'arrowdown', 'backspace'].includes(key);
const isSingleKey = !cmdOrCtrl && ['a', 'h', 'u', 'p'].includes(key);
if (!isCmdKey && !isSingleKey) return;
const ctx = buildMenuCtxRef.current(file);
const { showDeploy, showStop, showRestart, showUpdate } = ctx.menuVisibility;
const { showDeploy, showStop, showRestart, showUpdate, showTakeDown } = ctx.menuVisibility;
const canDeploy = (show: boolean) => ctx.canDeploy && show && !ctx.isBusy;
if (cmdOrCtrl) {
if (key === 'enter' && showDeploy && !ctx.isBusy) {
if (key === 'enter' && canDeploy(showDeploy)) {
e.preventDefault();
ctx.deploy();
} else if (key === '.' && showStop && !ctx.isBusy) {
} else if (key === '.' && canDeploy(showStop)) {
e.preventDefault();
ctx.stop();
} else if (key === 'r' && showRestart && !ctx.isBusy) {
} else if (key === 'r' && canDeploy(showRestart)) {
e.preventDefault();
ctx.restart();
} else if (key === 'arrowup' && showUpdate && !ctx.isBusy) {
} else if (key === 'arrowup' && canDeploy(showUpdate)) {
e.preventDefault();
ctx.update();
} else if (key === 'arrowdown' && canDeploy(showTakeDown)) {
e.preventDefault();
ctx.takeDown();
} else if (key === 'backspace' && ctx.canDelete && !ctx.isBusy) {
e.preventDefault();
ctx.remove();
+14 -10
View File
@@ -14,18 +14,19 @@ import {
Square,
Tag,
Trash2,
ArrowDownToLine,
} from 'lucide-react';
import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sidebar-types';
export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] {
const {
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel,
menuVisibility, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
} = ctx;
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
const { showDeploy, showStop, showRestart, showUpdate, showTakeDown } = menuVisibility;
return useMemo(() => {
const groups: MenuGroup[] = [];
@@ -78,10 +79,13 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
groups.push({ id: 'organize', items: organize });
const lifecycle: MenuItem[] = [];
if (showDeploy) lifecycle.push({ id: 'deploy', label: 'Deploy', icon: Play, shortcut: '⌘↵', onSelect: deploy, disabled: isBusy });
if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy });
if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy });
if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘', onSelect: update, disabled: isBusy });
if (canDeploy) {
if (showDeploy) lifecycle.push({ id: 'deploy', label: 'Deploy', icon: Play, shortcut: '⌘', onSelect: deploy, disabled: isBusy });
if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy });
if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy });
if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy });
if (showTakeDown) lifecycle.push({ id: 'take-down', label: 'Take down', icon: ArrowDownToLine, shortcut: '⌘↓', onSelect: takeDown, disabled: isBusy || isSelfStack });
}
if (isAdmin) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask });
if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle });
@@ -102,10 +106,10 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
return groups;
}, [
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate,
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate, showTakeDown,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
]);
}