read-side backup snapshot endpoints use job polling

This commit is contained in:
jarek committed 2026-08-13 10:20:30 +02:00
1 parent 315c795058
commit 610e46dc19
15 files changed
+224 -165

No files matched your search

+18
View File
@@ -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<T>(request: Request | undefined, fn: () => Promise<T>): Response {
return createJobResponse(async (send) => {
try {
send('result', await fn());
} catch (error) {
send('result', { error: error instanceof Error ? error.message : String(error) });
}
}, request);
}
+18
View File
@@ -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() });
};
@@ -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;
});
};
+24 -37
View File
@@ -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 = <T>(p: Promise<T>, name: string): Promise<T> =>
Promise.race([
@@ -50,10 +54,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
);
const allSnapshots: any[] = [];
const seen = new Set<string>();
// (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 });
}
@@ -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 };
});
};
@@ -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 };
});
};
@@ -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);
});
};
@@ -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));
};
+29 -17
View File
@@ -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 @@
<p class="text-sm text-muted-foreground">Configure backups on individual containers or stacks via their edit modal.</p>
</div>
{:else}
<!-- virtualScroll is OFF here on purpose: it assumes a fixed rowHeight, but an
expanded config renders a variable-height snapshot list, so the fixed-height
spacers mis-size the scroll area (flicker + endless scroll). Backup configs
number in the dozens, so plain rendering - the browser measures real heights -
is correct and fast. -->
<DataGrid
data={filteredConfigs}
keyField="key"
gridId="backups"
loading={loading}
virtualScroll={true}
rowHeight={33}
virtualScroll={false}
sortState={{ field: sortField, direction: sortDirection }}
onSortChange={(state) => { sortField = state.field; sortDirection = state.direction; }}
bind:expandedKeys={expandedKeys}
+12 -12
View File
@@ -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;
}
+18 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog';
@@ -400,9 +401,15 @@
try {
let res: Response;
let data: any;
if (isSnapshotMode) {
const params = new URLSearchParams({ destinationId: String(destinationId), path: filePath });
res = await fetch(`/api/backup/snapshots/${snapshotId}/dump?${params}`);
// Job-polling (readJobResponse) so a slow `restic dump` behind a proxy isn't aborted at ~15s.
res = await fetch(`/api/backup/snapshots/${snapshotId}/dump?${params}`, {
headers: { Accept: 'text/event-stream' }
});
data = await readJobResponse(res);
if (data?.error) throw new Error(data.error);
} else {
const params = new URLSearchParams({ path: filePath });
if (envId) params.set('env', envId.toString());
@@ -411,11 +418,10 @@
} else {
res = await fetch(`/api/containers/${effectiveContainerId}/files/content?${params}`);
}
}
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to read file');
data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to read file');
}
}
viewingFile = {
@@ -740,8 +746,12 @@
if (isSnapshotMode) {
const snapshotParams = new URLSearchParams({ destinationId: String(destinationId), path });
res = await fetch(`/api/backup/snapshots/${snapshotId}/browse?${snapshotParams}`);
data = await res.json();
// Job-polling (readJobResponse) so a slow `restic ls` behind a proxy isn't aborted at ~15s.
res = await fetch(`/api/backup/snapshots/${snapshotId}/browse?${snapshotParams}`, { headers: { Accept: 'text/event-stream' } });
data = await readJobResponse(res);
// A jobified error comes back as { error } with HTTP 200, so surface it explicitly
// (the shared `if (!res.ok)` gate below can't see it).
if (data?.error) throw new Error(data.error);
} else if (isVolumeMode) {
res = await fetch(`/api/volumes/${encodeURIComponent(volumeName!)}/browse?${params}`);
data = await res.json();
+14 -11
View File
@@ -9,7 +9,7 @@
import { RotateCcw, AlertTriangle, Loader2, HardDrive, Folder, Clock, Play, CheckCircle2, XCircle, Server, PackagePlus, Ban, Rocket, Box, Layers, HelpCircle, Info, KeyRound, FileX } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { toast } from 'svelte-sonner';
import { watchJob } from '$lib/utils/sse-fetch';
import { watchJob, readJobResponse } from '$lib/utils/sse-fetch';
import { environments } from '$lib/stores/environment';
import { formatDateTime } from '$lib/stores/settings';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
@@ -346,16 +346,18 @@
loading = true;
error = '';
try {
// Job-polling (server returns {jobId}; readJobResponse polls to the result) so a slow
// restic verify/read behind a reverse proxy can't be aborted at ~15s.
const res = await fetch('/api/backup/restore/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ destinationId, snapshotId, environmentId })
});
if (!res.ok) {
error = (await res.json()).error || 'Failed to read the snapshot';
const data = await readJobResponse(res);
if (data?.error) {
error = data.error || 'Failed to read the snapshot';
return;
}
const data = await res.json();
const types: Record<string, 'volume' | 'bind'> = data.volumeTypes || {};
const sources: Record<string, string> = data.volumeSources || {};
// Pre-fill each destination 1:1 from the snapshot's original location: a
@@ -398,9 +400,11 @@
const volumeDestinations = mode === 'new-location'
? selectedRows.map((v) => ({ volume: v.name, kind: v.destKind, target: v.dest.trim() }))
: undefined;
// Job-polling: the target probe runs a helper container per volume on the target host
// (slowest read path) - a sync request would abort at the proxy's ~15s.
const res = await fetch('/api/backup/restore/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({
destinationId, snapshotId, mode, environmentId: envId,
targetType: targetIsStack ? 'stack' : 'container',
@@ -409,14 +413,13 @@
volumes: selectedRows.map((v) => v.name),
})
});
if (seq !== targetPreviewSeq) return; // a newer request superseded this one
if (!res.ok) {
const data = await readJobResponse(res);
if (seq !== targetPreviewSeq) return; // a newer request superseded this one (checked AFTER the poll)
if (data?.error) {
targetPreview = null;
const msg = await res.json().then((d) => d?.error).catch(() => null);
targetPreviewError = msg || 'Could not check the target paths on the environment.';
targetPreviewError = data.error || 'Could not check the target paths on the environment.';
return;
}
const data = await res.json();
targetPreview = data.targets ?? null;
} catch {
if (seq === targetPreviewSeq) { targetPreview = null; targetPreviewError = 'Could not reach the server to check the target paths.'; }
+12 -10
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { Badge } from '$lib/components/ui/badge';
import {
FolderOpen, Box, Layers, Network, HardDrive, Tag, Variable,
@@ -68,9 +69,12 @@
destinationRepo = dest.repository || '';
}
// Get snapshot time + owning environment from the snapshot's tags.
const snapRes = await fetch(`/api/backup/snapshots?destinationId=${destinationId}`);
if (snapRes.ok) {
const d = await snapRes.json();
// Job-polling so a slow `restic snapshots` behind a proxy isn't aborted at ~15s.
const snapRes = await fetch(`/api/backup/snapshots?destinationId=${destinationId}`, {
headers: { Accept: 'text/event-stream' }
});
const d = await readJobResponse(snapRes);
if (d && !d.error) {
const snaps = d.snapshots ?? d;
const snap = (Array.isArray(snaps) ? snaps : []).find((s: any) => s.id === snapshotId || s.shortId === snapshotId?.slice(0, 8));
if (snap) {
@@ -91,13 +95,11 @@
metadataLoading = true;
metadataError = '';
try {
const res = await fetch(`/api/backup/snapshots/${snapshotId}/metadata?destinationId=${destinationId}`);
if (res.ok) {
metadata = await res.json();
} else {
try { metadataError = (await res.json()).error || 'No metadata available'; }
catch { metadataError = 'No metadata available'; }
}
// Job-polling so a slow `restic dump` behind a proxy isn't aborted at ~15s.
const res = await fetch(`/api/backup/snapshots/${snapshotId}/metadata?destinationId=${destinationId}`, { headers: { Accept: 'text/event-stream' } });
const data = await readJobResponse(res);
if (data?.error) metadataError = data.error || 'No metadata available';
else metadata = data;
} catch { metadataError = 'Failed to load metadata'; }
finally { metadataLoading = false; }
}
+7 -3
View File
@@ -6,6 +6,7 @@
import { LoadingState } from '$lib/components/ui/loading-state';
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { getRepoTypeIcon } from '$lib/utils/backup';
import SnapshotBrowser from './SnapshotBrowser.svelte';
import RestoreModal from './RestoreModal.svelte';
@@ -111,9 +112,12 @@
// many (or one unreachable) repos the list fills in live rather than after a long wait.
await Promise.all(destinations.map(async (dest) => {
try {
const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`);
if (!res.ok) return;
const data = 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 data = await readJobResponse(res);
if (data?.error) return;
const snaps: any[] = data.snapshots ?? data;
const mine = snaps
.filter((s) => (s.tags || []).some((t: string) => t === nameTag))
+11 -7
View File
@@ -26,7 +26,7 @@
import { canAccess } from '$lib/stores/auth';
import DestinationModal from './DestinationModal.svelte';
import BackupLogModal from '../../backups/BackupLogModal.svelte';
import { watchJob } from '$lib/utils/sse-fetch';
import { watchJob, readJobResponse } from '$lib/utils/sse-fetch';
import RotatePasswordModal from './RotatePasswordModal.svelte';
import { EmptyState } from '$lib/components/ui/empty-state';
@@ -370,13 +370,17 @@
browseOpen = true;
browseLoading = true;
try {
// List all snapshots in this destination (no configId filter)
const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`);
if (res.ok) { const d = await res.json(); browseSnapshots = d.snapshots ?? d; }
else {
const data = await res.json();
const errMsg = data.error || 'Failed to list snapshots';
// List all snapshots in this destination (no configId filter). 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?.error) {
const errMsg = d.error || 'Failed to list snapshots';
try { const p = JSON.parse(errMsg); toast.error(p.message || errMsg); } catch { toast.error(errMsg); }
} else {
browseSnapshots = d.snapshots ?? d;
}
} catch { toast.error('Failed to list snapshots'); }
finally { browseLoading = false; }