diff --git a/src/lib/components/BulkDeleteErrorDialog.svelte b/src/lib/components/BulkDeleteErrorDialog.svelte new file mode 100644 index 0000000..a420904 --- /dev/null +++ b/src/lib/components/BulkDeleteErrorDialog.svelte @@ -0,0 +1,37 @@ + + + + + + + + Snapshots could not be deleted + + + {#if deleted > 0} +

+ Deleted {deleted} snapshot{deleted === 1 ? '' : 's'}, but the rest failed. The backup + tool reported: +

+ {:else} +

The backup tool reported:

+ {/if} +
{error}
+ + + +
+
diff --git a/src/lib/utils/backup.ts b/src/lib/utils/backup.ts index 018dd92..30fafd8 100644 --- a/src/lib/utils/backup.ts +++ b/src/lib/utils/backup.ts @@ -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 { - 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' }; diff --git a/src/routes/backups/+page.svelte b/src/routes/backups/+page.svelte index 881cc43..4de60f3 100644 --- a/src/routes/backups/+page.svelte +++ b/src/routes/backups/+page.svelte @@ -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(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 @@ + ('selected'); + let bulkDeleteErrorOpen = $state(false); + let bulkDeleteError = $state(''); + let bulkDeleteErrorDeleted = $state(0); // snapshotId → { filesNew, filesChanged, dataAdded } from schedule executions. let stats = $state>(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} bulkDelete(bulkScope)} /> + >(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 @@ + ({ 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(); + }); });