Files
dockhand-upstream/src/routes/stacks/DeleteStackModal.svelte
T

189 lines
6.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>