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
+8 -1
View File
@@ -30,6 +30,7 @@ import {
import { SENCHO_OPEN_LOGS_EVENT, SENCHO_OPEN_STACK_EVENT } from '@/lib/events';
import type { SenchoOpenLogsDetail, SenchoOpenStackDetail } from '@/lib/events';
import { useNodes } from '@/context/NodeContext';
import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '@/lib/capabilities';
import { useAuth } from '@/context/AuthContext';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
@@ -145,7 +146,9 @@ export default function EditorLayout() {
stacksLoadNodeId,
} = stackListState;
const { nodes, activeNode, setActiveNode, hasCapability, isLoading: nodesLoading } = useNodes();
const { nodes, activeNode, setActiveNode, hasCapability, activeNodeMeta, isLoading: nodesLoading } = useNodes();
const canOfferVolumeRemoval =
activeNodeMeta?.capabilities.includes(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY) === true;
// Mirror activeNode.id in a ref so async handlers (e.g. CreateStackDialog's
// post-create handoff) can detect a node switch that happened mid-flight.
@@ -230,6 +233,7 @@ export default function EditorLayout() {
getLastDeployOutputLine,
diffPreviewEnabled,
hasUpdateGuard: hasCapability('update-guard'),
canOfferVolumeRemoval,
});
// Wire the ref now that stackActions is available
@@ -554,6 +558,8 @@ export default function EditorLayout() {
setEditingCompose={setEditingCompose}
setGitSourceOpen={setGitSourceOpen}
requestDeleteStack={stackActions.requestDeleteStack}
requestTakeDownStack={stackActions.requestTakeDownStack}
showTakeDown={selectedFile ? stackActions.getStackMenuVisibility(selectedFile).showTakeDown : false}
isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false}
recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined}
onRefreshState={async () => {
@@ -863,6 +869,7 @@ export default function EditorLayout() {
gitSourceOpen={gitSourceOpen}
setGitSourceOpen={setGitSourceOpen}
canSelfUpdate={hasCapability('self-update')}
canOfferVolumeRemoval={canOfferVolumeRemoval}
onOpenFleetNodeUpdates={() => {
if (isMobile) {
navigateMobileAware('fleet');
@@ -67,7 +67,8 @@ export type StackAction =
| 'restart'
| 'update'
| 'delete'
| 'rollback';
| 'rollback'
| 'down';
/**
* Stack operations the recovery panel can offer safe next steps for. A failed
@@ -184,6 +185,8 @@ export interface EditorViewProps {
// Composed action: wraps setStackToDelete + setDeleteDialogOpen
requestDeleteStack: () => void;
requestTakeDownStack: (stackName: string) => void;
showTakeDown: boolean;
/** True when this stack is the running Sencho instance on the active node. */
isSelfStack?: boolean;
@@ -264,6 +267,8 @@ export function EditorView(props: EditorViewProps) {
setEditingCompose,
setGitSourceOpen,
requestDeleteStack,
requestTakeDownStack,
showTakeDown,
isSelfStack,
recoveryResult,
onRefreshState,
@@ -390,6 +395,8 @@ export function EditorView(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
requestTakeDownStack={requestTakeDownStack}
showTakeDown={showTakeDown}
isSelfStack={isSelfStack}
stackMuteActions={stackMuteActions}
/>
@@ -75,6 +75,8 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
setEditingCompose: vi.fn(),
setGitSourceOpen: vi.fn(),
requestDeleteStack: vi.fn(),
requestTakeDownStack: vi.fn(),
showTakeDown: false,
onMobileBack: vi.fn(),
onCloseEditor: vi.fn(),
hasUnsavedChanges: () => false,
@@ -67,6 +67,8 @@ export function MobileStackDetail(props: EditorViewProps) {
setEditingCompose,
setGitSourceOpen,
requestDeleteStack,
requestTakeDownStack,
showTakeDown,
isSelfStack = false,
onMobileBack,
onCloseEditor,
@@ -148,6 +150,8 @@ export function MobileStackDetail(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
requestTakeDownStack={requestTakeDownStack}
showTakeDown={showTakeDown}
isSelfStack={isSelfStack}
stackMuteActions={stackMuteActions}
/>
@@ -4,6 +4,7 @@ import { PreDeployScanDialog } from '../stack/PreDeployScanDialog';
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog';
import { DeleteStackDialog } from './DeleteStackDialog';
import { TakeDownStackDialog } from './TakeDownStackDialog';
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
import { StackAlertSheet } from '../StackAlertSheet';
import { GitSourcePanel } from '../stack/GitSourcePanel';
@@ -25,6 +26,7 @@ interface ShellOverlaysProps {
gitSourceOpen: boolean;
setGitSourceOpen: (open: boolean) => void;
canSelfUpdate: boolean;
canOfferVolumeRemoval: boolean;
onOpenFleetNodeUpdates: () => void;
}
@@ -39,10 +41,12 @@ export function ShellOverlays({
gitSourceOpen,
setGitSourceOpen,
canSelfUpdate,
canOfferVolumeRemoval,
onOpenFleetNodeUpdates,
}: ShellOverlaysProps) {
const {
deleteDialogOpen, closeDeleteDialog, stackToDelete,
takeDownDialogOpen, closeTakeDownDialog, stackToTakeDown,
pendingUnsavedLoad, pendingLeaveAction,
bashModalOpen, selectedContainer,
logViewerOpen, logContainer,
@@ -64,6 +68,14 @@ export function ShellOverlays({
onConfirm={stackActions.deleteStack}
/>
<TakeDownStackDialog
open={takeDownDialogOpen}
onOpenChange={(open) => { if (!open) closeTakeDownDialog(); }}
stackName={stackToTakeDown}
showVolumeOption={canOfferVolumeRemoval}
onConfirm={stackActions.takeDownStack}
/>
<SelfStackProtectedDialog
open={selfStackProtectedOpen}
onOpenChange={setSelfStackProtectedOpen}
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
import { ConfirmModal } from '../ui/modal';
import { Checkbox } from '../ui/checkbox';
export interface TakeDownStackDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stackName: string | null;
showVolumeOption: boolean;
onConfirm: (removeVolumes: boolean) => void | Promise<void>;
}
export function TakeDownStackDialog({
open,
onOpenChange,
stackName,
showVolumeOption,
onConfirm,
}: TakeDownStackDialogProps) {
const [removeVolumes, setRemoveVolumes] = useState(false);
// Parent closes via overlay state (not always through handleOpenChange); reset so
// a prior volume opt-in cannot leak into the next dialog session.
useEffect(() => {
if (!open) setRemoveVolumes(false);
}, [open]);
return (
<ConfirmModal
open={open}
onOpenChange={onOpenChange}
variant="destructive"
data-testid="take-down-dialog"
kicker={`${(stackName ?? 'STACK').toUpperCase()} · TAKE DOWN${removeVolumes ? '' : ' · REVERSIBLE'}`}
title={
stackName ? (
<>
Take down <em className="font-display italic text-destructive">{stackName}</em>?
</>
) : (
'Take down stack?'
)
}
description="This removes running containers and compose-created networks. The stack configuration stays on disk so you can deploy again later."
hint={removeVolumes ? 'VOLUMES REMOVED' : 'VOLUMES KEPT'}
confirmLabel="Take down"
onConfirm={() => onConfirm(removeVolumes)}
>
{showVolumeOption && (
<div className="flex items-center gap-2">
<Checkbox
id="take-down-remove-volumes"
data-testid="take-down-remove-volumes"
checked={removeVolumes}
onCheckedChange={(v) => setRemoveVolumes(v === true)}
/>
<label
htmlFor="take-down-remove-volumes"
className="text-sm text-muted-foreground cursor-pointer select-none"
>
Also remove compose volumes (named and anonymous)
</label>
</div>
)}
</ConfirmModal>
);
}
@@ -75,6 +75,8 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
setEditingCompose: vi.fn(),
setGitSourceOpen: vi.fn(),
requestDeleteStack: vi.fn(),
requestTakeDownStack: vi.fn(),
showTakeDown: false,
onRefreshState: vi.fn(),
onDismissRecovery: vi.fn(),
panelStartedAt: null,
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'react';
import { StackIdentityHeader } from '../editor-view-blocks';
import type { ContainerInfo } from '../EditorView';
@@ -50,6 +51,8 @@ function renderHeader(over: Partial<ComponentProps<typeof StackIdentityHeader>>
rollbackStack={vi.fn()}
scanStackConfig={vi.fn()}
requestDeleteStack={vi.fn()}
requestTakeDownStack={vi.fn()}
showTakeDown={false}
{...over}
/>,
);
@@ -59,13 +62,45 @@ describe('StackIdentityHeader', () => {
it('renders stack identity and stack-wide actions without a header image line', () => {
renderHeader();
expect(screen.getByText('plex')).toBeInTheDocument();
expect(screen.getByText(/running · healthy/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Restart' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
expect(screen.getByText('plex')).toBeTruthy();
expect(screen.getByText(/running · healthy/i)).toBeTruthy();
expect(screen.getByRole('button', { name: 'Restart' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Update' })).toBeTruthy();
expect(screen.queryByText(/^image$/i)).not.toBeInTheDocument();
expect(screen.queryByText('nginx:alpine')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Copy digest' })).not.toBeInTheDocument();
expect(screen.queryByText(/^image$/i)).toBeNull();
expect(screen.queryByText('nginx:alpine')).toBeNull();
expect(screen.queryByRole('button', { name: 'Copy digest' })).toBeNull();
});
it('shows Take down when running and showTakeDown is true', () => {
renderHeader({ showTakeDown: true });
expect(screen.getByTestId('stack-take-down-button')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Take down' })).toBeTruthy();
});
it('hides Take down when showTakeDown is false', () => {
renderHeader({ showTakeDown: false, isRunning: true });
expect(screen.queryByTestId('stack-take-down-button')).toBeNull();
});
it('calls requestTakeDownStack with the stack name when Take down is clicked', async () => {
const user = userEvent.setup();
const requestTakeDownStack = vi.fn();
renderHeader({ showTakeDown: true, requestTakeDownStack });
await user.click(screen.getByTestId('stack-take-down-button'));
expect(requestTakeDownStack).toHaveBeenCalledWith('plex');
});
it('does not show Take down in the overflow menu when running', async () => {
const user = userEvent.setup();
renderHeader({ showTakeDown: true, isRunning: true, backupInfo: { exists: true, timestamp: Date.now() } });
await user.click(screen.getByRole('button', { name: 'More actions' }));
expect(screen.queryByRole('menuitem', { name: /Take down/i })).toBeNull();
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'react';
import { TakeDownStackDialog } from '../TakeDownStackDialog';
function renderDialog(
open: boolean,
overrides: Partial<ComponentProps<typeof TakeDownStackDialog>> = {},
) {
return render(
<TakeDownStackDialog
open={open}
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={vi.fn()}
{...overrides}
/>,
);
}
describe('TakeDownStackDialog', () => {
it('resets removeVolumes after parent-driven close and reopen', async () => {
const user = userEvent.setup();
const { rerender } = renderDialog(true);
const checkbox = screen.getByTestId('take-down-remove-volumes');
await user.click(checkbox);
expect(checkbox.getAttribute('data-state')).toBe('checked');
// Parent closes after async success without routing through onOpenChange(false).
rerender(
<TakeDownStackDialog
open={false}
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={vi.fn()}
/>,
);
rerender(
<TakeDownStackDialog
open
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={vi.fn()}
/>,
);
expect(screen.getByTestId('take-down-remove-volumes').getAttribute('data-state')).toBe('unchecked');
});
it('passes removeVolumes=false on confirm after parent-driven reopen', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
const { rerender } = render(
<TakeDownStackDialog
open
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={onConfirm}
/>,
);
await user.click(screen.getByTestId('take-down-remove-volumes'));
rerender(
<TakeDownStackDialog
open={false}
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={onConfirm}
/>,
);
rerender(
<TakeDownStackDialog
open
onOpenChange={vi.fn()}
stackName="plex"
showVolumeOption
onConfirm={onConfirm}
/>,
);
await user.click(screen.getByRole('button', { name: 'Take down' }));
expect(onConfirm).toHaveBeenCalledWith(false);
});
});
@@ -16,6 +16,7 @@ import {
ArrowUpRight,
Copy,
CloudDownload,
ArrowDownToLine,
Layers,
List,
Maximize2,
@@ -133,6 +134,8 @@ export interface StackIdentityHeaderProps {
rollbackStack: () => Promise<void>;
scanStackConfig: () => Promise<void>;
requestDeleteStack: () => void;
requestTakeDownStack: (stackName: string) => void;
showTakeDown: boolean;
/** True when this stack is the running Sencho instance on the active node. */
isSelfStack?: boolean;
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
@@ -158,6 +161,8 @@ export function StackIdentityHeader({
rollbackStack,
scanStackConfig,
requestDeleteStack,
requestTakeDownStack,
showTakeDown,
isSelfStack = false,
stackMuteActions,
}: StackIdentityHeaderProps) {
@@ -219,6 +224,20 @@ export function StackIdentityHeader({
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
</Button>
)}
{isRunning && showTakeDown && (
<Button
type="button"
size="sm"
variant="outline"
data-testid="stack-take-down-button"
className="rounded-lg max-md:h-11 border-warning/40 text-warning hover:bg-warning/10"
onClick={() => requestTakeDownStack(stackName)}
disabled={loadingAction !== null || selfProtected}
>
<ArrowDownToLine className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'down' ? 'Taking down...' : 'Take down'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={updateStack} disabled={loadingAction !== null || selfProtected}>
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
@@ -39,6 +39,17 @@ export function useOverlayState() {
setStackToDelete(null);
}, []);
const [takeDownDialogOpen, setTakeDownDialogOpen] = useState(false);
const [stackToTakeDown, setStackToTakeDown] = useState<string | null>(null);
const openTakeDownDialog = useCallback((stackName: string) => {
setStackToTakeDown(stackName);
setTakeDownDialogOpen(true);
}, []);
const closeTakeDownDialog = useCallback(() => {
setTakeDownDialogOpen(false);
setStackToTakeDown(null);
}, []);
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
const [pendingUnsavedNode, setPendingUnsavedNode] = useState<Node | null>(null);
// A deferred "leave the dirty editor" navigation (back to the list, Home, a
@@ -130,6 +141,7 @@ export function useOverlayState() {
return {
createDialogOpen, setCreateDialogOpen,
deleteDialogOpen, stackToDelete, openDeleteDialog, closeDeleteDialog,
takeDownDialogOpen, stackToTakeDown, openTakeDownDialog, closeTakeDownDialog,
pendingUnsavedLoad, setPendingUnsavedLoad,
pendingUnsavedNode, setPendingUnsavedNode,
pendingLeaveAction, setPendingLeaveAction,
@@ -62,6 +62,7 @@ export function useSidebarContextMenu({
isBusy: stackListState.isStackBusy(file),
isAdmin,
canDelete: can('stack:delete', 'stack', sName),
canDeploy: can('stack:deploy', 'stack', sName),
canEditLabels: can('stack:edit', 'stack', sName),
// POST /api/labels (the inline "New label" entry) is guarded by the
// unscoped requirePermission('stack:edit'); a user with only per-stack
@@ -79,6 +80,7 @@ export function useSidebarContextMenu({
stop: () => stackActions.executeStackActionByFile(file, 'stop', 'stop'),
restart: () => stackActions.executeStackActionByFile(file, 'restart', 'restart'),
update: () => stackActions.executeStackActionByFile(file, 'update', 'update'),
takeDown: () => stackActions.requestTakeDownStack(sName),
remove: () => {
if (stackListState.stackSelfFlags[file]) {
overlayState.openSelfStackProtected();
@@ -868,14 +868,21 @@ describe('useStackActions.getStackMenuVisibility', () => {
it('gives a partial stack the running-stack lifecycle actions', () => {
const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'partial' } as never } });
expect(result.current.getStackMenuVisibility('web.yml')).toEqual({
showDeploy: false, showStop: true, showRestart: true, showUpdate: true,
showDeploy: false, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true,
});
});
it('offers Take down for a running non-self stack', () => {
const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'running' } as never } });
expect(result.current.getStackMenuVisibility('web.yml')).toEqual({
showDeploy: false, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true,
});
});
it('shows deploy (not stop/restart/update) for an exited stack', () => {
const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'exited' } as never } });
expect(result.current.getStackMenuVisibility('web.yml')).toEqual({
showDeploy: true, showStop: false, showRestart: false, showUpdate: false,
showDeploy: true, showStop: false, showRestart: false, showUpdate: false, showTakeDown: true,
});
});
@@ -887,7 +894,7 @@ describe('useStackActions.getStackMenuVisibility', () => {
},
});
expect(result.current.getStackMenuVisibility('sencho.yml')).toEqual({
showDeploy: false, showStop: false, showRestart: true, showUpdate: false,
showDeploy: false, showStop: false, showRestart: true, showUpdate: false, showTakeDown: false,
});
});
@@ -94,7 +94,7 @@ interface StackOpInProgressInfo {
const STACK_OP_PRESENT_PARTICIPLE: Record<StackOpAction, string> = {
deploy: 'deploying',
down: 'stopping',
down: 'taking down',
restart: 'restarting',
stop: 'stopping',
start: 'starting',
@@ -129,6 +129,8 @@ interface UseStackActionsOptions {
// the pre-update readiness dialog. Defaults to false: without the
// capability, updates run directly with no dialog.
hasUpdateGuard?: boolean;
/** Fail-closed: true only when active node meta explicitly lists stack-down-remove-volumes. */
canOfferVolumeRemoval?: boolean;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -245,6 +247,7 @@ export function useStackActions(options: UseStackActionsOptions) {
getLastDeployOutputLine,
diffPreviewEnabled,
hasUpdateGuard = false,
canOfferVolumeRemoval = false,
} = options;
const pendingStackLoadRef = useRef<string | null>(null);
@@ -289,6 +292,7 @@ export function useStackActions(options: UseStackActionsOptions) {
showStop: !isSelf && status === 'running',
showRestart: status === 'running',
showUpdate: !isSelf && status === 'running',
showTakeDown: !isSelf && (raw === 'running' || raw === 'partial' || raw === 'exited'),
};
};
@@ -1226,6 +1230,85 @@ export function useStackActions(options: UseStackActionsOptions) {
}
};
const requestTakeDownStack = (stackName: string) => {
if (openSelfStackProtectedIfNeeded(
stackListState.files.find(f => f.replace(/\.(yml|yaml)$/, '') === stackName) ?? stackName,
)) return;
overlayState.openTakeDownDialog(stackName);
};
const takeDownStack = async (removeVolumes: boolean) => {
const stackToTakeDown = overlayState.stackToTakeDown;
if (!stackToTakeDown) return;
if (removeVolumes && !canOfferVolumeRemoval) {
toast.error('Volume removal is not supported on this node');
overlayState.closeTakeDownDialog();
return;
}
const stackFile =
stackListState.files.find(
f => f === stackToTakeDown || f.replace(/\.(yml|yaml)$/, '') === stackToTakeDown,
) ?? stackToTakeDown;
if (stackListState.isStackBusy(stackFile)) return;
if (openSelfStackProtectedIfNeeded(stackFile)) return;
const previousStatus = stackListState.stackStatuses[stackFile];
const startedAt = Date.now();
const opNodeId = activeNode?.id ?? null;
stackListState.setStackAction(stackFile, 'down');
stackListState.setOptimisticStatus(stackFile, 'exited');
try {
await runWithLog({ stackName: stackToTakeDown, action: 'down', nodeId: opNodeId }, async (started, ds) => {
await started;
try {
const url = removeVolumes
? `/stacks/${stackToTakeDown}/down?removeVolumes=true`
: `/stacks/${stackToTakeDown}/down`;
const response = await apiFetch(url, withDeploySession(ds, { method: 'POST', nodeId: opNodeId }));
if (!response.ok) {
const errText = await response.text();
if (response.status === 409) {
const inProgress = parseStackOpInProgress(errText);
if (inProgress) {
const message = stackOpInProgressMessage(stackToTakeDown, inProgress);
toast.error(message);
return { ok: false as const, errorMessage: message };
}
}
if (isSelfStackProtectedResponse(errText, response.status)) {
overlayState.openSelfStackProtected();
overlayState.closeTakeDownDialog();
return { ok: false as const, errorMessage: 'Protected stack' };
}
const actionError = parseStackActionError(errText, 'Take down failed', response.status);
recordActionFailureFor(stackFile, stackToTakeDown, 'down', startedAt, actionError.message, false, actionError.failure);
await refreshSelectedContainers(stackToTakeDown, stackFile);
return { ok: false as const, errorMessage: actionError.message };
}
toast.success('Stack taken down successfully!');
await refreshSelectedContainers(stackToTakeDown, stackFile);
stackListState.recordActionSuccess(stackFile);
overlayState.closeTakeDownDialog();
return { ok: true as const };
} catch (err) {
const message = (err as Error).message || 'Take down failed';
recordActionFailureFor(stackFile, stackToTakeDown, 'down', startedAt, message, false);
await refreshSelectedContainers(stackToTakeDown, stackFile);
return { ok: false as const, errorMessage: message };
}
});
} catch (error) {
console.error('Failed to take down stack:', error);
if (previousStatus !== undefined) {
stackListState.setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
}
toast.error((error as Error).message || 'Failed to take down stack');
} finally {
stackListState.clearStackAction(stackFile);
stackListState.refreshStacks(true);
}
};
// Guard a navigation that would leave (and discard) a dirty editor: back to
// the list, Home, or any bottom-tab / hamburger / command-palette
// destination. When the editor is dirty the navigation is stashed and the
@@ -1483,6 +1566,8 @@ export function useStackActions(options: UseStackActionsOptions) {
cancelPendingUnsavedLoad,
discardAndLoadPending,
requestDeleteStack,
requestTakeDownStack,
takeDownStack,
executeStackActionByFile,
checkUpdatesForStack,
getDisplayName,
@@ -49,6 +49,7 @@ export type NotificationCategory =
| 'stack_started'
| 'stack_stopped'
| 'stack_restarted'
| 'stack_taken_down'
| 'image_update_available'
| 'image_update_applied'
| 'autoheal_triggered'
@@ -29,12 +29,13 @@ export interface StackMenuCtx {
isBusy: boolean;
isAdmin: boolean;
canDelete: boolean;
canDeploy: boolean;
canEditLabels: boolean;
canCreateLabels: boolean;
isPinned: boolean;
labels: Label[];
assignedLabelIds: number[];
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean };
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean; showTakeDown: boolean };
openAlertSheet: () => void;
openAutoHeal: () => void;
checkUpdates: () => void;
@@ -43,6 +44,7 @@ export interface StackMenuCtx {
stop: () => void;
restart: () => void;
update: () => void;
takeDown: () => void;
remove: () => void;
pin: () => void;
unpin: () => void;
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle,
TriangleAlert, CircleCheck, HeartPulse, HeartCrack,
TriangleAlert, CircleCheck, HeartPulse, HeartCrack, ArrowDownToLine,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -41,6 +41,7 @@ const CATEGORY_ICON: Record<string, LucideIcon> = {
deploy_success: Rocket,
stack_restarted: RefreshCcw,
stack_stopped: CircleStop,
stack_taken_down: ArrowDownToLine,
stack_started: Play,
image_update_applied: ArrowUp,
drift_detected: TriangleAlert,
@@ -10,7 +10,7 @@ export type ActionVerb = 'deploy' | 'update' | 'down' | 'restart' | 'stop' | 'in
export const VERB_LABELS: Record<ActionVerb, { present: string; past: string }> = {
deploy: { present: 'Deploying', past: 'Deployed' },
update: { present: 'Updating', past: 'Updated' },
down: { present: 'Stopping', past: 'Stopped' },
down: { present: 'Taking down', past: 'Took down' },
restart: { present: 'Restarting', past: 'Restarted' },
stop: { present: 'Stopping', past: 'Stopped' },
install: { present: 'Installing', past: 'Installed' },
@@ -12,12 +12,13 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
isBusy: false,
isAdmin: true,
canDelete: true,
canDeploy: true,
canEditLabels: true,
canCreateLabels: true,
isPinned: false,
labels: [],
assignedLabelIds: [],
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false },
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false, showTakeDown: true },
openAlertSheet: vi.fn(),
openAutoHeal: vi.fn(),
checkUpdates: vi.fn(),
@@ -26,6 +27,7 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
stop: vi.fn(),
restart: vi.fn(),
update: vi.fn(),
takeDown: vi.fn(),
remove: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
@@ -162,17 +164,52 @@ describe('useStackMenuItems', () => {
it('lifecycle items follow menuVisibility flags', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true },
menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true, showTakeDown: false },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
const ids = lifecycle.items.map(i => i.id);
expect(ids).toEqual(['deploy', 'update', 'schedule']);
});
it('shows Take down in lifecycle when showTakeDown and canDeploy', () => {
const takeDown = vi.fn();
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
takeDown,
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false, showTakeDown: true },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
const item = lifecycle.items.find(i => i.id === 'take-down');
expect(item?.label).toBe('Take down');
expect(item?.shortcut).toBe('⌘↓');
item?.onSelect();
expect(takeDown).toHaveBeenCalled();
});
it('hides deploy lifecycle items when canDeploy is false', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
canDeploy: false,
menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
const ids = lifecycle.items.map(i => i.id);
expect(ids).not.toContain('deploy');
expect(ids).not.toContain('take-down');
expect(ids).toEqual(['schedule']);
});
it('disables take down for the self stack', () => {
const { result } = renderHook(() => useStackMenuItems('sencho.yml', makeCtx({
isSelfStack: true,
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false, showTakeDown: true },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
expect(lifecycle.items.find(i => i.id === 'take-down')?.disabled).toBe(true);
});
it('disables action lifecycle items when isBusy but leaves schedule enabled', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
isBusy: true,
menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true },
menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true, showTakeDown: false },
})));
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
const actionItems = lifecycle.items.filter(i => i.id !== 'schedule');
@@ -22,26 +22,30 @@ export function useStackKeyboardShortcuts(
const cmdOrCtrl = e.metaKey || e.ctrlKey;
const key = e.key.toLowerCase();
const isCmdKey = cmdOrCtrl && ['enter', '.', 'r', 'arrowup', 'backspace'].includes(key);
const isCmdKey = cmdOrCtrl && ['enter', '.', 'r', 'arrowup', 'arrowdown', 'backspace'].includes(key);
const isSingleKey = !cmdOrCtrl && ['a', 'h', 'u', 'p'].includes(key);
if (!isCmdKey && !isSingleKey) return;
const ctx = buildMenuCtxRef.current(file);
const { showDeploy, showStop, showRestart, showUpdate } = ctx.menuVisibility;
const { showDeploy, showStop, showRestart, showUpdate, showTakeDown } = ctx.menuVisibility;
const canDeploy = (show: boolean) => ctx.canDeploy && show && !ctx.isBusy;
if (cmdOrCtrl) {
if (key === 'enter' && showDeploy && !ctx.isBusy) {
if (key === 'enter' && canDeploy(showDeploy)) {
e.preventDefault();
ctx.deploy();
} else if (key === '.' && showStop && !ctx.isBusy) {
} else if (key === '.' && canDeploy(showStop)) {
e.preventDefault();
ctx.stop();
} else if (key === 'r' && showRestart && !ctx.isBusy) {
} else if (key === 'r' && canDeploy(showRestart)) {
e.preventDefault();
ctx.restart();
} else if (key === 'arrowup' && showUpdate && !ctx.isBusy) {
} else if (key === 'arrowup' && canDeploy(showUpdate)) {
e.preventDefault();
ctx.update();
} else if (key === 'arrowdown' && canDeploy(showTakeDown)) {
e.preventDefault();
ctx.takeDown();
} else if (key === 'backspace' && ctx.canDelete && !ctx.isBusy) {
e.preventDefault();
ctx.remove();
+14 -10
View File
@@ -14,18 +14,19 @@ import {
Square,
Tag,
Trash2,
ArrowDownToLine,
} from 'lucide-react';
import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sidebar-types';
export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] {
const {
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel,
menuVisibility, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
} = ctx;
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
const { showDeploy, showStop, showRestart, showUpdate, showTakeDown } = menuVisibility;
return useMemo(() => {
const groups: MenuGroup[] = [];
@@ -78,10 +79,13 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
groups.push({ id: 'organize', items: organize });
const lifecycle: MenuItem[] = [];
if (showDeploy) lifecycle.push({ id: 'deploy', label: 'Deploy', icon: Play, shortcut: '⌘↵', onSelect: deploy, disabled: isBusy });
if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy });
if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy });
if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘', onSelect: update, disabled: isBusy });
if (canDeploy) {
if (showDeploy) lifecycle.push({ id: 'deploy', label: 'Deploy', icon: Play, shortcut: '⌘', onSelect: deploy, disabled: isBusy });
if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy });
if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy });
if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy });
if (showTakeDown) lifecycle.push({ id: 'take-down', label: 'Take down', icon: ArrowDownToLine, shortcut: '⌘↓', onSelect: takeDown, disabled: isBusy || isSelfStack });
}
if (isAdmin) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask });
if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle });
@@ -102,10 +106,10 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
return groups;
}, [
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate,
stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate, showTakeDown,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, openScheduleTask,
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
]);
}
+4
View File
@@ -32,6 +32,10 @@ export const CAPABILITIES = [
'project-env-files',
'compose-storage',
'cross-node-rbac',
'stack-down-remove-volumes',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Must stay in sync with backend CapabilityRegistry.STACK_DOWN_REMOVE_VOLUMES_CAPABILITY */
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
@@ -6,6 +6,7 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
stack_started: 'Stack started',
stack_stopped: 'Stack stopped',
stack_restarted: 'Stack restarted',
stack_taken_down: 'Stack taken down',
image_update_available: 'Update available',
image_update_applied: 'Update applied',
autoheal_triggered: 'Auto-heal',
@@ -5,6 +5,7 @@ const PANEL_HIDDEN_CATEGORIES = new Set<NotificationCategory>([
'stack_started',
'stack_stopped',
'stack_restarted',
'stack_taken_down',
'image_update_applied',
]);