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 -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))