1.0.38
This commit is contained in:
177 files changed
+21182
-1042
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Loader2, CheckCircle, XCircle, Archive, Copy, Check, Square } from 'lucide-svelte';
|
||||
import LogConsole from '$lib/components/LogConsole.svelte';
|
||||
|
||||
// Backup-only progress window: shown for the Play-on-a-config path, which has no
|
||||
// form modal to host its log. Restore shows its log inline inside RestoreModal.
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
status: 'running' | 'success' | 'error';
|
||||
progress?: number;
|
||||
logs: string[];
|
||||
error?: string;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), title, status, progress = 0, logs, error, onStop }: Props = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
function copyLogs() {
|
||||
const text = logs.join('\n') + (error ? '\n' + error : '');
|
||||
navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
setTimeout(() => { copied = false; }, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-4xl h-[60vh] flex flex-col overflow-hidden" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Archive class="w-5 h-5" />
|
||||
{title}
|
||||
{#if status === 'running'}
|
||||
<Badge variant="secondary" class="text-xs gap-1"><Loader2 class="w-3 h-3 animate-spin" />Running</Badge>
|
||||
{:else if status === 'success'}
|
||||
<Badge variant="secondary" class="text-xs gap-1 text-green-500"><CheckCircle class="w-3 h-3" />Completed</Badge>
|
||||
{:else}
|
||||
<Badge variant="destructive" class="text-xs gap-1"><XCircle class="w-3 h-3" />Failed</Badge>
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if status === 'running' && progress > 0}
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden shrink-0">
|
||||
<div class="h-full bg-primary transition-all duration-300 rounded-full" style="width: {progress}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative flex min-h-0 flex-1 flex-col">
|
||||
<!-- Shared console (pills, dark toggle, auto-scroll) — same as the backup
|
||||
and container/stack restore logs, so all operation logs look identical. -->
|
||||
<LogConsole lines={logs} class="flex-1 min-h-0" />
|
||||
{#if status === 'running' && logs.length === 0}
|
||||
<div class="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3 w-3 animate-spin" /><span>Waiting for output…</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}<div class="mt-1 text-xs text-red-400">{error}</div>{/if}
|
||||
<!-- Copy button -->
|
||||
{#if logs.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-8 right-3 rounded border bg-background/80 p-1.5 text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
|
||||
onclick={copyLogs}
|
||||
title="Copy log content"
|
||||
>
|
||||
{#if copied}<Check class="w-3.5 h-3.5 text-green-500" />{:else}<Copy class="w-3.5 h-3.5" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="pt-3">
|
||||
{#if status === 'running' && onStop}
|
||||
<Button variant="destructive" onclick={onStop}>
|
||||
<Square class="w-3.5 h-3.5 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="outline" onclick={() => (open = false)}>
|
||||
{status === 'running' ? 'Run in background' : 'Close'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,435 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import { Package, Box, Layers, Search, Loader2, CheckCircle2, XCircle, ArrowBigRight, Settings, Clock, Play } from 'lucide-svelte';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { getRepoTypeIcon, formatCron, runBackupAction, isRemoteEnvironment, tagLogLine, type BackupFormState } from '$lib/utils/backup';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import VolumePicker from '$lib/components/backup/VolumePicker.svelte';
|
||||
import DestinationPicker from '$lib/components/backup/DestinationPicker.svelte';
|
||||
import LogConsole from '$lib/components/LogConsole.svelte';
|
||||
import { normalizeMounts, volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCreated?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onCreated }: Props = $props();
|
||||
|
||||
interface Environment { id: number; name: string; icon?: string; connectionType?: string; host?: string | null; }
|
||||
interface Destination { id: number; name: string; repository: string; }
|
||||
interface ContainerItem { name: string; type: 'container' | 'stack'; envId: number; envName: string; envIcon?: string; volumes: VolumeInfo[]; }
|
||||
|
||||
// Step state (4 = running/complete)
|
||||
let step = $state<1 | 2 | 3 | 4>(1);
|
||||
|
||||
// Step 1: Select source
|
||||
let environments = $state<Environment[]>([]);
|
||||
let selectedEnvId = $state<number | undefined>(undefined);
|
||||
let containers = $state<ContainerItem[]>([]);
|
||||
let loadingContainers = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let selectedItem = $state<ContainerItem | null>(null);
|
||||
|
||||
// Step 2: Configure
|
||||
let destinations = $state<Destination[]>([]);
|
||||
let selectedDestId = $state(0);
|
||||
let stopBeforeBackup = $state(false);
|
||||
let allVolumes = $state(true);
|
||||
let selectedVolumes = $state<string[]>([]);
|
||||
|
||||
// Step 3: Schedule & Run
|
||||
let saveSchedule = $state(false);
|
||||
let schedule = $state('0 2 * * *');
|
||||
let scheduleInvalid = $state(false);
|
||||
let running = $state(false);
|
||||
|
||||
// Step 4: Running log
|
||||
let logs = $state<string[]>([]);
|
||||
let backupStatus = $state<'idle' | 'running' | 'success' | 'error'>('idle');
|
||||
let backupError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
step = 1;
|
||||
selectedItem = null;
|
||||
searchQuery = '';
|
||||
selectedEnvId = undefined;
|
||||
selectedDestId = 0;
|
||||
stopBeforeBackup = false;
|
||||
allVolumes = true;
|
||||
selectedVolumes = [];
|
||||
saveSchedule = false;
|
||||
schedule = '0 2 * * *';
|
||||
running = false;
|
||||
logs = [];
|
||||
backupStatus = 'idle';
|
||||
backupError = '';
|
||||
fetchEnvironments();
|
||||
fetchDestinations();
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchEnvironments() {
|
||||
try {
|
||||
const res = await fetch('/api/environments');
|
||||
environments = await res.json();
|
||||
if (environments.length === 1) {
|
||||
selectedEnvId = environments[0].id;
|
||||
fetchContainers(environments[0].id);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchDestinations() {
|
||||
try {
|
||||
const res = await fetch('/api/backup/destinations');
|
||||
destinations = await res.json();
|
||||
if (destinations.length === 1) selectedDestId = destinations[0].id;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchContainers(envId: number) {
|
||||
loadingContainers = true;
|
||||
containers = [];
|
||||
try {
|
||||
const [contRes, stackRes] = await Promise.all([
|
||||
fetch(`/api/containers?env=${envId}`),
|
||||
fetch(`/api/stacks?env=${envId}`)
|
||||
]);
|
||||
const contData = await contRes.json();
|
||||
const stackData = await stackRes.json();
|
||||
const env = environments.find(e => e.id === envId);
|
||||
const items: ContainerItem[] = [];
|
||||
|
||||
if (Array.isArray(contData)) {
|
||||
for (const c of contData) {
|
||||
items.push({ name: c.name || c.Names?.[0]?.replace(/^\//, ''), type: 'container', envId, envName: env?.name || '', envIcon: env?.icon, volumes: normalizeMounts(c.mounts || c.Mounts) });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(stackData)) {
|
||||
for (const s of stackData) {
|
||||
// Only internal/git stacks are backup-able — Dockhand knows their compose
|
||||
// file location. External/untracked stacks (unknown compose path) would
|
||||
// produce an incomplete artifact, and the backend refuses them
|
||||
// (assertStackBackupable), so don't offer them as targets here.
|
||||
if (s.sourceType !== 'internal' && s.sourceType !== 'git') continue;
|
||||
items.push({ name: s.name, type: 'stack', envId, envName: env?.name || '', envIcon: env?.icon, volumes: volumesForStack(contData, s.name) });
|
||||
}
|
||||
}
|
||||
containers = items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {}
|
||||
loadingContainers = false;
|
||||
}
|
||||
|
||||
function selectSource(item: ContainerItem) {
|
||||
selectedItem = item;
|
||||
step = 2;
|
||||
}
|
||||
|
||||
async function runBackup() {
|
||||
if (!selectedItem || !selectedDestId) return;
|
||||
running = true;
|
||||
logs = [];
|
||||
backupStatus = 'running';
|
||||
backupError = '';
|
||||
|
||||
// Wizard exposes two intents: "run and save the schedule" or
|
||||
// "run once and discard". Both map cleanly onto the shared action
|
||||
// orchestrator — same code path as BackupPanel's Save & run / Run once.
|
||||
const form: BackupFormState = {
|
||||
targetName: selectedItem.name,
|
||||
type: selectedItem.type,
|
||||
environmentId: selectedItem.envId,
|
||||
destinationId: selectedDestId,
|
||||
stopBeforeBackup,
|
||||
allVolumes,
|
||||
selectedVolumes
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await runBackupAction({
|
||||
form,
|
||||
action: saveSchedule ? 'save-run' : 'run-once',
|
||||
schedule,
|
||||
enabled: true,
|
||||
onProgress: (line) => {
|
||||
if (line.event === 'progress') {
|
||||
const msg = (line.data as { message?: string } | null)?.message;
|
||||
if (msg) logs = [...logs, tagLogLine(msg)];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
backupStatus = 'error';
|
||||
backupError = result.error || 'Backup failed';
|
||||
} else {
|
||||
backupStatus = 'success';
|
||||
toast.success(saveSchedule ? 'Backup completed and schedule saved' : 'Backup completed');
|
||||
}
|
||||
onCreated?.();
|
||||
} catch (err: any) {
|
||||
backupStatus = 'error';
|
||||
backupError = err?.message || 'Backup failed';
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredContainers = $derived(
|
||||
searchQuery.trim()
|
||||
? containers.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: containers
|
||||
);
|
||||
|
||||
const getDestIcon = getRepoTypeIcon;
|
||||
const selectedDest = $derived(destinations.find(d => d.id === selectedDestId));
|
||||
// Used by the DestinationPicker to disable local-path destinations when
|
||||
// the chosen environment is remote (helper container on the remote host
|
||||
// can't read Dockhand's local filesystem).
|
||||
const selectedEnv = $derived(environments.find((e) => e.id === selectedItem?.envId));
|
||||
const isSelectedEnvRemote = $derived(isRemoteEnvironment(selectedEnv));
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen && running) return; open = isOpen; }}>
|
||||
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col">
|
||||
<Dialog.Header class="pb-0">
|
||||
<Dialog.Title class="flex items-center gap-2 text-base">
|
||||
<Package class="w-4 h-4" />Create backup
|
||||
</Dialog.Title>
|
||||
{#if selectedItem}
|
||||
<Dialog.Description class="flex items-center gap-2 text-sm flex-wrap">
|
||||
{#if selectedItem.type === 'container'}<Box class="w-4 h-4 text-blue-500" />{:else}<Layers class="w-4 h-4 text-purple-500" />{/if}
|
||||
{selectedItem.name}
|
||||
<span class="text-muted-foreground">on</span>
|
||||
<EnvironmentIcon icon={selectedItem.envIcon || 'globe'} envId={selectedItem.envId} class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-muted-foreground">{selectedItem.envName}</span>
|
||||
{#if selectedDest}
|
||||
<span class="text-muted-foreground">→</span>
|
||||
<svelte:component this={getDestIcon(selectedDest.repository)} class="w-4 h-4" />
|
||||
<span class="text-muted-foreground">{selectedDest.name}</span>
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Stepper tabs -->
|
||||
<div class="flex items-center border-b shrink-0 px-1 bg-muted/10">
|
||||
<button
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer flex items-center gap-2 {step === 1 ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => step = 1}
|
||||
>
|
||||
<Box class="w-4 h-4" />
|
||||
Source
|
||||
{#if selectedItem}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
|
||||
{/if}
|
||||
</button>
|
||||
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<button
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 {step === 2 ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground'} {selectedItem ? 'cursor-pointer hover:text-foreground' : 'cursor-not-allowed'}"
|
||||
disabled={!selectedItem}
|
||||
onclick={() => { if (selectedItem) step = 2; }}
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
Configure
|
||||
{#if selectedDestId}
|
||||
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
|
||||
{/if}
|
||||
</button>
|
||||
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<button
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 {step === 3 ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground'} {selectedItem && selectedDestId ? 'cursor-pointer hover:text-foreground' : 'cursor-not-allowed'}"
|
||||
disabled={!selectedItem || !selectedDestId}
|
||||
onclick={() => { if (selectedItem && selectedDestId) step = 3; }}
|
||||
>
|
||||
<Clock class="w-4 h-4" />
|
||||
Schedule & run
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step content -->
|
||||
{#if step === 1}
|
||||
<!-- Sticky env selector + search -->
|
||||
<div class="shrink-0 flex gap-2 px-3 pt-3 pb-2">
|
||||
<Select.Root type="single" value={selectedEnvId ? String(selectedEnvId) : undefined} onValueChange={(v) => { selectedEnvId = Number(v); selectedItem = null; fetchContainers(Number(v)); }}>
|
||||
<Select.Trigger class="h-8 w-48 text-xs">
|
||||
{@const env = environments.find(e => e.id === selectedEnvId)}
|
||||
{#if env}
|
||||
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-3 h-3 mr-1 text-muted-foreground" />{env.name}
|
||||
{:else}Select environment{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each environments as env}
|
||||
<Select.Item value={String(env.id)}>
|
||||
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-3 h-3 mr-1.5 inline text-muted-foreground" />{env.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if selectedEnvId}
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input bind:value={searchQuery} placeholder="Search containers and stacks..." class="pl-8 h-8 text-xs" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1 min-h-0 flex flex-col overflow-y-auto px-3 pb-3">
|
||||
{#if step === 1}
|
||||
<!-- Step 1: Select source -->
|
||||
<div class="space-y-3">
|
||||
|
||||
{#if loadingContainers}
|
||||
<div class="flex items-center justify-center py-8"><Loader2 class="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||
{:else if selectedEnvId && filteredContainers.length > 0}
|
||||
<div class="flex-1 overflow-y-auto border rounded">
|
||||
{#each filteredContainers as item}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-xs hover:bg-muted transition-colors text-left border-b last:border-0 {selectedItem?.name === item.name && selectedItem?.envId === item.envId ? 'bg-primary/10' : ''}"
|
||||
onclick={() => selectSource(item)}
|
||||
>
|
||||
{#if item.type === 'container'}<Box class="w-3.5 h-3.5 text-muted-foreground shrink-0" />{:else}<Layers class="w-3.5 h-3.5 text-muted-foreground shrink-0" />{/if}
|
||||
<span class="font-medium truncate">{item.name}</span>
|
||||
<Badge variant="outline" class="text-xs ml-auto shrink-0">{item.type}</Badge>
|
||||
{#if item.volumes.length > 0}
|
||||
<span class="text-xs text-muted-foreground shrink-0">{item.volumes.length} vol</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if selectedEnvId}
|
||||
<p class="text-xs text-muted-foreground py-4 text-center">No containers or stacks found</p>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground py-4 text-center">Select an environment to see available sources</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if step === 2}
|
||||
<!-- Step 2: Configure -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">Backup repository</Label>
|
||||
<DestinationPicker
|
||||
destinations={destinations}
|
||||
bind:value={selectedDestId}
|
||||
disableLocalForRemoteEnv={isSelectedEnvRemote}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<TogglePill bind:checked={stopBeforeBackup} />
|
||||
<div>
|
||||
<Label class="text-xs">Stop {selectedItem?.type || 'container'} during backup</Label>
|
||||
<p class="text-xs text-muted-foreground">Ensures data consistency (will restart after)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedItem}
|
||||
<VolumePicker
|
||||
volumes={selectedItem.volumes}
|
||||
bind:allVolumes
|
||||
bind:selectedVolumes
|
||||
emptyLabel="No volumes detected on this {selectedItem.type}"
|
||||
showBindWarning={true}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button size="sm" onclick={() => step = 3} disabled={!selectedDestId}>
|
||||
Next <ArrowBigRight class="w-3.5 h-3.5 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if step === 3}
|
||||
<!-- Step 3: Schedule & Run -->
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground pb-2 border-b">
|
||||
{#if allVolumes}
|
||||
<span>All volumes</span>
|
||||
{:else}
|
||||
<span>{selectedVolumes.length} volume{selectedVolumes.length !== 1 ? 's' : ''}</span>
|
||||
{/if}
|
||||
{#if stopBeforeBackup}
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<span>stop during backup</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox checked={saveSchedule} onCheckedChange={() => { saveSchedule = !saveSchedule; }} />
|
||||
<span class="text-xs">Save as recurring schedule</span>
|
||||
</div>
|
||||
|
||||
{#if saveSchedule}
|
||||
<div class="pl-6">
|
||||
<CronEditor bind:value={schedule} bind:invalid={scheduleInvalid} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="pl-6 text-[11px] text-muted-foreground">
|
||||
Runs once now. The backup stays on the list so you can re-run or remove it later — it just won't run on a schedule.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if backupStatus === 'idle'}
|
||||
<!-- Not started yet -->
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
{#if backupStatus === 'idle' || (backupStatus !== 'running' && logs.length === 0)}
|
||||
<Button variant="outline" size="sm" onclick={() => step = 2} disabled={running}>Back</Button>
|
||||
{/if}
|
||||
<div class="flex-1"></div>
|
||||
{#if backupStatus === 'success'}
|
||||
<Button size="sm" onclick={() => { open = false; }}>
|
||||
<CheckCircle2 class="w-3.5 h-3.5 mr-1.5 text-green-500" />Done
|
||||
</Button>
|
||||
{:else if backupStatus === 'error' && !running}
|
||||
<Button size="sm" variant="outline" onclick={() => { backupStatus = 'idle'; logs = []; }}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button size="sm" onclick={() => { open = false; }}>Close</Button>
|
||||
{:else}
|
||||
<Button size="sm" onclick={runBackup} disabled={running || (saveSchedule && scheduleInvalid)}>
|
||||
{#if running}<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />{:else}<Play class="w-3.5 h-3.5 mr-1.5" />{/if}
|
||||
{saveSchedule ? 'Run & save schedule' : 'Run backup now'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Backup log — same shared renderer (pills, auto-scroll) as the backup
|
||||
panel and restore modal, with the wizard's own status line below. -->
|
||||
{#if logs.length > 0 || running}
|
||||
<div class="mt-3 flex min-h-0 flex-1 flex-col">
|
||||
<LogConsole lines={logs} class="flex-1 min-h-0" />
|
||||
{#if backupStatus === 'running'}
|
||||
<div class="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />Backing up…
|
||||
</div>
|
||||
{:else if backupStatus === 'success'}
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-green-500">
|
||||
<CheckCircle2 class="h-3.5 w-3.5" />Backup completed
|
||||
</div>
|
||||
{:else if backupStatus === 'error'}
|
||||
<div class="mt-1.5 flex items-start gap-1.5 text-xs font-medium text-red-400">
|
||||
<XCircle class="mt-0.5 h-3.5 w-3.5 shrink-0" /><span class="break-all">{backupError || 'Failed'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Plus, Minus, FileEdit, FileCheck, Loader2, ArrowRight, FolderOpen, Container, Info } from 'lucide-svelte';
|
||||
import { formatDateTime } from '$lib/stores/settings';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
destinationId: number;
|
||||
snapshotA: { id: string; shortId: string; time: string };
|
||||
snapshotB: { id: string; shortId: string; time: string };
|
||||
}
|
||||
|
||||
let { open = $bindable(), destinationId, snapshotA, snapshotB }: Props = $props();
|
||||
|
||||
interface DiffResult {
|
||||
added: string[];
|
||||
removed: string[];
|
||||
modified: string[];
|
||||
metadataChanged: string[];
|
||||
}
|
||||
|
||||
let activeTab = $state<'files' | 'metadata'>('files');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let diff = $state<DiffResult | null>(null);
|
||||
let metaA = $state<any>(null);
|
||||
let metaB = $state<any>(null);
|
||||
let metaLoading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && snapshotA?.id && snapshotB?.id) {
|
||||
activeTab = 'files';
|
||||
fetchDiff();
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchDiff() {
|
||||
loading = true;
|
||||
error = null;
|
||||
diff = null;
|
||||
metaA = null;
|
||||
metaB = null;
|
||||
try {
|
||||
const res = await fetch(`/api/backup/snapshots/diff?destinationId=${destinationId}&snapshotA=${snapshotA.id}&snapshotB=${snapshotB.id}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
error = data.error || `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
diff = await res.json();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetadata() {
|
||||
if (metaA && metaB) return;
|
||||
metaLoading = true;
|
||||
try {
|
||||
const [resA, resB] = await Promise.all([
|
||||
fetch(`/api/backup/snapshots/${snapshotA.id}/metadata?destinationId=${destinationId}`),
|
||||
fetch(`/api/backup/snapshots/${snapshotB.id}/metadata?destinationId=${destinationId}`)
|
||||
]);
|
||||
metaA = resA.ok ? await resA.json() : null;
|
||||
metaB = resB.ok ? await resB.json() : null;
|
||||
} catch {}
|
||||
metaLoading = false;
|
||||
}
|
||||
|
||||
function switchToMetadata() {
|
||||
activeTab = 'metadata';
|
||||
loadMetadata();
|
||||
}
|
||||
|
||||
const totalChanges = $derived(diff ? diff.added.length + diff.removed.length + diff.modified.length + diff.metadataChanged.length : 0);
|
||||
|
||||
// Compute metadata differences
|
||||
interface MetaDiff { field: string; valueA: string; valueB: string }
|
||||
const metaDiffs = $derived.by(() => {
|
||||
if (!metaA?.containers?.[0] || !metaB?.containers?.[0]) return [];
|
||||
const a = metaA.containers[0];
|
||||
const b = metaB.containers[0];
|
||||
const diffs: MetaDiff[] = [];
|
||||
|
||||
function compare(field: string, va: any, vb: any) {
|
||||
const sa = typeof va === 'object' ? JSON.stringify(va, null, 2) : String(va ?? '—');
|
||||
const sb = typeof vb === 'object' ? JSON.stringify(vb, null, 2) : String(vb ?? '—');
|
||||
if (sa !== sb) diffs.push({ field, valueA: sa, valueB: sb });
|
||||
}
|
||||
|
||||
compare('Image', a.image, b.image);
|
||||
compare('Cmd', a.config?.Cmd, b.config?.Cmd);
|
||||
compare('Entrypoint', a.config?.Entrypoint, b.config?.Entrypoint);
|
||||
compare('Env', a.config?.Env, b.config?.Env);
|
||||
compare('Labels', a.config?.Labels, b.config?.Labels);
|
||||
compare('Hostname', a.config?.Hostname, b.config?.Hostname);
|
||||
compare('User', a.config?.User, b.config?.User);
|
||||
compare('WorkingDir', a.config?.WorkingDir, b.config?.WorkingDir);
|
||||
compare('RestartPolicy', a.hostConfig?.RestartPolicy, b.hostConfig?.RestartPolicy);
|
||||
compare('NetworkMode', a.hostConfig?.NetworkMode, b.hostConfig?.NetworkMode);
|
||||
compare('PortBindings', a.hostConfig?.PortBindings, b.hostConfig?.PortBindings);
|
||||
compare('Privileged', a.hostConfig?.Privileged, b.hostConfig?.Privileged);
|
||||
compare('Memory', a.hostConfig?.Memory, b.hostConfig?.Memory);
|
||||
compare('CpuShares', a.hostConfig?.CpuShares, b.hostConfig?.CpuShares);
|
||||
compare('Mounts', a.mounts?.length, b.mounts?.length);
|
||||
compare('Networks', Object.keys(a.networkSettings?.Networks || {}), Object.keys(b.networkSettings?.Networks || {}));
|
||||
|
||||
return diffs;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-5xl max-h-[85vh] flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Snapshot comparison</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
<span class="font-mono text-xs">{snapshotA.shortId}</span>
|
||||
<span class="text-muted-foreground text-xs mx-1">({formatDateTime(snapshotA.time)})</span>
|
||||
<ArrowRight class="w-3 h-3 inline mx-1" />
|
||||
<span class="font-mono text-xs">{snapshotB.shortId}</span>
|
||||
<span class="text-muted-foreground text-xs mx-1">({formatDateTime(snapshotB.time)})</span>
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 border-b mb-3">
|
||||
<button class="px-3 py-1.5 text-sm {activeTab === 'files' ? 'border-b-2 border-primary font-medium' : 'text-muted-foreground'}" onclick={() => activeTab = 'files'}>
|
||||
<FolderOpen class="w-3.5 h-3.5 inline mr-1" />Files
|
||||
</button>
|
||||
<button class="px-3 py-1.5 text-sm {activeTab === 'metadata' ? 'border-b-2 border-primary font-medium' : 'text-muted-foreground'}" onclick={switchToMetadata}>
|
||||
<Container class="w-3.5 h-3.5 inline mr-1" />Metadata
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-[400px] overflow-y-auto pr-3">
|
||||
{#if activeTab === 'files'}
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<span class="ml-2 text-sm text-muted-foreground">Comparing snapshots...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-sm text-destructive py-4">{error}</div>
|
||||
{:else if diff}
|
||||
<div class="flex gap-2 mb-3 flex-wrap">
|
||||
{#if diff.added.length > 0}
|
||||
<Badge variant="outline" class="text-green-600"><Plus class="w-3 h-3 mr-1" />{diff.added.length} added</Badge>
|
||||
{/if}
|
||||
{#if diff.removed.length > 0}
|
||||
<Badge variant="outline" class="text-red-500"><Minus class="w-3 h-3 mr-1" />{diff.removed.length} removed</Badge>
|
||||
{/if}
|
||||
{#if diff.modified.length > 0}
|
||||
<Badge variant="outline" class="text-amber-500"><FileEdit class="w-3 h-3 mr-1" />{diff.modified.length} modified</Badge>
|
||||
{/if}
|
||||
{#if diff.metadataChanged.length > 0}
|
||||
<Badge variant="outline" class="text-blue-500"><FileCheck class="w-3 h-3 mr-1" />{diff.metadataChanged.length} metadata</Badge>
|
||||
{/if}
|
||||
{#if totalChanges === 0}
|
||||
<span class="text-sm text-muted-foreground">No differences found</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md bg-muted/30 p-2 font-mono text-xs space-y-0.5">
|
||||
{#each diff.added as path}
|
||||
<div class="text-green-600">+ {path}</div>
|
||||
{/each}
|
||||
{#each diff.removed as path}
|
||||
<div class="text-red-500">- {path}</div>
|
||||
{/each}
|
||||
{#each diff.modified as path}
|
||||
<div class="text-amber-500">M {path}</div>
|
||||
{/each}
|
||||
{#each diff.metadataChanged as path}
|
||||
<div class="text-blue-500">U {path}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'metadata'}
|
||||
{#if metaLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<span class="ml-2 text-sm text-muted-foreground">Loading metadata...</span>
|
||||
</div>
|
||||
{:else if !metaA || !metaB}
|
||||
<p class="text-sm text-muted-foreground py-4">Metadata not available for one or both snapshots.</p>
|
||||
{:else if metaDiffs.length === 0}
|
||||
<p class="py-4 text-sm text-muted-foreground">The container configuration is identical (image, command, env, ports, mounts, networks, …).</p>
|
||||
<p class="flex items-start gap-1.5 text-xs text-muted-foreground"><Info class="mt-0.5 h-3.5 w-3.5 flex-shrink-0" /><span>If the Files tab shows <span class="font-mono">metadata.json</span> as modified, that's the capture timestamp / recorded container state inside it changing between backups — not a config change.</span></p>
|
||||
{:else}
|
||||
<table class="w-full text-xs">
|
||||
<thead class="sticky top-0 bg-background">
|
||||
<tr class="border-b text-muted-foreground">
|
||||
<th class="text-left py-1.5 px-2 w-32">Field</th>
|
||||
<th class="text-left py-1.5 px-2">{snapshotA.shortId}</th>
|
||||
<th class="text-left py-1.5 px-2">{snapshotB.shortId}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each metaDiffs as d}
|
||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||
<td class="py-1.5 px-2 font-medium text-muted-foreground align-top">{d.field}</td>
|
||||
<td class="py-1.5 px-2 align-top">
|
||||
<pre class="whitespace-pre-wrap text-red-500/80 bg-red-500/5 rounded px-1 max-h-32 overflow-auto">{d.valueA}</pre>
|
||||
</td>
|
||||
<td class="py-1.5 px-2 align-top">
|
||||
<pre class="whitespace-pre-wrap text-green-600/80 bg-green-500/5 rounded px-1 max-h-32 overflow-auto">{d.valueB}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in new issue
Block a user