feat(stacks): add optional volume prune to delete confirmation (#788)

The Delete Stack dialog now includes an opt-in checkbox to also remove
associated Docker volumes when the stack is deleted. The checkbox is
unchecked by default and resets to unchecked on every open.

Backend: DELETE /stacks/:name accepts ?pruneVolumes=true and calls
pruneManagedOnly for volumes labeled with the stack project name after
bringing the stack down. Prune failure is non-fatal and logged; the
delete proceeds regardless.
This commit is contained in:
Anso
2026-04-26 18:56:30 -04:00
committed by GitHub
parent dcf8794047
commit 38a9f277c6
2 changed files with 28 additions and 2 deletions
+10
View File
@@ -526,6 +526,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const pruneVolumes = req.query.pruneVolumes === 'true';
try {
try {
await ComposeService.getInstance(req.nodeId).downStack(stackName);
@@ -533,6 +534,15 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
}
if (pruneVolumes) {
try {
const result = await DockerController.getInstance().pruneManagedOnly('volumes', [stackName]);
console.log(`[Stacks] Pruned volumes for ${stackName}: ${result.reclaimedBytes} bytes reclaimed`);
} catch (pruneErr) {
console.warn(`[Stacks] Volume prune failed for ${stackName}, continuing delete:`, pruneErr);
}
}
let fsErr: unknown = null;
try {
await FileSystemService.getInstance(req.nodeId).deleteStack(stackName);
+18 -2
View File
@@ -272,6 +272,7 @@ export default function EditorLayout() {
const [gitDeployNow, setGitDeployNow] = useState(false);
const [creatingFromGit, setCreatingFromGit] = useState(false);
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
const [pruneVolumesOnDelete, setPruneVolumesOnDelete] = useState(false);
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
const [pendingUnsavedNode, setPendingUnsavedNode] = useState<Node | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -1557,7 +1558,10 @@ export default function EditorLayout() {
if (isStackBusy(deleteKey)) return;
setStackAction(deleteKey, 'delete');
try {
const response = await apiFetch(`/stacks/${stackToDelete}`, {
const url = pruneVolumesOnDelete
? `/stacks/${stackToDelete}?pruneVolumes=true`
: `/stacks/${stackToDelete}`;
const response = await apiFetch(url, {
method: 'DELETE',
});
if (!response.ok) {
@@ -1567,6 +1571,7 @@ export default function EditorLayout() {
toast.success('Stack deleted successfully!');
setDeleteDialogOpen(false);
setStackToDelete(null);
setPruneVolumesOnDelete(false);
if (selectedFile === stackToDelete) {
setSelectedFile(null);
setContent('');
@@ -1981,7 +1986,7 @@ export default function EditorLayout() {
stop: () => executeStackActionByFile(file, 'stop', 'stop'),
restart: () => executeStackActionByFile(file, 'restart', 'restart'),
update: () => executeStackActionByFile(file, 'update', 'update'),
remove: () => { setStackToDelete(stackName); setDeleteDialogOpen(true); },
remove: () => { setStackToDelete(stackName); setPruneVolumesOnDelete(false); setDeleteDialogOpen(true); },
pin: () => pin(file),
unpin: () => unpin(file),
setAutoUpdateEnabled: async (enabled: boolean) => {
@@ -2488,6 +2493,7 @@ export default function EditorLayout() {
disabled={loadingAction !== null}
onClick={() => {
setStackToDelete(selectedFile);
setPruneVolumesOnDelete(false);
setDeleteDialogOpen(true);
}}
>
@@ -2938,6 +2944,16 @@ export default function EditorLayout() {
Are you sure you want to delete {stackToDelete}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center gap-2 px-1 py-1">
<Checkbox
id="prune-volumes"
checked={pruneVolumesOnDelete}
onCheckedChange={(v) => setPruneVolumesOnDelete(v === true)}
/>
<label htmlFor="prune-volumes" className="text-sm text-muted-foreground cursor-pointer select-none">
Also remove associated volumes
</label>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteDialogOpen(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={deleteStack}>Delete</AlertDialogAction>