mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user