deleting a stack shows which folders will be removed, with a choice to keep/delete folders

This commit is contained in:
jarek committed 2026-08-09 10:31:28 +02:00
1 parent 4375f42415
commit 3859888da6
6 files changed
+274 -27

No files matched your search

+1 -1
View File
@@ -838,7 +838,7 @@ export function deleteRepositoryFiles(repoId: number): void {
// === Git Stack Functions ===
async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise<string> {
export async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise<string> {
if (stackName && environmentId) {
// Use old path if it already exists (backward compat), otherwise use name-based path
const oldPath = join(GIT_REPOS_DIR, `stack-${stackId}`);
+27
View File
@@ -0,0 +1,27 @@
/**
* Pure path guard for stack-directory deletion (#675). Import-light (only node:path), so
* it's unit-testable without pulling the DB/docker imports of stacks.ts.
*
* THE BUG (#675): a stack ADOPTED at the stacks ROOT (compose at
* $DATA_DIR/stacks/docker-compose.yml) is auto-named "stacks". On delete, its custom dir
* is the stacks ROOT and its basename ("stacks") equals the stack name ("stacks"). A plain
* `startsWith(stacksRoot)` guard passed both checks, so rmSync wiped the ENTIRE stacks
* folder — every other stack. The fix is a STRICT-subdir check (`startsWith(stacksRoot +
* sep)`): resolve() strips trailing separators, so the root has none and fails, while a
* real nested stack dir ($DATA_DIR/stacks/<env>/<name>) is a strict subdir and passes.
*/
import { resolve, basename, sep as pathSep } from 'node:path';
/**
* True when `customDir` is a directory Dockhand may delete for `stackName`: a STRICT
* subdirectory of `stacksDir` (never the root itself) whose basename matches the stack
* name. Path-only — the caller still checks existsSync before deleting.
*/
export function isDeletableStackDir(customDir: string, stacksDir: string, stackName: string): boolean {
const resolvedCustomDir = resolve(customDir);
const resolvedStacksDir = resolve(stacksDir);
return (
resolvedCustomDir.startsWith(resolvedStacksDir + pathSep) &&
basename(resolvedCustomDir) === stackName
);
}
+5 -2
View File
@@ -10,6 +10,9 @@ export const DELETE: RequestHandler = async (event) => {
const force = url.searchParams.get('force') === 'true';
const volumes = url.searchParams.get('volumes') === 'true';
// files defaults to true (backward-compatible: old callers with no param still delete
// files). The delete modal passes files=false for "Remove stack" (keep files on disk).
const files = url.searchParams.get('files') !== 'false';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
@@ -25,10 +28,10 @@ export const DELETE: RequestHandler = async (event) => {
try {
const stackName = decodeURIComponent(params.name);
const result = await removeStack(stackName, envIdNum, force, volumes);
const result = await removeStack(stackName, envIdNum, force, volumes, files);
// Audit log
await auditStack(event, 'delete', stackName, envIdNum, { force, volumes });
await auditStack(event, 'delete', stackName, envIdNum, { force, volumes, files });
if (!result.success) {
return json({ success: false, error: result.error }, { status: 400 });
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { computeStackDeletionPaths } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
// GET /api/stacks/[name]/delete-preview?env=N
// Returns the on-disk directories a delete-with-files would remove, so the confirm modal
// shows the user EXACTLY what the backend will delete (same source of truth — no drift).
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
if (auth.authEnabled && !(await auth.can('stacks', 'remove', envIdNum))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const { name } = params;
try {
const { stackDir, gitDir, sourceType, namedVolumes } = await computeStackDeletionPaths(name, envIdNum);
return json({
stackName: name,
sourceType, // internal | git | external | null
stackDir, // absolute path, or null if Dockhand won't delete files
gitDir, // absolute path of the cloned git repo dir, or null
namedVolumes, // compose-managed named volumes removed by "also delete volumes"
canDeleteFiles: !!(stackDir || gitDir),
});
} catch (error: any) {
return json({ error: error.message || 'Failed to compute deletion preview' }, { status: 500 });
}
};
+22 -24
View File
@@ -25,6 +25,7 @@
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
import StackModal from './StackModal.svelte';
import DeleteStackModal from './DeleteStackModal.svelte';
import GitSourceBadge from './GitSourceBadge.svelte';
import GitStackModal from './GitStackModal.svelte';
import ImportStackModal from './ImportStackModal.svelte';
@@ -477,10 +478,12 @@
}
// Confirmation popover state
let confirmDeleteName = $state<string | null>(null);
let confirmStopName = $state<string | null>(null);
let confirmDownName = $state<string | null>(null);
let deleteVolumes = $state(false);
// Delete-stack modal state (replaces the old confirm popover — shows the exact dirs
// that will be removed and offers "remove stack" vs "remove stack + files").
let showDeleteModal = $state(false);
let deleteStackName = $state('');
// Stack operation loading state
let stackActionLoading = $state<string | null>(null);
@@ -1063,12 +1066,10 @@
}
}
async function removeStack(name: string) {
async function removeStack(name: string, opts: { deleteFiles: boolean; deleteVolumes: boolean }) {
operationError = null;
const withVolumes = deleteVolumes;
deleteVolumes = false;
try {
const params = `force=true${withVolumes ? '&volumes=true' : ''}`;
const params = `force=true${opts.deleteVolumes ? '&volumes=true' : ''}${opts.deleteFiles ? '' : '&files=false'}`;
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}?${params}`, envId), { method: 'DELETE' });
if (!response.ok) {
const data = await response.json();
@@ -1076,7 +1077,8 @@
showErrorDialog(`Failed to remove ${name}`, errorMsg);
return;
}
toast.success(`Removed ${name}${withVolumes ? ' (volumes deleted)' : ''}`);
const bits = [opts.deleteFiles ? 'files deleted' : 'files kept', ...(opts.deleteVolumes ? ['volumes deleted'] : [])];
toast.success(`Removed ${name} (${bits.join(', ')})`);
await fetchStacks();
} catch (error) {
console.error('Failed to remove stack:', error);
@@ -2126,25 +2128,14 @@
</ConfirmPopover>
{/if}
{#if $canAccess('stacks', 'remove')}
<ConfirmPopover
open={confirmDeleteName === stack.name}
action="Delete"
itemType="stack"
itemName={stack.name}
<button
type="button"
title="Remove"
onConfirm={() => removeStack(stack.name)}
onOpenChange={(open) => { confirmDeleteName = open ? stack.name : null; if (!open) deleteVolumes = false; }}
onclick={(e) => { e.stopPropagation(); deleteStackName = stack.name; showDeleteModal = true; }}
class="p-0.5 rounded hover:bg-muted transition-colors cursor-pointer"
>
{#snippet extraContent()}
<label class="flex items-center gap-1.5 cursor-pointer">
<Checkbox bind:checked={deleteVolumes} />
<span class="text-xs text-muted-foreground">Also delete volumes</span>
</label>
{/snippet}
{#snippet children({ open })}
<Trash2 class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
<Trash2 class="grid-action-icon grid-action-delete text-muted-foreground hover:text-destructive" />
</button>
{/if}
</div>
{/if}
@@ -2662,6 +2653,13 @@
onAdopted={fetchStacks}
/>
<DeleteStackModal
bind:open={showDeleteModal}
stackName={deleteStackName}
envId={envId ?? null}
onConfirm={(opts) => removeStack(deleteStackName, opts)}
/>
<ContainerInspectModal
bind:open={showInspectModal}
containerId={inspectContainerId}
+188
View File
@@ -0,0 +1,188 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Trash2, Folder, Database, Loader2, ArrowRight } from 'lucide-svelte';
import GitGenericIcon from '$lib/components/icons/GitGenericIcon.svelte';
import { appendEnvParam } from '$lib/stores/environment';
// The parent owns the fetch; onConfirm receives what the user chose to remove.
let {
open = $bindable(false),
stackName = '',
envId = null as number | null,
onConfirm,
}: {
open?: boolean;
stackName?: string;
envId?: number | null;
onConfirm: (opts: { deleteFiles: boolean; deleteVolumes: boolean }) => void | Promise<void>;
} = $props();
interface Preview {
sourceType: string | null;
stackDir: string | null;
gitDir: string | null;
namedVolumes: string[];
canDeleteFiles: boolean;
}
let preview = $state<Preview | null>(null);
let loading = $state(false);
let busy = $state(false);
// What to remove — the user ticks each. Containers are ALWAYS removed (the point of the
// operation), so they aren't a checkbox. Files default ON (the common intent), volumes
// default OFF (destructive, unrecoverable).
let removeFiles = $state(true);
let removeVolumes = $state(false);
const hasFiles = $derived(!!(preview?.stackDir || preview?.gitDir));
const hasVolumes = $derived((preview?.namedVolumes?.length ?? 0) > 0);
$effect(() => {
if (open && stackName) {
loading = true;
preview = null;
removeFiles = true;
removeVolumes = false;
fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/delete-preview`, envId))
.then((r) => (r.ok ? r.json() : null))
.then((d) => { preview = d; })
.catch(() => { preview = null; })
.finally(() => { loading = false; });
}
});
// Fixed set of outcome lines — ALWAYS shown (so the box never resizes as you toggle),
// each flipping between a red "deleted" and a green "kept" state. `delete: null` = no
// choice (containers are always removed). `kind` picks the icon for the "kept" state.
const willHappen = $derived.by(() => {
const lines: { delete: boolean | null; kind: 'container' | 'files' | 'volumes'; text: string }[] = [
{ delete: null, kind: 'container', text: 'Stop and remove the stack’s containers' },
];
if (hasFiles) {
lines.push(removeFiles
? { delete: true, kind: 'files', text: 'Delete the stack files on disk' }
: { delete: false, kind: 'files', text: 'Keep the stack files on disk' });
}
if (hasVolumes) {
const n = preview!.namedVolumes.length;
lines.push(removeVolumes
? { delete: true, kind: 'volumes', text: `Delete ${n} named volume(s) — data is unrecoverable` }
: { delete: false, kind: 'volumes', text: `Keep ${n} named volume(s)` });
}
return lines;
});
async function run() {
busy = true;
try {
await onConfirm({ deleteFiles: hasFiles && removeFiles, deleteVolumes: hasVolumes && removeVolumes });
open = false;
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Trash2 class="w-5 h-5 text-destructive" />
Remove stack "{stackName}"
</Dialog.Title>
<Dialog.Description>
Choose what to remove. The stack's containers are always stopped and removed.
</Dialog.Description>
</Dialog.Header>
{#if loading}
<div class="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 class="w-4 h-4 animate-spin" /> Checking what can be removed…
</div>
{:else if preview}
<div class="my-1 space-y-3">
{#if hasFiles}
<label class="flex items-start gap-2.5 cursor-pointer">
<Checkbox bind:checked={removeFiles} class="mt-0.5" />
<Folder class="w-4 h-4 shrink-0 translate-y-0.5 text-amber-500" />
<div class="min-w-0">
<div class="text-sm">Delete files on disk</div>
{#if preview.stackDir}
<code class="block break-all text-xs text-muted-foreground">{preview.stackDir}</code>
{/if}
{#if preview.gitDir}
<div class="mt-1 flex items-start gap-1.5">
<GitGenericIcon class="w-3.5 h-3.5 shrink-0 translate-y-0.5 text-purple-500" />
<code class="break-all text-xs text-muted-foreground">{preview.gitDir}</code>
</div>
{/if}
</div>
</label>
{/if}
{#if hasVolumes}
<label class="flex items-start gap-2.5 cursor-pointer">
<Checkbox bind:checked={removeVolumes} class="mt-0.5" />
<Database class="w-4 h-4 shrink-0 translate-y-0.5 text-blue-500" />
<div class="min-w-0">
<div class="text-sm">Delete named volumes <span class="text-muted-foreground">(data is unrecoverable)</span></div>
<div class="flex flex-wrap gap-1 mt-0.5">
{#each preview.namedVolumes as v}
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{v}</code>
{/each}
</div>
</div>
</label>
{/if}
{#if !preview.canDeleteFiles && !hasVolumes}
<p class="text-sm text-muted-foreground">
This stack has no files or named volumes Dockhand manages — only the stack
record and its containers will be removed.
</p>
{/if}
</div>
<!-- What will happen — fixed set of lines; each flips red(delete)/green(keep). -->
<div class="mt-2 rounded-md border bg-muted/30 p-3">
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
What will happen
</div>
<ul class="space-y-1 text-sm">
{#each willHappen as w}
<li class="flex items-start gap-1.5">
{#if w.delete === true}
<Trash2 class="w-3.5 h-3.5 shrink-0 translate-y-0.5 text-destructive" />
<span>{w.text}</span>
{:else if w.delete === false}
<!-- kept: green folder/volume icon so "kept" reads as safe -->
{#if w.kind === 'files'}
<Folder class="w-3.5 h-3.5 shrink-0 translate-y-0.5 text-emerald-500" />
{:else}
<Database class="w-3.5 h-3.5 shrink-0 translate-y-0.5 text-emerald-500" />
{/if}
<span class="text-muted-foreground">{w.text}</span>
{:else}
<ArrowRight class="w-3.5 h-3.5 shrink-0 translate-y-0.5 text-muted-foreground" />
<span>{w.text}</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
<div class="mt-4 flex justify-end gap-1.5">
<Button variant="outline" size="sm" onclick={() => (open = false)} disabled={busy}>
Cancel
</Button>
<Button variant="destructive" size="sm" onclick={run} disabled={busy || loading}>
{#if busy}<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />{:else}<Trash2 class="w-3.5 h-3.5 mr-1" />{/if}
Remove
</Button>
</div>
</Dialog.Content>
</Dialog.Root>