show the real backup error when bulk snapshot deletion fails
This commit is contained in:
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
@@ -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' };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user