read-side backup snapshot endpoints use job polling
This commit is contained in:
15 files changed
+224
-165
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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.'; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user