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;
|
skipped: number;
|
||||||
/** true if any destination's batch call returned a non-2xx. */
|
/** true if any destination's batch call returned a non-2xx. */
|
||||||
failed: boolean;
|
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(
|
export async function bulkDeleteSnapshots(
|
||||||
targets: { id: string; _destinationId: number }[]
|
targets: { id: string; _destinationId: number }[]
|
||||||
): Promise<BulkDeleteResult> {
|
): 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)) {
|
for (const { destinationId, snapshotIds } of groupSnapshotIdsByDestination(targets)) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/backup/snapshots/batch-delete', {
|
const res = await fetch('/api/backup/snapshots/batch-delete', {
|
||||||
@@ -480,17 +483,34 @@ export async function bulkDeleteSnapshots(
|
|||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
deleted += data.deleted?.length ?? 0;
|
deleted += data.deleted?.length ?? 0;
|
||||||
if (res.ok) skipped += data.skipped?.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 {
|
} catch {
|
||||||
failed = true;
|
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.
|
* 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).
|
* 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 } {
|
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' };
|
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 { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
||||||
import { currentEnvironment } from '$lib/stores/environment';
|
import { currentEnvironment } from '$lib/stores/environment';
|
||||||
import { watchJob, readJobResponse } from '$lib/utils/sse-fetch';
|
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 { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
|
import BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
|
||||||
|
import BulkDeleteErrorDialog from '$lib/components/BulkDeleteErrorDialog.svelte';
|
||||||
import SnapshotBrowser from '../containers/SnapshotBrowser.svelte';
|
import SnapshotBrowser from '../containers/SnapshotBrowser.svelte';
|
||||||
import RestoreModal from '../containers/RestoreModal.svelte';
|
import RestoreModal from '../containers/RestoreModal.svelte';
|
||||||
import BackupLogModal from './BackupLogModal.svelte';
|
import BackupLogModal from './BackupLogModal.svelte';
|
||||||
@@ -113,6 +114,9 @@
|
|||||||
let bulkDialogOpen = $state(false);
|
let bulkDialogOpen = $state(false);
|
||||||
let bulkDeleting = $state(false);
|
let bulkDeleting = $state(false);
|
||||||
let bulkConfig = $state<BackupConfig | null>(null);
|
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.
|
// Snapshots of a config, normalized to {id, _destinationId} for the shared helper.
|
||||||
function configSelectedTargets(config: BackupConfig) {
|
function configSelectedTargets(config: BackupConfig) {
|
||||||
return (snapshotsMap.get(config.key) || [])
|
return (snapshotsMap.get(config.key) || [])
|
||||||
@@ -139,8 +143,15 @@
|
|||||||
bulkDeleting = true;
|
bulkDeleting = true;
|
||||||
try {
|
try {
|
||||||
const result = await bulkDeleteSnapshots(configSelectedTargets(config));
|
const result = await bulkDeleteSnapshots(configSelectedTargets(config));
|
||||||
const t = bulkDeleteToast(result);
|
const failure = bulkDeleteFailure(result);
|
||||||
toast[t.type](t.message);
|
if (failure) {
|
||||||
|
bulkDeleteError = failure.error;
|
||||||
|
bulkDeleteErrorDeleted = failure.deleted;
|
||||||
|
bulkDeleteErrorOpen = true;
|
||||||
|
} else {
|
||||||
|
const t = bulkDeleteToast(result);
|
||||||
|
toast[t.type](t.message);
|
||||||
|
}
|
||||||
selectedSnapshots = new Set();
|
selectedSnapshots = new Set();
|
||||||
await loadSnapshots(config);
|
await loadSnapshots(config);
|
||||||
const loaded = snapshotsMap.get(config.key);
|
const loaded = snapshotsMap.get(config.key);
|
||||||
@@ -1146,6 +1157,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={bulkConfig ? configSelectedTargets(bulkConfig).length : 0} busy={bulkDeleting} onConfirm={bulkDeleteConfig} />
|
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={bulkConfig ? configSelectedTargets(bulkConfig).length : 0} busy={bulkDeleting} onConfirm={bulkDeleteConfig} />
|
||||||
|
<BulkDeleteErrorDialog bind:open={bulkDeleteErrorOpen} error={bulkDeleteError} deleted={bulkDeleteErrorDeleted} />
|
||||||
|
|
||||||
<SnapshotBrowser
|
<SnapshotBrowser
|
||||||
bind:open={showBrowser}
|
bind:open={showBrowser}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { readJobResponse } from '$lib/utils/sse-fetch';
|
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 BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
|
||||||
|
import BulkDeleteErrorDialog from '$lib/components/BulkDeleteErrorDialog.svelte';
|
||||||
import SnapshotBrowser from './SnapshotBrowser.svelte';
|
import SnapshotBrowser from './SnapshotBrowser.svelte';
|
||||||
import RestoreModal from './RestoreModal.svelte';
|
import RestoreModal from './RestoreModal.svelte';
|
||||||
import SnapshotDiffModal from '../backups/SnapshotDiffModal.svelte';
|
import SnapshotDiffModal from '../backups/SnapshotDiffModal.svelte';
|
||||||
@@ -57,6 +58,9 @@
|
|||||||
let bulkDeleting = $state(false);
|
let bulkDeleting = $state(false);
|
||||||
let bulkDialogOpen = $state(false);
|
let bulkDialogOpen = $state(false);
|
||||||
let bulkScope = $state<'selected' | 'all'>('selected');
|
let bulkScope = $state<'selected' | 'all'>('selected');
|
||||||
|
let bulkDeleteErrorOpen = $state(false);
|
||||||
|
let bulkDeleteError = $state('');
|
||||||
|
let bulkDeleteErrorDeleted = $state(0);
|
||||||
// snapshotId → { filesNew, filesChanged, dataAdded } from schedule executions.
|
// snapshotId → { filesNew, filesChanged, dataAdded } from schedule executions.
|
||||||
let stats = $state<Map<string, { filesNew: number; filesChanged: number; dataAdded: number }>>(new Map());
|
let stats = $state<Map<string, { filesNew: number; filesChanged: number; dataAdded: number }>>(new Map());
|
||||||
|
|
||||||
@@ -251,8 +255,15 @@
|
|||||||
bulkDeleting = true;
|
bulkDeleting = true;
|
||||||
try {
|
try {
|
||||||
const result = await bulkDeleteSnapshots(targets);
|
const result = await bulkDeleteSnapshots(targets);
|
||||||
const t = bulkDeleteToast(result);
|
const failure = bulkDeleteFailure(result);
|
||||||
toast[t.type](t.message);
|
if (failure) {
|
||||||
|
bulkDeleteError = failure.error;
|
||||||
|
bulkDeleteErrorDeleted = failure.deleted;
|
||||||
|
bulkDeleteErrorOpen = true;
|
||||||
|
} else {
|
||||||
|
const t = bulkDeleteToast(result);
|
||||||
|
toast[t.type](t.message);
|
||||||
|
}
|
||||||
selectedSnapshots = new Set();
|
selectedSnapshots = new Set();
|
||||||
await loadSnapshots();
|
await loadSnapshots();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -411,6 +422,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={bulkCount} busy={bulkDeleting} onConfirm={() => bulkDelete(bulkScope)} />
|
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={bulkCount} busy={bulkDeleting} onConfirm={() => bulkDelete(bulkScope)} />
|
||||||
|
<BulkDeleteErrorDialog bind:open={bulkDeleteErrorOpen} error={bulkDeleteError} deleted={bulkDeleteErrorDeleted} />
|
||||||
|
|
||||||
<SnapshotBrowser
|
<SnapshotBrowser
|
||||||
bind:open={showBrowser}
|
bind:open={showBrowser}
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
import { FolderOpen, Box, Layers, FileStack, Camera, ChevronRight, ChevronDown } from 'lucide-svelte';
|
import { FolderOpen, Box, Layers, FileStack, Camera, ChevronRight, ChevronDown } from 'lucide-svelte';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
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 BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
|
||||||
|
import BulkDeleteErrorDialog from '$lib/components/BulkDeleteErrorDialog.svelte';
|
||||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||||
import { LoadingState } from '$lib/components/ui/loading-state';
|
import { LoadingState } from '$lib/components/ui/loading-state';
|
||||||
import { getRepoTypeIcon, getRepoTypeLabel } from '$lib/utils/backup';
|
import { getRepoTypeIcon, getRepoTypeLabel } from '$lib/utils/backup';
|
||||||
@@ -290,6 +291,9 @@
|
|||||||
let selectedSnapshots = $state<Set<string>>(new Set());
|
let selectedSnapshots = $state<Set<string>>(new Set());
|
||||||
let bulkDialogOpen = $state(false);
|
let bulkDialogOpen = $state(false);
|
||||||
let bulkDeleting = $state(false);
|
let bulkDeleting = $state(false);
|
||||||
|
let bulkDeleteErrorOpen = $state(false);
|
||||||
|
let bulkDeleteError = $state('');
|
||||||
|
let bulkDeleteErrorDeleted = $state(0);
|
||||||
function toggleSnapshotSel(id: string) {
|
function toggleSnapshotSel(id: string) {
|
||||||
const next = new Set(selectedSnapshots);
|
const next = new Set(selectedSnapshots);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
@@ -310,8 +314,15 @@
|
|||||||
bulkDeleting = true;
|
bulkDeleting = true;
|
||||||
try {
|
try {
|
||||||
const result = await bulkDeleteSnapshots(targets);
|
const result = await bulkDeleteSnapshots(targets);
|
||||||
const t = bulkDeleteToast(result);
|
const failure = bulkDeleteFailure(result);
|
||||||
toast[t.type](t.message);
|
if (failure) {
|
||||||
|
bulkDeleteError = failure.error;
|
||||||
|
bulkDeleteErrorDeleted = failure.deleted;
|
||||||
|
bulkDeleteErrorOpen = true;
|
||||||
|
} else {
|
||||||
|
const t = bulkDeleteToast(result);
|
||||||
|
toast[t.type](t.message);
|
||||||
|
}
|
||||||
selectedSnapshots = new Set();
|
selectedSnapshots = new Set();
|
||||||
const dest = destinations.find((d) => d.id === browseDestId);
|
const dest = destinations.find((d) => d.id === browseDestId);
|
||||||
if (dest) await browseDestination(dest);
|
if (dest) await browseDestination(dest);
|
||||||
@@ -933,6 +944,7 @@
|
|||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={browseSelectedCount} busy={bulkDeleting} onConfirm={bulkDeleteBrowsed} />
|
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={browseSelectedCount} busy={bulkDeleting} onConfirm={bulkDeleteBrowsed} />
|
||||||
|
<BulkDeleteErrorDialog bind:open={bulkDeleteErrorOpen} error={bulkDeleteError} deleted={bulkDeleteErrorDeleted} />
|
||||||
|
|
||||||
<SnapshotBrowser
|
<SnapshotBrowser
|
||||||
bind:open={snapshotBrowseOpen}
|
bind:open={snapshotBrowseOpen}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, afterEach } from 'bun:test';
|
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 });
|
const snap = (id: string, dest: number) => ({ id, _destinationId: dest });
|
||||||
|
|
||||||
@@ -96,4 +96,29 @@ describe('bulkDeleteSnapshots', () => {
|
|||||||
expect(r.failed).toBe(true);
|
expect(r.failed).toBe(true);
|
||||||
expect(r.deleted).toBe(1);
|
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