From 610e46dc19b921904468e529e098ca7e29fb4a1e Mon Sep 17 00:00:00 2001 From: jarek Date: Thu, 13 Aug 2026 10:20:30 +0200 Subject: [PATCH] read-side backup snapshot endpoints use job polling --- src/lib/server/sse.ts | 18 ++++++ src/routes/api/backup/instance/+server.ts | 18 ++++++ .../api/backup/restore/preview/+server.ts | 16 +++-- src/routes/api/backup/snapshots/+server.ts | 61 +++++++----------- .../backup/snapshots/[id]/browse/+server.ts | 14 ++-- .../api/backup/snapshots/[id]/dump/+server.ts | 64 ++++++++++--------- .../backup/snapshots/[id]/metadata/+server.ts | 15 ++--- .../api/backup/snapshots/diff/+server.ts | 12 ++-- src/routes/backups/+page.svelte | 46 ++++++++----- src/routes/backups/SnapshotDiffModal.svelte | 24 +++---- src/routes/containers/FileBrowserPanel.svelte | 26 +++++--- src/routes/containers/RestoreModal.svelte | 25 ++++---- src/routes/containers/SnapshotBrowser.svelte | 22 ++++--- src/routes/containers/SnapshotsPanel.svelte | 10 ++- src/routes/settings/backups/BackupsTab.svelte | 18 ++++-- 15 files changed, 224 insertions(+), 165 deletions(-) create mode 100644 src/routes/api/backup/instance/+server.ts diff --git a/src/lib/server/sse.ts b/src/lib/server/sse.ts index 54a6e75..fe1008b 100644 --- a/src/lib/server/sse.ts +++ b/src/lib/server/sse.ts @@ -68,3 +68,21 @@ export function createJobResponse( return json({ jobId: job.id }); } + +/** + * Job-polling wrapper for a read endpoint that just returns DATA (no progress lines): + * run `fn`, deliver its value as the single `result`, and turn a thrown error into a + * `{ error }` result (HTTP 200 + a failed-shaped payload the client reads back via + * readJobResponse). Collapses the identical createJobResponse+try/catch boilerplate every + * backup read endpoint (browse/preview/metadata/diff) had. The proxy sees {jobId} at once, + * so a slow restic op can't be aborted mid-flight at the reverse-proxy's ~15s cap. + */ +export function jobResult(request: Request | undefined, fn: () => Promise): Response { + return createJobResponse(async (send) => { + try { + send('result', await fn()); + } catch (error) { + send('result', { error: error instanceof Error ? error.message : String(error) }); + } + }, request); +} diff --git a/src/routes/api/backup/instance/+server.ts b/src/routes/api/backup/instance/+server.ts new file mode 100644 index 0000000..2147a30 --- /dev/null +++ b/src/routes/api/backup/instance/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { requireBackups } from '$lib/server/backups/route-guards'; +import { getInstanceId } from '$lib/server/backups/identity'; + +// This install's stable instance id. The snapshots list used to ride it on an +// X-Dockhand-Instance response header, but that list is now job-polled (a proxy would +// abort the sync `restic snapshots` at ~15s) and headers don't survive polling. The +// client fetches the id here once and matches it against each snapshot's +// dockhand:instance= tag to tell own snapshots from foreign orphans (#1351). +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + const denied = await requireBackups(auth, 'view'); + if (denied) return denied; + + return json({ instanceId: await getInstanceId() }); +}; diff --git a/src/routes/api/backup/restore/preview/+server.ts b/src/routes/api/backup/restore/preview/+server.ts index 297f874..e546814 100644 --- a/src/routes/api/backup/restore/preview/+server.ts +++ b/src/routes/api/backup/restore/preview/+server.ts @@ -5,6 +5,7 @@ import { requireBackups } from '$lib/server/backups/route-guards'; import { previewSnapshot, previewRestoreTargets } from '$lib/server/backups'; import { validateSnapshotId } from '$lib/server/docker-validation'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; +import { jobResult } from '$lib/server/sse'; export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); @@ -26,7 +27,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => { const denied = await guardSnapshotEnvAccess(auth, body.destinationId, body.snapshotId); if (denied) return denied; - try { + // Job-polling: the preview runs restic (verify-ownership + ls/dump) and, with a mode, + // per-volume probe helpers - all slow enough that a reverse proxy would abort the sync + // request at ~15s and SIGTERM the restic mid-op (the "wrong password / signal terminated" + // red herrings). Returning {jobId} immediately keeps the connection alive; the client polls. + return jobResult(request, async () => { const access = { isEnterprise: auth.isEnterprise, canAccessEnvironment: (id: number) => auth.canAccessEnvironment(id) }; const preview = await previewSnapshot(body.destinationId, body.snapshotId, access); // When the caller supplies a restore mode, ALSO resolve the exact on-disk targets and probe @@ -44,11 +49,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => { mergeStackFiles: body.mergeStackFiles, volumes: body.volumes, }, access); - return json({ ...preview, targets }); + return { ...preview, targets }; } - return json(preview); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return json({ error: errorMsg }, { status: 500 }); - } + return preview; + }); }; diff --git a/src/routes/api/backup/snapshots/+server.ts b/src/routes/api/backup/snapshots/+server.ts index 0c5c22d..2054c3f 100644 --- a/src/routes/api/backup/snapshots/+server.ts +++ b/src/routes/api/backup/snapshots/+server.ts @@ -5,8 +5,9 @@ import { requireBackups } from '$lib/server/backups/route-guards'; import { getBackupConfig, getBackupDestinations } from '$lib/server/db'; import { listSnapshots } from '$lib/server/backups'; import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards'; +import { jobResult } from '$lib/server/sse'; -export const GET: RequestHandler = async ({ url, cookies }) => { +export const GET: RequestHandler = async ({ url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); if (denied) return denied; @@ -30,13 +31,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => { // Narrow to THIS config's identity by its stable config-id tag so a sibling // config sharing the same destination + targetName isn't mixed in. if (allDests) { - // Search ALL destinations for this config's snapshots - try { + // Search ALL destinations for this config's snapshots. + // Job-polling: `restic snapshots` across every destination is the slowest read here + // and a reverse proxy would abort the sync request at ~15s. The result stays a bare + // snapshot array (unchanged contract - many callers/tests treat it as an array); the + // old X-Incomplete-Destinations header is now log-only. + return jobResult(request, async () => { const destinations = await getBackupDestinations(); // Bound each destination's listing so one slow or unreachable repo // can't stall the whole response for minutes (restic's own timeout is - // several minutes). A timed-out destination is treated as a failed - // one — surfaced as a partial result via X-Incomplete-Destinations. + // several minutes). A timed-out destination is treated as a failed one. const PER_DEST_MS = Number(process.env.SNAPSHOT_LIST_TIMEOUT_MS ?? 30_000); const withTimeout = (p: Promise, name: string): Promise => Promise.race([ @@ -50,10 +54,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => { ); const allSnapshots: any[] = []; const seen = new Set(); - // (audit #29) Don't silently drop destinations that errored. Collect - // the failures and surface them via a response header (keeping the body - // a plain array for compatibility), plus a server log — so a partial - // result doesn't look complete. + // (audit #29) Don't silently drop destinations that errored. Collect the + // failures and surface a count so a partial result doesn't look complete. const failed: string[] = []; results.forEach((result, i) => { if (result.status === 'fulfilled') { @@ -72,48 +74,33 @@ export const GET: RequestHandler = async ({ url, cookies }) => { allSnapshots.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()); if (failed.length > 0) { console.warn(`[Backup] Snapshot search incomplete — ${failed.length} destination(s) failed: ${failed.join('; ')}`); - return json(allSnapshots, { headers: { 'X-Incomplete-Destinations': String(failed.length) } }); } - return json(allSnapshots); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return json({ error: errorMsg }, { status: 500 }); - } + return allSnapshots; + }); } - // Default: only search the config's current destination - try { - const snapshots = await listSnapshots(config.destinationId, config.id); - return json(snapshots); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return json({ error: errorMsg }, { status: 500 }); - } + // Default: only search the config's current destination. + return jobResult(request, () => listSnapshots(config.destinationId, config.id)); } else if (destIdParam) { const destinationId = parseInt(destIdParam); if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 }); - try { + // Job-polling: `restic snapshots` would otherwise be aborted at the proxy's ~15s cap. + // Returns a bare snapshot array (unchanged contract). The per-snapshot dockhand:instance= + // tag identifies the writing install; the client learns its OWN id from + // GET /api/backup/instance (the old X-Dockhand-Instance header can't ride job polling). + return jobResult(request, async () => { // No target filter — return all snapshots in this destination (incl. snapshots - // from OTHER Dockhand instances sharing/copied into the repo). The response carries - // this install's instance id so the client can tell own snapshots from foreign - // orphans without relying on configid (which collides across instances - #1351). - const { getInstanceId } = await import('$lib/server/backups/identity'); - const thisInstanceId = await getInstanceId(); + // from OTHER Dockhand instances sharing/copied into the repo). const snapshots = await listSnapshots(destinationId); // (HIGH #8) On enterprise, a non-admin must not see snapshots belonging to // environments they can't access — the ids/paths alone are disclosure. Drop // any snapshot whose owning env (from its dockhand:env tag) isn't accessible. - const headers = { 'X-Dockhand-Instance': thisInstanceId }; if (auth.isEnterprise) { - const filtered = await filterSnapshotsByEnvAccess(auth, snapshots); - return json(filtered, { headers }); + return await filterSnapshotsByEnvAccess(auth, snapshots); } - return json(snapshots, { headers }); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return json({ error: errorMsg }, { status: 500 }); - } + return snapshots; + }); } else { return json({ error: 'configId or destinationId parameter is required' }, { status: 400 }); } diff --git a/src/routes/api/backup/snapshots/[id]/browse/+server.ts b/src/routes/api/backup/snapshots/[id]/browse/+server.ts index 6be4131..3e8333f 100644 --- a/src/routes/api/backup/snapshots/[id]/browse/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/browse/+server.ts @@ -5,8 +5,9 @@ import { authorize } from '$lib/server/authorize'; import { requireBackups } from '$lib/server/backups/route-guards'; import { browseSnapshot } from '$lib/server/backups'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; +import { jobResult } from '$lib/server/sse'; -export const GET: RequestHandler = async ({ params, url, cookies }) => { +export const GET: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); if (denied) return denied; @@ -38,11 +39,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { return json({ error: 'Environment access denied' }, { status: 403 }); } - try { + // Job-polling: `restic ls` behind a reverse proxy would abort at ~15s and SIGTERM restic + // (surfacing a misleading "wrong password / signal terminated"). Return {jobId} at once. + return jobResult(request, async () => { const entries = await browseSnapshot(destinationId, snapshotId, path); - return json({ entries, path }); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return json({ error: errorMsg }, { status: 500 }); - } + return { entries, path }; + }); }; diff --git a/src/routes/api/backup/snapshots/[id]/dump/+server.ts b/src/routes/api/backup/snapshots/[id]/dump/+server.ts index 7980b49..e75ab61 100644 --- a/src/routes/api/backup/snapshots/[id]/dump/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/dump/+server.ts @@ -6,8 +6,9 @@ import { requireBackups } from '$lib/server/backups/route-guards'; import { dumpSnapshotFile, dumpSnapshotFileBytes, dumpSnapshotArchive } from '$lib/server/backups'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { parseSnapshotLayout, redactSnapshotLayout } from '$lib/server/backups/snapshot-layout'; +import { jobResult } from '$lib/server/sse'; -export const GET: RequestHandler = async ({ params, url, cookies }) => { +export const GET: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); if (denied) return denied; @@ -58,35 +59,35 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { if (download) { return json({ error: 'metadata.json cannot be downloaded raw; use the snapshot metadata endpoint (secrets are redacted there)' }, { status: 403 }); } - try { + // Job-polling: `restic dump` behind a reverse proxy would abort at ~15s. + return jobResult(request, async () => { const raw = await dumpSnapshotFile(destinationId, snapshotId, '/metadata/metadata.json'); const layout = parseSnapshotLayout(raw); - if (!layout) return json({ error: 'metadata unreadable' }, { status: 404 }); - return json({ content: JSON.stringify(redactSnapshotLayout(layout), null, 2) }); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return json({ error: errorMsg }, { status: 500 }); - } + if (!layout) return { error: 'metadata unreadable' }; + return { content: JSON.stringify(redactSnapshotLayout(layout), null, 2) }; + }); } // Sanitize filename for Content-Disposition (strip quotes, backslashes, control chars) const sanitizeFilename = (name: string) => name.replace(/["\\\x00-\x1f]/g, '_'); - try { - if (download && isDir) { - // Binary tar stream — serve the raw bytes untouched (a UTF-8 round-trip - // would corrupt any non-ASCII byte in the archive). - const tarData = await dumpSnapshotArchive(destinationId, snapshotId, path); - const filename = sanitizeFilename((path.split('/').filter(Boolean).pop() || 'archive') + '.tar'); - return new Response(new Uint8Array(tarData), { - headers: { - 'Content-Type': 'application/x-tar', - 'Content-Disposition': `attachment; filename="${filename}"` - } - }); - } - - if (download) { + // Binary downloads are window.open navigations, not fetch()es — they can't poll a job, + // so they stay synchronous. (A raw byte/tar stream also can't be JSON-wrapped.) These + // stream restic's output, so the proxy sees bytes flowing and won't idle-abort them. + if (download) { + try { + if (isDir) { + // Binary tar stream — serve the raw bytes untouched (a UTF-8 round-trip + // would corrupt any non-ASCII byte in the archive). + const tarData = await dumpSnapshotArchive(destinationId, snapshotId, path); + const filename = sanitizeFilename((path.split('/').filter(Boolean).pop() || 'archive') + '.tar'); + return new Response(new Uint8Array(tarData), { + headers: { + 'Content-Type': 'application/x-tar', + 'Content-Disposition': `attachment; filename="${filename}"` + } + }); + } // A file download may be binary — serve raw bytes, not a decoded string. const bytes = await dumpSnapshotFileBytes(destinationId, snapshotId, path); const filename = sanitizeFilename(path.split('/').pop() || 'file'); @@ -96,13 +97,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { 'Content-Disposition': `attachment; filename="${filename}"` } }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return json({ error: errorMsg }, { status: 500 }); } - - // Inline preview — text only. - const content = await dumpSnapshotFile(destinationId, snapshotId, path); - return json({ content }); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return json({ error: errorMsg }, { status: 500 }); } + + // Inline preview (text only) — job-polling: `restic dump` would otherwise be aborted + // at the reverse-proxy's ~15s cap. + return jobResult(request, async () => { + const content = await dumpSnapshotFile(destinationId, snapshotId, path); + return { content }; + }); }; diff --git a/src/routes/api/backup/snapshots/[id]/metadata/+server.ts b/src/routes/api/backup/snapshots/[id]/metadata/+server.ts index 3b0361d..2adaae0 100644 --- a/src/routes/api/backup/snapshots/[id]/metadata/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/metadata/+server.ts @@ -6,8 +6,9 @@ import { requireBackups } from '$lib/server/backups/route-guards'; import { getSnapshotMetadata } from '$lib/server/backups'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { redactSnapshotLayout } from '$lib/server/backups/snapshot-layout'; +import { jobResult } from '$lib/server/sse'; -export const GET: RequestHandler = async ({ params, url, cookies }) => { +export const GET: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); if (denied) return denied; @@ -26,14 +27,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const envDenied = await guardSnapshotEnvAccess(auth, destinationId, snapshotId); if (envDenied) return envDenied; - try { + // Job-polling: reading metadata runs `restic dump` which a proxy would abort at ~15s. + return jobResult(request, async () => { const metadata = await getSnapshotMetadata(destinationId, snapshotId); - if (!metadata) return json({ error: 'No metadata available' }, { status: 404 }); + if (!metadata) return { error: 'No metadata available' }; // Single redaction point: strips stack.secrets values AND container inspect // Config.Env/Labels (plaintext secrets) before anything reaches the client. - return json(redactSnapshotLayout(metadata)); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - return json({ error: msg }, { status: 500 }); - } + return redactSnapshotLayout(metadata); + }); }; diff --git a/src/routes/api/backup/snapshots/diff/+server.ts b/src/routes/api/backup/snapshots/diff/+server.ts index e721589..b3182b8 100644 --- a/src/routes/api/backup/snapshots/diff/+server.ts +++ b/src/routes/api/backup/snapshots/diff/+server.ts @@ -5,8 +5,9 @@ import { requireBackups } from '$lib/server/backups/route-guards'; import { diffSnapshots } from '$lib/server/backups'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { validateSnapshotId } from '$lib/server/docker-validation'; +import { jobResult } from '$lib/server/sse'; -export const GET: RequestHandler = async ({ url, cookies }) => { +export const GET: RequestHandler = async ({ url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); if (denied) return denied; @@ -31,11 +32,6 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const deniedB = await guardSnapshotEnvAccess(auth, destinationId, snapB); if (deniedB) return deniedB; - try { - const result = await diffSnapshots(destinationId, snapA, snapB); - return json(result); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - return json({ error: msg }, { status: 500 }); - } + // Job-polling: `restic diff` runs two restic reads a proxy would abort at ~15s. + return jobResult(request, () => diffSnapshots(destinationId, snapA, snapB)); }; diff --git a/src/routes/backups/+page.svelte b/src/routes/backups/+page.svelte index 6d50047..0a7cb34 100644 --- a/src/routes/backups/+page.svelte +++ b/src/routes/backups/+page.svelte @@ -25,7 +25,7 @@ import RotateCwFadingClock from '$lib/components/icons/RotateCwFadingClock.svelte'; import { formatDateTime, formatRelativeTime } from '$lib/stores/settings'; import { currentEnvironment } from '$lib/stores/environment'; - import { watchJob } from '$lib/utils/sse-fetch'; + import { watchJob, readJobResponse } from '$lib/utils/sse-fetch'; import { getRepoTypeIcon, formatCron, retentionSummary, classifyJobResult, tagLogLine } from '$lib/utils/backup'; import SnapshotBrowser from '../containers/SnapshotBrowser.svelte'; import RestoreModal from '../containers/RestoreModal.svelte'; @@ -328,6 +328,14 @@ // one of these belongs to a shown config; anything else is orphaned. const liveConfigIds = new Set(configs.map(c => c.id)); + // This install's id, fetched once: a snapshot is a LOCAL config's only when THIS + // instance wrote it (configid collides across installs - #1351). Formerly read from + // the snapshots list's X-Dockhand-Instance header, which job polling can't carry. + const thisInstance = await fetch('/api/backup/instance') + .then(r => r.ok ? r.json() : { instanceId: '' }) + .then(d => d.instanceId || '') + .catch(() => ''); + // Query each destination once, then split its snapshots two ways: PER CONFIG // (by dockhand:configid) for the shown rows, and PER NAME for the orphan rows // (snapshots whose config was deleted). The right-column count must match the @@ -340,13 +348,12 @@ // rest keep filling in counts progressively behind the scenes. await Promise.allSettled(destinations.map(async (dest) => { try { - const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`); - if (!res.ok) return; - // This install id: a snapshot belongs to a LOCAL config only when THIS instance wrote it. - // configid collides across instances (a fresh install reuses 1,2,3...), so a foreign - // snapshot with configid=1 must be an orphan, not mis-bucketed into local config #1 (#1351). - const thisInstance = res.headers.get("X-Dockhand-Instance") || ""; - const d = await res.json(); + // Job-polling so a slow `restic snapshots` behind a proxy isn't aborted at ~15s. + const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`, { + headers: { Accept: 'text/event-stream' } + }); + const d = await readJobResponse(res); + if (!d || d.error) return; const snaps: any[] = d.snapshots ?? d; if (!Array.isArray(snaps)) return; @@ -406,16 +413,17 @@ loadingSnapshots = newLoading; try { - // Query snapshots for this config's destination only (not allDestinations) + // Query snapshots for this config's destination only (not allDestinations). + // Job-polling so a slow `restic snapshots` behind a proxy isn't aborted at ~15s. const snapUrl = config.isOrphan ? `/api/backup/snapshots?destinationId=${config.destinationId}` : `/api/backup/snapshots?configId=${config.id}`; - const [snapRes, execRes] = await Promise.all([ - fetch(snapUrl), + const [snapJson, execRes] = await Promise.all([ + fetch(snapUrl, { headers: { Accept: 'text/event-stream' } }).then(readJobResponse), config.isOrphan ? Promise.resolve(new Response('{"executions":[]}')) : fetch(`/api/schedules/executions?scheduleType=backup&scheduleId=${config.id}&limit=50`) ]); - const snapJson = await snapRes.json(); - let snapData = snapJson.snapshots ?? snapJson; + const snapError = snapJson?.error; + let snapData = snapJson?.snapshots ?? snapJson; // For orphans, filter by target name (they can span destinations). if (config.isOrphan && Array.isArray(snapData)) { snapData = snapData.filter((s: any) => (s.tags || []).some((t: string) => t === `dockhand:name=${config.targetName}`)); @@ -433,11 +441,11 @@ })); } const newMap = new Map(snapshotsMap); - if (snapRes.ok) { + if (!snapError) { newMap.set(key, Array.isArray(snapData) ? snapData : []); } else { newMap.set(key, []); - toast.error(cleanErrorMessage(snapData.error || 'Failed to load snapshots')); + toast.error(cleanErrorMessage(snapError || 'Failed to load snapshots')); } snapshotsMap = newMap; @@ -784,13 +792,17 @@

Configure backups on individual containers or stacks via their edit modal.

{:else} + { sortField = state.field; sortDirection = state.direction; }} bind:expandedKeys={expandedKeys} diff --git a/src/routes/backups/SnapshotDiffModal.svelte b/src/routes/backups/SnapshotDiffModal.svelte index 085896d..2df82bf 100644 --- a/src/routes/backups/SnapshotDiffModal.svelte +++ b/src/routes/backups/SnapshotDiffModal.svelte @@ -3,6 +3,7 @@ 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 { readJobResponse } from '$lib/utils/sse-fetch'; import { formatDateTime } from '$lib/stores/settings'; interface Props { @@ -43,13 +44,11 @@ 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(); + // Job-polling so the two `restic diff` reads behind a proxy aren't aborted at ~15s. + const res = await fetch(`/api/backup/snapshots/diff?destinationId=${destinationId}&snapshotA=${snapshotA.id}&snapshotB=${snapshotB.id}`, { headers: { Accept: 'text/event-stream' } }); + const data = await readJobResponse(res); + if (data?.error) { error = data.error; return; } + diff = data; } catch (err) { error = err instanceof Error ? err.message : String(err); } finally { @@ -61,12 +60,13 @@ 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}`) + // Job-polling for both metadata dumps (slow `restic dump` behind a proxy). + const [dataA, dataB] = await Promise.all([ + fetch(`/api/backup/snapshots/${snapshotA.id}/metadata?destinationId=${destinationId}`, { headers: { Accept: 'text/event-stream' } }).then(readJobResponse), + fetch(`/api/backup/snapshots/${snapshotB.id}/metadata?destinationId=${destinationId}`, { headers: { Accept: 'text/event-stream' } }).then(readJobResponse) ]); - metaA = resA.ok ? await resA.json() : null; - metaB = resB.ok ? await resB.json() : null; + metaA = dataA?.error ? null : dataA; + metaB = dataB?.error ? null : dataB; } catch {} metaLoading = false; } diff --git a/src/routes/containers/FileBrowserPanel.svelte b/src/routes/containers/FileBrowserPanel.svelte index fb52fae..ccad9f7 100644 --- a/src/routes/containers/FileBrowserPanel.svelte +++ b/src/routes/containers/FileBrowserPanel.svelte @@ -1,5 +1,6 @@