snapshots from another Dockhand instance sharing the repo now appear as restorable orphans

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent 5adac5e19e
commit 3d1b383ad7
4 files changed
+110 -40

No files matched your search

+39 -20
View File
@@ -1,12 +1,12 @@
/** /**
* backups/snapshots.ts — the READ side: list snapshots and read their contents, * backups/snapshots.ts - the READ side: list snapshots and read their contents,
* with the ownership guards that keep one installation (or one environment) from * with the ownership guards that keep one installation (or one environment) from
* reading another's backups on a shared repository. * reading another's backups on a shared repository.
* *
* TWO ownership checks, both enforced HERE so no read path can forget them: * TWO ownership checks, both enforced HERE so no read path can forget them:
* - INSTANCE ownership: every content read (browse/dump/diff/metadata) first * - INSTANCE ownership: every content read (browse/dump/diff/metadata) first
* asks restic for the snapshot filtered by THIS install's instance tag. If * asks restic for the snapshot filtered by THIS install's instance tag. If
* restic returns nothing, the snapshot isn't ours — we refuse. Without this, * restic returns nothing, the snapshot isn't ours - we refuse. Without this,
* anyone who knows a snapshot id could read another install's volume data * anyone who knows a snapshot id could read another install's volume data
* from a shared repo. * from a shared repo.
* - ENVIRONMENT ownership (enterprise): the snapshot's owning environment is * - ENVIRONMENT ownership (enterprise): the snapshot's owning environment is
@@ -42,21 +42,28 @@ export async function assertInstanceOwned(
destination: any, destination: any,
instanceId: string, instanceId: string,
snapshotId: string, snapshotId: string,
enterprise = false,
): Promise<string[]> { ): Promise<string[]> {
if (!isValidSnapshotId(snapshotId)) { if (!isValidSnapshotId(snapshotId)) {
throw new BackupError('VALIDATION', 'invalid snapshot id'); throw new BackupError('VALIDATION', 'invalid snapshot id');
} }
// ENTERPRISE keeps the strict per-instance check: env-scoped RBAC installs enforce env
// boundaries and don't copy repositories between instances, so a snapshot must be THIS
// instance's. NON-ENTERPRISE looks up WITHOUT the instance filter, so a snapshot written
// by another Dockhand instance (a physically-copied or shared repo) is a restorable orphan
// (#1351, promised by the manual). Either way we confirm the id exists and is a Dockhand
// snapshot (has a dockhand:instance= tag), so a foreign app's snapshot can't be targeted.
const filterArgs = enterprise ? ['--tag', instanceTagFilter(instanceId)] : [];
const run = await restic.runLocal(destination, [ const run = await restic.runLocal(destination, [
'snapshots', '--json', '--no-lock', '--tag', instanceTagFilter(instanceId), snapshotId, 'snapshots', '--json', '--no-lock', ...filterArgs, snapshotId,
]); ]);
if (run.exitCode !== 0) { if (run.exitCode !== 0) {
throw new BackupError('RESTIC', `could not verify snapshot ownership: ${run.stderr.trim() || 'restic failed'}`); throw new BackupError('RESTIC', `could not verify snapshot ownership: ${run.stderr.trim() || 'restic failed'}`);
} }
const snaps = parseSnapshots(run.stdout); const snaps = parseSnapshots(run.stdout);
const match = snaps.find((s) => s.id === snapshotId || s.shortId === snapshotId); const match = snaps.find((s) => s.id === snapshotId || s.shortId === snapshotId);
if (!match) { if (!match || !(match.tags || []).some((t) => t.startsWith('dockhand:instance='))) {
// Not found under our instance tag → not ours. Report as not-found so we // Not a Dockhand snapshot in this repo (or, for enterprise, not this instance's) -> refuse.
// don't reveal that the id exists under another instance.
throw new BackupError('VALIDATION', 'snapshot not found for this installation', { snapshotId }); throw new BackupError('VALIDATION', 'snapshot not found for this installation', { snapshotId });
} }
return match.tags; return match.tags;
@@ -64,8 +71,8 @@ export async function assertInstanceOwned(
/** /**
* Assert the caller may access the snapshot's owning ENVIRONMENT (enterprise * Assert the caller may access the snapshot's owning ENVIRONMENT (enterprise
* only). Resolves the env from the snapshot's own `dockhand:envid` tag — never a * only). Resolves the env from the snapshot's own `dockhand:envid` tag - never a
* caller-supplied env — and fails closed if it can't be resolved. `tags` is the * caller-supplied env - and fails closed if it can't be resolved. `tags` is the
* snapshot's tag list (from assertInstanceOwned, so we don't re-query). * snapshot's tag list (from assertInstanceOwned, so we don't re-query).
*/ */
export async function assertEnvAccess(access: AccessContext, tags: string[]): Promise<void> { export async function assertEnvAccess(access: AccessContext, tags: string[]): Promise<void> {
@@ -91,13 +98,13 @@ export async function guardSnapshotAccess(
snapshotId: string, snapshotId: string,
access: AccessContext, access: AccessContext,
): Promise<void> { ): Promise<void> {
const tags = await assertInstanceOwned(restic, destination, instanceId, snapshotId); const tags = await assertInstanceOwned(restic, destination, instanceId, snapshotId, access.isEnterprise);
await assertEnvAccess(access, tags); await assertEnvAccess(access, tags);
} }
/** /**
* Resolve a snapshot's owning ENVIRONMENT from its own stable-id tag * Resolve a snapshot's owning ENVIRONMENT from its own stable-id tag
* (`dockhand:envid=<n>`), read server-side — never from a caller-supplied value. * (`dockhand:envid=<n>`), read server-side - never from a caller-supplied value.
* Used by the route-level env gate. The lookup is instance-scoped (restic filters * Used by the route-level env gate. The lookup is instance-scoped (restic filters
* by this install's instance tag), so a snapshot that isn't ours resolves to * by this install's instance tag), so a snapshot that isn't ours resolves to
* "unresolved" and the caller fails closed. * "unresolved" and the caller fails closed.
@@ -105,7 +112,7 @@ export async function guardSnapshotAccess(
* Returns: * Returns:
* - { envId } the numeric owning environment id * - { envId } the numeric owning environment id
* - { envId: null } a 'local' / unscoped snapshot (nothing to gate on) * - { envId: null } a 'local' / unscoped snapshot (nothing to gate on)
* - { envId: undefined, resolved: false } when the env can't be resolved — * - { envId: undefined, resolved: false } when the env can't be resolved -
* callers MUST deny on this to fail closed. * callers MUST deny on this to fail closed.
*/ */
export async function resolveSnapshotEnvId( export async function resolveSnapshotEnvId(
@@ -123,7 +130,7 @@ export async function resolveSnapshotEnvId(
const match = snaps.find((s) => s.id === snapshotId || s.shortId === snapshotId); const match = snaps.find((s) => s.id === snapshotId || s.shortId === snapshotId);
if (!match) return { envId: undefined, resolved: false }; if (!match) return { envId: undefined, resolved: false };
const envId = readEnvIdFromTags(match.tags); const envId = readEnvIdFromTags(match.tags);
if (envId === null) return { envId: undefined, resolved: false }; // tag absent/malformed → fail closed if (envId === null) return { envId: undefined, resolved: false }; // tag absent/malformed -> fail closed
if (envId === 'local') return { envId: null, resolved: true }; // unscoped snapshot if (envId === 'local') return { envId: null, resolved: true }; // unscoped snapshot
return { envId, resolved: true }; return { envId, resolved: true };
} }
@@ -145,7 +152,7 @@ export async function filterSnapshotsByAccessibleEnv<T extends { tags: string[]
for (const snap of snapshots) { for (const snap of snapshots) {
const envId = readEnvIdFromTags(snap.tags); const envId = readEnvIdFromTags(snap.tags);
if (envId === 'local') { kept.push(snap); continue; } if (envId === 'local') { kept.push(snap); continue; }
if (envId === null) continue; // unresolvable env → fail closed if (envId === null) continue; // unresolvable env -> fail closed
if (!decided.has(envId)) decided.set(envId, await canAccess(envId)); if (!decided.has(envId)) decided.set(envId, await canAccess(envId));
if (decided.get(envId)) kept.push(snap); if (decided.get(envId)) kept.push(snap);
} }
@@ -163,22 +170,34 @@ export async function listSnapshots(
access: AccessContext, access: AccessContext,
configId?: number, configId?: number,
): Promise<ReturnType<typeof parseSnapshots>> { ): Promise<ReturnType<typeof parseSnapshots>> {
const tag = configId != null // Per-config listing (a config row's own snapshots) stays instance-scoped - a config is
? `${instanceTagFilter(instanceId)},dockhand:configid=${configId}` // this install's, and its configid collides across instances (a fresh install reuses
: instanceTagFilter(instanceId); // 1,2,3...), so scoping by instance+configid is what keeps it correct. The FULL repo
const run = await restic.runLocal(destination, ['snapshots', '--json', '--no-lock', '--tag', tag]); // listing (no configId - feeds orphan detection) must NOT be instance-scoped, or foreign
// Dockhand snapshots (a physically-copied or shared repo) never surface as orphans (#1351).
const args = ['snapshots', '--json', '--no-lock'];
if (configId != null) args.push('--tag', `${instanceTagFilter(instanceId)},dockhand:configid=${configId}`);
const run = await restic.runLocal(destination, args);
if (run.exitCode !== 0) { if (run.exitCode !== 0) {
// An empty repo (not initialised) and a real error look different; surface // An empty repo (not initialised) and a real error look different; surface
// the stderr so the caller can classify. Return [] only on a clean exit. // the stderr so the caller can classify. Return [] only on a clean exit.
throw new BackupError('RESTIC', run.stderr.trim() || 'could not list snapshots'); throw new BackupError('RESTIC', run.stderr.trim() || 'could not list snapshots');
} }
const snaps = parseSnapshots(run.stdout); // restic --tag can't match "has ANY dockhand:instance= tag" (exact-match only), so filter
// here: keep only snapshots this repo received FROM DOCKHAND (any instance), never those a
// different app wrote to a shared repo. Per-config results already carry the tag.
const snaps = parseSnapshots(run.stdout).filter((s) =>
configId != null || (s.tags || []).some((t) => t.startsWith('dockhand:instance=')));
if (!access.isEnterprise) return snaps; if (!access.isEnterprise) return snaps;
// Filter to snapshots whose env the caller can access (unscoped = visible). // Filter to snapshots whose env the caller can access. `local` is unscoped -> visible;
// a null envId (tag absent/malformed) is UNRESOLVABLE -> fail CLOSED (drop), matching
// readEnvIdFromTags's contract and the sibling filterSnapshotsByAccessibleEnv. Treating
// null as visible would leak scoped snapshots whose env tag is missing/garbled.
const visible = []; const visible = [];
for (const s of snaps) { for (const s of snaps) {
const envId = readEnvIdFromTags(s.tags); const envId = readEnvIdFromTags(s.tags);
if (envId === null || envId === 'local') { visible.push(s); continue; } if (envId === 'local') { visible.push(s); continue; }
if (envId === null) continue; // unresolvable env -> fail closed
if (await access.canAccessEnvironment(envId)) visible.push(s); if (await access.canAccessEnvironment(envId)) visible.push(s);
} }
return visible; return visible;
+12 -6
View File
@@ -1,15 +1,15 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { requireBackups } from '$lib/server/backups/route-guards';
import { getBackupConfig, getBackupDestinations } from '$lib/server/db'; import { getBackupConfig, getBackupDestinations } from '$lib/server/db';
import { listSnapshots } from '$lib/server/backups'; import { listSnapshots } from '$lib/server/backups';
import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards'; import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards';
export const GET: RequestHandler = async ({ url, cookies }) => { export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('backups', 'view')) { const denied = await requireBackups(auth, 'view');
return json({ error: 'Permission denied' }, { status: 403 }); if (denied) return denied;
}
const configIdParam = url.searchParams.get('configId'); const configIdParam = url.searchParams.get('configId');
const destIdParam = url.searchParams.get('destinationId'); const destIdParam = url.searchParams.get('destinationId');
@@ -94,16 +94,22 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 }); if (isNaN(destinationId)) return json({ error: 'Invalid destinationId' }, { status: 400 });
try { try {
// No target filter — return all snapshots in this destination. // 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();
const snapshots = await listSnapshots(destinationId); const snapshots = await listSnapshots(destinationId);
// (HIGH #8) On enterprise, a non-admin must not see snapshots belonging to // (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 // 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. // any snapshot whose owning env (from its dockhand:env tag) isn't accessible.
const headers = { 'X-Dockhand-Instance': thisInstanceId };
if (auth.isEnterprise) { if (auth.isEnterprise) {
const filtered = await filterSnapshotsByEnvAccess(auth, snapshots); const filtered = await filterSnapshotsByEnvAccess(auth, snapshots);
return json(filtered); return json(filtered, { headers });
} }
return json(snapshots); return json(snapshots, { headers });
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
return json({ error: errorMsg }, { status: 500 }); return json({ error: errorMsg }, { status: 500 });
+33 -5
View File
@@ -10,6 +10,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { SnapshotLoadProgress } from '$lib/components/ui/snapshot-load-progress';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { DataGrid } from '$lib/components/data-grid'; import { DataGrid } from '$lib/components/data-grid';
@@ -106,6 +107,12 @@
// Snapshot counts loaded async per destination, keyed by targetName:destId // Snapshot counts loaded async per destination, keyed by targetName:destId
let snapshotCounts = $state<Map<string, number>>(new Map()); let snapshotCounts = $state<Map<string, number>>(new Map());
let snapshotCountsLoading = $state(true); let snapshotCountsLoading = $state(true);
// Progress of the per-destination snapshot count sweep: how many destinations have
// responded out of the total. Drives an "X / N" indicator so a slow/unreachable
// repo (backend restic timeout is 5 min) shows visible progress instead of an
// indeterminate spinner. done === total means the sweep finished.
let snapshotCountsDone = $state(0);
let snapshotCountsTotal = $state(0);
// Orphan targets: found in repos but no config exists // Orphan targets: found in repos but no config exists
interface OrphanTarget { key: string; targetName: string; type: string; destinationId: number; destinationName?: string; destinationRepository?: string; snapshotCount: number; latestSnapshot?: string; } interface OrphanTarget { key: string; targetName: string; type: string; destinationId: number; destinationName?: string; destinationRepository?: string; snapshotCount: number; latestSnapshot?: string; }
let orphanTargets = $state<OrphanTarget[]>([]); let orphanTargets = $state<OrphanTarget[]>([]);
@@ -169,6 +176,8 @@
let restoreSnapshotId = $state(''); let restoreSnapshotId = $state('');
let restoreContainerName = $state(''); let restoreContainerName = $state('');
let restoreEnvId = $state<number | undefined>(undefined); let restoreEnvId = $state<number | undefined>(undefined);
let restoreDestName = $state('');
let restoreDestRepo = $state('');
// Snapshot diff // Snapshot diff
let showDiff = $state(false); let showDiff = $state(false);
@@ -313,6 +322,8 @@
async function loadSnapshotCounts() { async function loadSnapshotCounts() {
snapshotCountsLoading = true; snapshotCountsLoading = true;
snapshotCountsDone = 0;
snapshotCountsTotal = destinations.length;
// Live config ids per destination — a snapshot's dockhand:configid tag matching // Live config ids per destination — a snapshot's dockhand:configid tag matching
// one of these belongs to a shown config; anything else is orphaned. // one of these belongs to a shown config; anything else is orphaned.
const liveConfigIds = new Set(configs.map(c => c.id)); const liveConfigIds = new Set(configs.map(c => c.id));
@@ -322,10 +333,19 @@
// (snapshots whose config was deleted). The right-column count must match the // (snapshots whose config was deleted). The right-column count must match the
// expanded per-config list, so it's keyed by config, NOT by target name — two // expanded per-config list, so it's keyed by config, NOT by target name — two
// configs backing the same container to the same repo have DIFFERENT counts. // configs backing the same container to the same repo have DIFFERENT counts.
//
// Each destination updates snapshotCounts as soon as IT responds, and the
// "Loading" indicator clears after the FIRST one finishes - so a single slow or
// unreachable repo can't hold the whole list hostage for its 30s timeout. The
// rest keep filling in counts progressively behind the scenes.
await Promise.allSettled(destinations.map(async (dest) => { await Promise.allSettled(destinations.map(async (dest) => {
try { try {
const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`); const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`);
if (!res.ok) return; 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(); const d = await res.json();
const snaps: any[] = d.snapshots ?? d; const snaps: any[] = d.snapshots ?? d;
if (!Array.isArray(snaps)) return; if (!Array.isArray(snaps)) return;
@@ -338,8 +358,9 @@
const perConfig = new Map<number, number>(); const perConfig = new Map<number, number>();
const orphanByName = new Map<string, any[]>(); const orphanByName = new Map<string, any[]>();
for (const snap of snaps) { for (const snap of snaps) {
const cid = Number(tagVal(snap, 'dockhand:configid=')); const cid = Number(tagVal(snap, "dockhand:configid="));
if (Number.isInteger(cid) && liveConfigIds.has(cid)) { const isOwn = tagVal(snap, "dockhand:instance=") === thisInstance;
if (isOwn && Number.isInteger(cid) && liveConfigIds.has(cid)) {
perConfig.set(cid, (perConfig.get(cid) || 0) + 1); perConfig.set(cid, (perConfig.get(cid) || 0) + 1);
} else { } else {
const name = tagVal(snap, 'dockhand:name=') || 'unknown'; const name = tagVal(snap, 'dockhand:name=') || 'unknown';
@@ -371,9 +392,10 @@
} }
snapshotCounts = newCounts; snapshotCounts = newCounts;
orphanTargets = newOrphans; orphanTargets = newOrphans;
} catch {} } catch {} finally { snapshotCountsDone += 1; }
})); }));
// Backstop: if there were zero destinations, nothing cleared it above.
snapshotCountsLoading = false; snapshotCountsLoading = false;
} }
@@ -622,6 +644,8 @@
restoreSnapshotId = snapshot.id; restoreSnapshotId = snapshot.id;
restoreContainerName = config.targetName; restoreContainerName = config.targetName;
restoreEnvId = config.environmentId ?? undefined; restoreEnvId = config.environmentId ?? undefined;
restoreDestName = snapshot._destinationName ?? config.destinationName ?? '';
restoreDestRepo = snapshot._destinationRepository ?? config.destinationRepository ?? '';
showRestore = true; showRestore = true;
} }
@@ -651,11 +675,13 @@
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden"> <div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8"> <div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader title="Backups" icon={Archive} count={filteredConfigs.length}> <PageHeader title="Backups" icon={Archive} count={filteredConfigs.length}>
{#if loading || snapshotCountsLoading} {#if loading}
<span class="flex items-center gap-1.5 text-xs text-muted-foreground"> <span class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="w-3.5 h-3.5 animate-spin" /> <Loader2 class="w-3.5 h-3.5 animate-spin" />
{loading ? 'Loading…' : 'Loading snapshots…'} Loading…
</span> </span>
{:else if snapshotCountsLoading}
<SnapshotLoadProgress done={snapshotCountsDone} total={snapshotCountsTotal} />
{/if} {/if}
</PageHeader> </PageHeader>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -1045,6 +1071,8 @@
destinationId={restoreDestId} destinationId={restoreDestId}
snapshotId={restoreSnapshotId} snapshotId={restoreSnapshotId}
containerName={restoreContainerName} containerName={restoreContainerName}
destinationName={restoreDestName}
destinationRepository={restoreDestRepo}
environmentId={restoreEnvId} environmentId={restoreEnvId}
onDone={fetchData} onDone={fetchData}
/> />
+26 -9
View File
@@ -41,6 +41,10 @@
} }
let loading = $state(true); let loading = $state(true);
// Progress of the per-destination snapshot sweep (X of N repos) - a slow/unreachable
// repo can take up to the backend restic timeout (5 min), so show it advancing.
let loadDone = $state(0);
let loadTotal = $state(0);
let snapshots = $state<Snapshot[]>([]); let snapshots = $state<Snapshot[]>([]);
let deletingSnapshot = $state<string | null>(null); let deletingSnapshot = $state<string | null>(null);
let confirmDeleteSnapshot = $state<string | null>(null); let confirmDeleteSnapshot = $state<string | null>(null);
@@ -55,6 +59,8 @@
let showRestore = $state(false); let showRestore = $state(false);
let restoreDestId = $state(0); let restoreDestId = $state(0);
let restoreSnapshotId = $state(''); let restoreSnapshotId = $state('');
let restoreDestName = $state('');
let restoreDestRepo = $state('');
// Diff (click one to select, another to compare) // Diff (click one to select, another to compare)
let showDiff = $state(false); let showDiff = $state(false);
let diffDestId = $state(0); let diffDestId = $state(0);
@@ -97,22 +103,29 @@
const destinations: { id: number; name: string; repository: string }[] = destRes.ok ? await destRes.json() : []; const destinations: { id: number; name: string; repository: string }[] = destRes.ok ? await destRes.json() : [];
const nameTag = `dockhand:name=${targetName}`; const nameTag = `dockhand:name=${targetName}`;
const all: Snapshot[] = []; const all: Snapshot[] = [];
// Query every destination and keep only THIS target's snapshots (by tag). loadDone = 0;
loadTotal = destinations.length;
snapshots = [];
// Query every destination and keep only THIS target's snapshots (by tag). Show each
// repo's results the moment it returns instead of blocking on the slowest repo - with
// many (or one unreachable) repos the list fills in live rather than after a long wait.
await Promise.all(destinations.map(async (dest) => { await Promise.all(destinations.map(async (dest) => {
try { try {
const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`); const res = await fetch(`/api/backup/snapshots?destinationId=${dest.id}`);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const snaps: any[] = data.snapshots ?? data; const snaps: any[] = data.snapshots ?? data;
for (const s of snaps) { const mine = snaps
if ((s.tags || []).some((t: string) => t === nameTag)) { .filter((s) => (s.tags || []).some((t: string) => t === nameTag))
all.push({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository }); .map((s) => ({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository }));
} if (mine.length) {
all.push(...mine);
all.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
snapshots = [...all];
onCount?.(all.length);
} }
} catch { /* skip an unreachable destination */ } } catch { /* skip an unreachable destination */ } finally { loadDone += 1; }
})); }));
all.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
snapshots = all;
onCount?.(all.length); onCount?.(all.length);
// Per-snapshot stats from schedule executions (recorded at backup time). // Per-snapshot stats from schedule executions (recorded at backup time).
@@ -151,6 +164,8 @@
function openRestore(s: Snapshot) { function openRestore(s: Snapshot) {
restoreDestId = s._destinationId; restoreDestId = s._destinationId;
restoreSnapshotId = s.id; restoreSnapshotId = s.id;
restoreDestName = s._destinationName;
restoreDestRepo = s._destinationRepository;
showRestore = true; showRestore = true;
} }
function toggleDiff(s: Snapshot) { function toggleDiff(s: Snapshot) {
@@ -226,7 +241,7 @@
<!-- First load only: a quiet spinner, no skeleton. On a refresh where rows already <!-- First load only: a quiet spinner, no skeleton. On a refresh where rows already
exist we fall through and keep the real table visible (the header refresh icon exist we fall through and keep the real table visible (the header refresh icon
already signals progress). Matches the History tab's empty/loading treatment. --> already signals progress). Matches the History tab's empty/loading treatment. -->
<LoadingState class="min-h-[50vh]" label="Loading snapshots..." /> <LoadingState class="min-h-[50vh]" label={loadTotal > 1 ? `Loading snapshots… ${loadDone} of ${loadTotal} repos` : 'Loading snapshots…'} />
{:else if snapshots.length === 0} {:else if snapshots.length === 0}
<div class="flex min-h-[60vh] flex-col items-center justify-center py-10 text-center"> <div class="flex min-h-[60vh] flex-col items-center justify-center py-10 text-center">
<Archive class="mb-3 h-10 w-10 text-muted-foreground/40" /> <Archive class="mb-3 h-10 w-10 text-muted-foreground/40" />
@@ -333,6 +348,8 @@
destinationId={restoreDestId} destinationId={restoreDestId}
snapshotId={restoreSnapshotId} snapshotId={restoreSnapshotId}
containerName={targetName} containerName={targetName}
destinationName={restoreDestName}
destinationRepository={restoreDestRepo}
{environmentId} {environmentId}
onDone={loadSnapshots} onDone={loadSnapshots}
/> />