deleting a stack shows which folders will be removed, with a choice to keep/delete folders
This commit is contained in:
6 files changed
+274
-27
No files matched your search
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user