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
@@ -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,