show the real backup error when bulk snapshot deletion fails

This commit is contained in:
Jarek Krochmalski committed 2026-09-14 11:53:29 +02:00
1 parent ab855f2b0c
commit a7e4ce9bda
6 files changed
+131 -13

No files matched your search

@@ -0,0 +1,37 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { AlertTriangle } from 'lucide-svelte';
interface Props {
open: boolean;
/** Raw restic stderr for the failed batch (shown verbatim). */
error: string;
/** How many snapshots were still deleted before the failure. */
deleted?: number;
}
let { open = $bindable(), error, deleted = 0 }: Props = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-destructive">
<AlertTriangle class="h-5 w-5" />
Snapshots could not be deleted
</Dialog.Title>
</Dialog.Header>
{#if deleted > 0}
<p class="text-sm text-muted-foreground">
Deleted {deleted} snapshot{deleted === 1 ? '' : 's'}, but the rest failed. The backup
tool reported:
</p>
{:else}
<p class="text-sm text-muted-foreground">The backup tool reported:</p>
{/if}
<pre class="max-h-[50vh] overflow-auto rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-words font-mono">{error}</pre>
<Dialog.Footer>
<Button onclick={() => (open = false)}>OK</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+23 -3
View File
@@ -458,6 +458,9 @@ export interface BulkDeleteResult {
skipped: number;
/** true if any destination's batch call returned a non-2xx. */
failed: boolean;
/** Raw restic error from the first failing destination (repo locked, timeout, etc.),
* surfaced verbatim so the user sees the real reason instead of a generic message. */
error?: string;
}
/**
@@ -469,7 +472,7 @@ export interface BulkDeleteResult {
export async function bulkDeleteSnapshots(
targets: { id: string; _destinationId: number }[]
): Promise<BulkDeleteResult> {
let deleted = 0, skipped = 0, failed = false;
let deleted = 0, skipped = 0, failed = false, error: string | undefined;
for (const { destinationId, snapshotIds } of groupSnapshotIdsByDestination(targets)) {
try {
const res = await fetch('/api/backup/snapshots/batch-delete', {
@@ -480,17 +483,34 @@ export async function bulkDeleteSnapshots(
const data = await res.json().catch(() => ({}));
deleted += data.deleted?.length ?? 0;
if (res.ok) skipped += data.skipped?.length ?? 0;
else failed = true;
else {
failed = true;
if (!error && data.error) error = String(data.error);
}
} catch {
failed = true;
}
}
return { deleted, skipped, failed };
return { deleted, skipped, failed, error };
}
/**
* A hard restic failure that warrants the full-error dialog (repo locked, timeout,
* etc.) rather than a toast. Returns the raw restic error and how many were still
* deleted, or null when there is no such failure (toast handles success/skipped).
* A `failed` with no `error` (e.g. a thrown fetch) also returns null - nothing
* verbatim to show - so the caller falls back to the toast.
*/
export function bulkDeleteFailure(r: BulkDeleteResult): { error: string; deleted: number } | null {
if (r.failed && r.error) return { error: r.error, deleted: r.deleted };
return null;
}
/**
* Consistent toast wording for a bulk-delete result, shared by every snapshot list.
* Returns { type, message } so each caller fires its own toast (import stays local).
* A hard restic failure with a verbatim error is handled by the caller via
* bulkDeleteFailure + the error dialog, not here.
*/
export function bulkDeleteToast(r: BulkDeleteResult): { type: 'success' | 'error'; message: string } {
if (r.failed) return { type: 'error', message: r.deleted > 0 ? `Deleted ${r.deleted}, but some snapshots could not be deleted` : 'Snapshots could not be deleted' };
+15 -3
View File
@@ -26,9 +26,10 @@
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
import { currentEnvironment } from '$lib/stores/environment';
import { watchJob, readJobResponse } from '$lib/utils/sse-fetch';
import { getRepoTypeIcon, formatCron, retentionSummary, classifyJobResult, tagLogLine, bulkDeleteSnapshots, bulkDeleteToast } from '$lib/utils/backup';
import { getRepoTypeIcon, formatCron, retentionSummary, classifyJobResult, tagLogLine, bulkDeleteSnapshots, bulkDeleteToast, bulkDeleteFailure } from '$lib/utils/backup';
import { Checkbox } from '$lib/components/ui/checkbox';
import BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
import BulkDeleteErrorDialog from '$lib/components/BulkDeleteErrorDialog.svelte';
import SnapshotBrowser from '../containers/SnapshotBrowser.svelte';
import RestoreModal from '../containers/RestoreModal.svelte';
import BackupLogModal from './BackupLogModal.svelte';
@@ -113,6 +114,9 @@
let bulkDialogOpen = $state(false);
let bulkDeleting = $state(false);
let bulkConfig = $state<BackupConfig | null>(null);
let bulkDeleteErrorOpen = $state(false);
let bulkDeleteError = $state('');
let bulkDeleteErrorDeleted = $state(0);
// Snapshots of a config, normalized to {id, _destinationId} for the shared helper.
function configSelectedTargets(config: BackupConfig) {
return (snapshotsMap.get(config.key) || [])
@@ -139,8 +143,15 @@
bulkDeleting = true;
try {
const result = await bulkDeleteSnapshots(configSelectedTargets(config));
const t = bulkDeleteToast(result);
toast[t.type](t.message);
const failure = bulkDeleteFailure(result);
if (failure) {
bulkDeleteError = failure.error;
bulkDeleteErrorDeleted = failure.deleted;
bulkDeleteErrorOpen = true;
} else {
const t = bulkDeleteToast(result);
toast[t.type](t.message);
}
selectedSnapshots = new Set();
await loadSnapshots(config);
const loaded = snapshotsMap.get(config.key);
@@ -1146,6 +1157,7 @@
</div>
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={bulkConfig ? configSelectedTargets(bulkConfig).length : 0} busy={bulkDeleting} onConfirm={bulkDeleteConfig} />
<BulkDeleteErrorDialog bind:open={bulkDeleteErrorOpen} error={bulkDeleteError} deleted={bulkDeleteErrorDeleted} />
<SnapshotBrowser
bind:open={showBrowser}
+15 -3
View File
@@ -8,8 +8,9 @@
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { getRepoTypeIcon, selectOwnSnapshotsFromDestination, bulkDeleteSnapshots, bulkDeleteToast } from '$lib/utils/backup';
import { getRepoTypeIcon, selectOwnSnapshotsFromDestination, bulkDeleteSnapshots, bulkDeleteToast, bulkDeleteFailure } from '$lib/utils/backup';
import BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
import BulkDeleteErrorDialog from '$lib/components/BulkDeleteErrorDialog.svelte';
import SnapshotBrowser from './SnapshotBrowser.svelte';
import RestoreModal from './RestoreModal.svelte';
import SnapshotDiffModal from '../backups/SnapshotDiffModal.svelte';
@@ -57,6 +58,9 @@
let bulkDeleting = $state(false);
let bulkDialogOpen = $state(false);
let bulkScope = $state<'selected' | 'all'>('selected');
let bulkDeleteErrorOpen = $state(false);
let bulkDeleteError = $state('');
let bulkDeleteErrorDeleted = $state(0);
// snapshotId → { filesNew, filesChanged, dataAdded } from schedule executions.
let stats = $state<Map<string, { filesNew: number; filesChanged: number; dataAdded: number }>>(new Map());
@@ -251,8 +255,15 @@
bulkDeleting = true;
try {
const result = await bulkDeleteSnapshots(targets);
const t = bulkDeleteToast(result);
toast[t.type](t.message);
const failure = bulkDeleteFailure(result);
if (failure) {
bulkDeleteError = failure.error;
bulkDeleteErrorDeleted = failure.deleted;
bulkDeleteErrorOpen = true;
} else {
const t = bulkDeleteToast(result);
toast[t.type](t.message);
}
selectedSnapshots = new Set();
await loadSnapshots();
} finally {
@@ -411,6 +422,7 @@
{/if}
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={bulkCount} busy={bulkDeleting} onConfirm={() => bulkDelete(bulkScope)} />
<BulkDeleteErrorDialog bind:open={bulkDeleteErrorOpen} error={bulkDeleteError} deleted={bulkDeleteErrorDeleted} />
<SnapshotBrowser
bind:open={showBrowser}
+15 -3
View File
@@ -16,8 +16,9 @@
import { FolderOpen, Box, Layers, FileStack, Camera, ChevronRight, ChevronDown } from 'lucide-svelte';
import { formatBytes } from '$lib/utils/format';
import { Checkbox } from '$lib/components/ui/checkbox';
import { bulkDeleteSnapshots, bulkDeleteToast } from '$lib/utils/backup';
import { bulkDeleteSnapshots, bulkDeleteToast, bulkDeleteFailure } from '$lib/utils/backup';
import BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
import BulkDeleteErrorDialog from '$lib/components/BulkDeleteErrorDialog.svelte';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { LoadingState } from '$lib/components/ui/loading-state';
import { getRepoTypeIcon, getRepoTypeLabel } from '$lib/utils/backup';
@@ -290,6 +291,9 @@
let selectedSnapshots = $state<Set<string>>(new Set());
let bulkDialogOpen = $state(false);
let bulkDeleting = $state(false);
let bulkDeleteErrorOpen = $state(false);
let bulkDeleteError = $state('');
let bulkDeleteErrorDeleted = $state(0);
function toggleSnapshotSel(id: string) {
const next = new Set(selectedSnapshots);
if (next.has(id)) next.delete(id); else next.add(id);
@@ -310,8 +314,15 @@
bulkDeleting = true;
try {
const result = await bulkDeleteSnapshots(targets);
const t = bulkDeleteToast(result);
toast[t.type](t.message);
const failure = bulkDeleteFailure(result);
if (failure) {
bulkDeleteError = failure.error;
bulkDeleteErrorDeleted = failure.deleted;
bulkDeleteErrorOpen = true;
} else {
const t = bulkDeleteToast(result);
toast[t.type](t.message);
}
selectedSnapshots = new Set();
const dest = destinations.find((d) => d.id === browseDestId);
if (dest) await browseDestination(dest);
@@ -933,6 +944,7 @@
</Dialog.Root>
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={browseSelectedCount} busy={bulkDeleting} onConfirm={bulkDeleteBrowsed} />
<BulkDeleteErrorDialog bind:open={bulkDeleteErrorOpen} error={bulkDeleteError} deleted={bulkDeleteErrorDeleted} />
<SnapshotBrowser
bind:open={snapshotBrowseOpen}
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from 'bun:test';
import { groupSnapshotIdsByDestination, bulkDeleteSnapshots, bulkDeleteToast } from '../src/lib/utils/backup';
import { groupSnapshotIdsByDestination, bulkDeleteSnapshots, bulkDeleteToast, bulkDeleteFailure } from '../src/lib/utils/backup';
const snap = (id: string, dest: number) => ({ id, _destinationId: dest });
@@ -96,4 +96,29 @@ describe('bulkDeleteSnapshots', () => {
expect(r.failed).toBe(true);
expect(r.deleted).toBe(1);
});
it('carries the raw restic error from the first failing repo', async () => {
globalThis.fetch = (async (_url: string, init: any) => {
const body = JSON.parse(init.body);
if (body.destinationId === 2) return { ok: false, json: async () => ({ deleted: [], error: 'repository is already locked\ncontext canceled' }) } as any;
return { ok: true, json: async () => ({ deleted: ['a'], skipped: [] }) } as any;
}) as any;
const r = await bulkDeleteSnapshots([snap('a', 1), snap('b', 2)]);
expect(r.failed).toBe(true);
expect(r.error).toBe('repository is already locked\ncontext canceled');
});
});
describe('bulkDeleteFailure', () => {
it('returns the raw error and deleted count on a hard failure', () => {
expect(bulkDeleteFailure({ deleted: 1, skipped: 0, failed: true, error: 'repo locked' }))
.toEqual({ error: 'repo locked', deleted: 1 });
});
it('is null when nothing failed (toast handles success/skipped)', () => {
expect(bulkDeleteFailure({ deleted: 3, skipped: 0, failed: false })).toBeNull();
expect(bulkDeleteFailure({ deleted: 0, skipped: 2, failed: false })).toBeNull();
});
it('is null when failed but no verbatim error (thrown fetch) - toast falls back', () => {
expect(bulkDeleteFailure({ deleted: 0, skipped: 0, failed: true })).toBeNull();
});
});