bulk-delete backup snapshots, and forget a config's snapshots on delete
This commit is contained in:
14 files changed
+831
-34
No files matched your search
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { AlertTriangle, Loader2, Trash2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
/** How many snapshots the confirm will delete. */
|
||||||
|
count: number;
|
||||||
|
/** Deletion in progress (disables the button, shows a spinner). */
|
||||||
|
busy?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
let { open = $bindable(), count, busy = false, onConfirm }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content class="max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle class="h-5 w-5" />
|
||||||
|
Delete {count} snapshot{count === 1 ? '' : 's'}?
|
||||||
|
</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
This permanently forgets and prunes the selected snapshot{count === 1 ? '' : 's'} from
|
||||||
|
{count === 1 ? 'its' : 'their'} backup repository. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<Dialog.Footer class="gap-2 sm:justify-end">
|
||||||
|
<Button variant="outline" onclick={() => (open = false)} disabled={busy}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onclick={onConfirm} disabled={busy}>
|
||||||
|
{#if busy}<Loader2 class="mr-1 h-4 w-4 animate-spin" />{:else}<Trash2 class="mr-1 h-4 w-4" />{/if}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -3425,7 +3425,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"backup"
|
"backup"
|
||||||
],
|
],
|
||||||
"summary": "Delete a backup configuration and unregister its schedule",
|
"summary": "Delete a backup configuration and unregister its schedule, optionally forgetting its snapshots too",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -3435,11 +3435,20 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"description": "Backup configuration id (from GET /api/backup/configs)"
|
"description": "Backup configuration id (from GET /api/backup/configs)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleteSnapshots",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"description": "Also forget and prune this config's snapshots (default false; snapshots survive so a restore is still possible)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Returns { success: true } once the configuration is deleted"
|
"description": "Returns { success: true }, plus { snapshots: { deleted, skipped, error? } } when deleteSnapshots=true"
|
||||||
},
|
},
|
||||||
"409": {
|
"409": {
|
||||||
"description": "A backup is currently running for this config — stop it before deleting"
|
"description": "A backup is currently running for this config — stop it before deleting"
|
||||||
@@ -3453,7 +3462,7 @@
|
|||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards."
|
"description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards. With deleteSnapshots=true the config's snapshots are forgotten and pruned (best-effort; reported in the response, never blocks the delete)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/backup/configs/{id}/run": {
|
"/api/backup/configs/{id}/run": {
|
||||||
@@ -4779,6 +4788,68 @@
|
|||||||
"description": "Job-polled so a proxy can't abort the restic dump at ~15s. The layout is redacted (stack secrets and container Config.Env/Labels stripped) before it leaves the server."
|
"description": "Job-polled so a proxy can't abort the restic dump at ~15s. The layout is redacted (stack secrets and container Config.Env/Labels stripped) before it leaves the server."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/backup/snapshots/batch-delete": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_api_backup_snapshots_batch-delete",
|
||||||
|
"tags": [
|
||||||
|
"backup"
|
||||||
|
],
|
||||||
|
"summary": "Forget and prune several snapshots from one destination in a single restic forget --prune, with per-snapshot ownership and environment access gates",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Returns { success: true, deleted: string[], skipped: string[] } - deleted were forgotten, skipped were not owned or not accessible"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Missing/invalid destinationId, empty snapshotIds, or an invalid snapshot id"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Failed to forget the snapshots (restic error)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard. Per-snapshot ownership/environment failures are skipped (returned in `skipped`), not 403d.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"destinationId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"snapshotIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"destinationId",
|
||||||
|
"snapshotIds"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"destinationId": 3,
|
||||||
|
"snapshotIds": [
|
||||||
|
"1a2b3c4d",
|
||||||
|
"5e6f7a8b"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/backup/snapshots/diff": {
|
"/api/backup/snapshots/diff": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "get_api_backup_snapshots_diff",
|
"operationId": "get_api_backup_snapshots_diff",
|
||||||
@@ -23897,6 +23968,12 @@
|
|||||||
},
|
},
|
||||||
"useSelfhstIcons": {
|
"useSelfhstIcons": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"stackLogOperations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -23955,7 +24032,10 @@
|
|||||||
"showWhatsNew": true,
|
"showWhatsNew": true,
|
||||||
"terminalFont": "string",
|
"terminalFont": "string",
|
||||||
"timeFormat": "string",
|
"timeFormat": "string",
|
||||||
"useSelfhstIcons": true
|
"useSelfhstIcons": true,
|
||||||
|
"stackLogOperations": [
|
||||||
|
"string"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { RestoreService, type RestorePorts, type RestoreJob } from './restore-se
|
|||||||
import { openOperation } from './operations';
|
import { openOperation } from './operations';
|
||||||
import { LiveTargetLocks, DestinationSerializer } from './locks';
|
import { LiveTargetLocks, DestinationSerializer } from './locks';
|
||||||
import { resolveTargets, discoverVolumes, stopForBackup } from './docker';
|
import { resolveTargets, discoverVolumes, stopForBackup } from './docker';
|
||||||
import { guardSnapshotAccess, listSnapshots as listSnapshotsCore, resolveSnapshotEnvId as resolveSnapshotEnvIdCore, filterSnapshotsByAccessibleEnv } from './snapshots';
|
import { guardSnapshotAccess, selectOwnedForForget, listSnapshots as listSnapshotsCore, resolveSnapshotEnvId as resolveSnapshotEnvIdCore, filterSnapshotsByAccessibleEnv } from './snapshots';
|
||||||
import { initRepository as initRepoCore, testRepository as testRepoCore, checkRepository, pruneRepository, unlockRepository, repoStats, rotateDestinationPassword as rotateCore } from './repo';
|
import { initRepository as initRepoCore, testRepository as testRepoCore, checkRepository, pruneRepository, unlockRepository, repoStats, rotateDestinationPassword as rotateCore } from './repo';
|
||||||
import { parseRetention, buildForgetArgs, checkWouldWipe } from './retention';
|
import { parseRetention, buildForgetArgs, checkWouldWipe } from './retention';
|
||||||
import { buildSnapshotLayout, serializeLayout, parseSnapshotLayout, type SnapshotLayout, type SnapshotStack, type SnapshotSecret } from './snapshot-layout';
|
import { buildSnapshotLayout, serializeLayout, parseSnapshotLayout, type SnapshotLayout, type SnapshotStack, type SnapshotSecret } from './snapshot-layout';
|
||||||
@@ -1334,6 +1334,75 @@ export async function forgetSnapshot(destinationId: number, snapshotId: string):
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ForgetSnapshotsResult = {
|
||||||
|
/** Snapshot ids that were forgotten (owned + passed to restic). */
|
||||||
|
deleted: string[];
|
||||||
|
/** Ids we refused: not owned by this instance or not env-accessible (never sent to restic). */
|
||||||
|
skipped: string[];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-id vetting for forgetSnapshots. `access` carries the enterprise env gate so the
|
||||||
|
* ONE ownership listing per id also covers env access - no separate per-id env round-trip
|
||||||
|
* at the route. `preVetted` skips the per-id guard when the caller has already proven
|
||||||
|
* instance ownership + env access for every id via the SAME instance-scoped restic listing
|
||||||
|
* (e.g. a `--tag instance,configid` config listing), which is a strictly stronger proof. */
|
||||||
|
export type ForgetSnapshotsOptions = {
|
||||||
|
access?: { isEnterprise: boolean; canAccessEnvironment: (envId: number) => Promise<boolean> };
|
||||||
|
preVetted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FULL_ACCESS = { isEnterprise: false, canAccessEnvironment: async () => true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget MANY snapshots from ONE destination in a single `restic forget ... --prune`
|
||||||
|
* (prunes once, not per-id). Every id is instance-ownership- and (enterprise) env-access-
|
||||||
|
* checked first via ONE restic listing per id; ids that fail are skipped (never passed to
|
||||||
|
* restic) so a bulk delete can't reach another instance's or another env's snapshots
|
||||||
|
* sharing the repo. Pass `preVetted` only when the ids came from an instance-scoped
|
||||||
|
* listing that already proved ownership + access. All owned ids share the one lock.
|
||||||
|
*/
|
||||||
|
export async function forgetSnapshots(
|
||||||
|
destinationId: number,
|
||||||
|
snapshotIds: string[],
|
||||||
|
options: ForgetSnapshotsOptions = {},
|
||||||
|
): Promise<ForgetSnapshotsResult> {
|
||||||
|
const destination = await loadDest(destinationId);
|
||||||
|
const instanceId = await getInstanceId();
|
||||||
|
const access = options.access ?? FULL_ACCESS;
|
||||||
|
|
||||||
|
const { owned, skipped } = await selectOwnedForForget(snapshotIds, {
|
||||||
|
preVetted: options.preVetted,
|
||||||
|
guard: (id) => guardSnapshotAccess(reader(), destination, instanceId, id, access),
|
||||||
|
});
|
||||||
|
if (owned.length === 0) return { deleted: [], skipped };
|
||||||
|
|
||||||
|
const run = await serializeByRepo(destinationId, () =>
|
||||||
|
restic.runLocal(destination, ['forget', ...owned, '--prune', '--retry-lock', '5m'], 'data'),
|
||||||
|
);
|
||||||
|
logRepoOp(destination.name, `forget ${owned.length} snapshot(s)`, run.exitCode === 0, { output: run.stdout, error: run.stderr });
|
||||||
|
if (run.exitCode !== 0) return { deleted: [], skipped, error: run.stderr.trim() || 'forget failed' };
|
||||||
|
return { deleted: owned, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget every snapshot belonging to a backup config (matched by its `dockhand:configid`
|
||||||
|
* tag) from its destination, in a single restic forget --prune. Used when a config is
|
||||||
|
* deleted WITH "also delete snapshots". Best-effort: returns the tally; a restic error is
|
||||||
|
* reported, never thrown, so it can't fail the config deletion that already happened.
|
||||||
|
* The listing is `--tag instance,configid` scoped, a stronger ownership proof than the
|
||||||
|
* per-id guard, so the ids are forgotten preVetted (no redundant per-id re-listing).
|
||||||
|
* CALLER CONTRACT: `preVetted` suppresses the per-snapshot env guard, so the caller MUST
|
||||||
|
* gate on the config's environment first. The DELETE config route does (loadConfigGateEnv),
|
||||||
|
* and every id here shares that already-gated config's env - keep this true for any new caller.
|
||||||
|
*/
|
||||||
|
export async function forgetSnapshotsForConfig(configId: number, destinationId: number): Promise<ForgetSnapshotsResult> {
|
||||||
|
const snaps = await listSnapshots(destinationId, configId).catch(() => []);
|
||||||
|
const ids = snaps.map((s) => s.id);
|
||||||
|
if (ids.length === 0) return { deleted: [], skipped: [] };
|
||||||
|
return forgetSnapshots(destinationId, ids, { preVetted: true });
|
||||||
|
}
|
||||||
|
|
||||||
// --- repository maintenance ---
|
// --- repository maintenance ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -159,6 +159,36 @@ export async function filterSnapshotsByAccessibleEnv<T extends { tags: string[]
|
|||||||
return kept;
|
return kept;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a bulk-delete's ids into the ones to forget vs. the ones to skip.
|
||||||
|
*
|
||||||
|
* `preVetted` is the contract behind the bulk/config-delete perf path: the caller has
|
||||||
|
* ALREADY proven instance ownership + env access for every id via an instance-scoped
|
||||||
|
* restic listing (the batch route's `resolveSnapshotEnvId`, or the config listing's
|
||||||
|
* `--tag instance,configid`), which is a strictly stronger proof than the per-id guard.
|
||||||
|
* So preVetted forwards the ids as-is and NEVER calls `guard`. Without it, each id is
|
||||||
|
* guarded here and a failing one is skipped (never forgotten) - the only path that lets
|
||||||
|
* a not-owned / not-accessible id reach restic is a caller wrongly asserting preVetted.
|
||||||
|
* Pure over the injected `guard` so the branch is unit-testable without a repo.
|
||||||
|
*/
|
||||||
|
export async function selectOwnedForForget(
|
||||||
|
snapshotIds: string[],
|
||||||
|
opts: { preVetted?: boolean; guard: (id: string) => Promise<void> },
|
||||||
|
): Promise<{ owned: string[]; skipped: string[] }> {
|
||||||
|
if (opts.preVetted) return { owned: snapshotIds, skipped: [] };
|
||||||
|
const owned: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
for (const id of snapshotIds) {
|
||||||
|
try {
|
||||||
|
await opts.guard(id);
|
||||||
|
owned.push(id);
|
||||||
|
} catch {
|
||||||
|
skipped.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { owned, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List this installation's snapshots in a repository (optionally scoped to one
|
* List this installation's snapshots in a repository (optionally scoped to one
|
||||||
* config). Enterprise callers only see snapshots whose env they can access.
|
* config). Enterprise callers only see snapshots whose env they can access.
|
||||||
|
|||||||
@@ -434,3 +434,66 @@ export function selectOwnSnapshotsFromDestination<S extends { tags?: string[] }>
|
|||||||
.filter((s) => snapshotMatchesConfigScope(s.tags, configIds, targetName))
|
.filter((s) => snapshotMatchesConfigScope(s.tags, configIds, targetName))
|
||||||
.map((s) => ({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository }));
|
.map((s) => ({ ...s, _destinationId: dest.id, _destinationName: dest.name, _destinationRepository: dest.repository }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group snapshots by their destination id, returning `{ destinationId, snapshotIds }`
|
||||||
|
* batches. Bulk delete needs this because `restic forget` runs against ONE repo at a
|
||||||
|
* time, so a selection spanning multiple destinations becomes one batch call per dest.
|
||||||
|
* Pure so the grouping is unit-tested independently of the UI.
|
||||||
|
*/
|
||||||
|
export function groupSnapshotIdsByDestination<S extends { id: string; _destinationId: number }>(
|
||||||
|
snaps: S[]
|
||||||
|
): { destinationId: number; snapshotIds: string[] }[] {
|
||||||
|
const byDest = new Map<number, string[]>();
|
||||||
|
for (const s of snaps) {
|
||||||
|
const arr = byDest.get(s._destinationId) ?? [];
|
||||||
|
arr.push(s.id);
|
||||||
|
byDest.set(s._destinationId, arr);
|
||||||
|
}
|
||||||
|
return [...byDest.entries()].map(([destinationId, snapshotIds]) => ({ destinationId, snapshotIds }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkDeleteResult {
|
||||||
|
deleted: number;
|
||||||
|
skipped: number;
|
||||||
|
/** true if any destination's batch call returned a non-2xx. */
|
||||||
|
failed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete many snapshots via the batch endpoint, grouped by destination (one
|
||||||
|
* `restic forget --prune` per repo). Shared by every snapshot list (containers/stacks
|
||||||
|
* backup modal, the Backups page, the Settings repo browser) so the delete logic is one
|
||||||
|
* place. Callers own the UI (toasts, reload); this only performs the calls and tallies.
|
||||||
|
*/
|
||||||
|
export async function bulkDeleteSnapshots(
|
||||||
|
targets: { id: string; _destinationId: number }[]
|
||||||
|
): Promise<BulkDeleteResult> {
|
||||||
|
let deleted = 0, skipped = 0, failed = false;
|
||||||
|
for (const { destinationId, snapshotIds } of groupSnapshotIdsByDestination(targets)) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/backup/snapshots/batch-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ destinationId, snapshotIds })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
deleted += data.deleted?.length ?? 0;
|
||||||
|
if (res.ok) skipped += data.skipped?.length ?? 0;
|
||||||
|
else failed = true;
|
||||||
|
} catch {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { deleted, skipped, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consistent toast wording for a bulk-delete result, shared by every snapshot list.
|
||||||
|
* Returns { type, message } so each caller fires its own toast (import stays local).
|
||||||
|
*/
|
||||||
|
export function bulkDeleteToast(r: BulkDeleteResult): { type: 'success' | 'error'; message: string } {
|
||||||
|
if (r.failed) return { type: 'error', message: r.deleted > 0 ? `Deleted ${r.deleted}, but some snapshots could not be deleted` : 'Snapshots could not be deleted' };
|
||||||
|
if (r.deleted === 0 && r.skipped > 0) return { type: 'error', message: `${r.skipped} snapshot${r.skipped === 1 ? '' : 's'} could not be deleted (not owned or no access)` };
|
||||||
|
return { type: 'success', message: `Deleted ${r.deleted} snapshot${r.deleted === 1 ? '' : 's'}${r.skipped > 0 ? ` (${r.skipped} skipped)` : ''}` };
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
deleteBackupConfig
|
deleteBackupConfig
|
||||||
} from '$lib/server/db';
|
} from '$lib/server/db';
|
||||||
import { registerSchedule, unregisterSchedule, isValidCron } from '$lib/server/scheduler';
|
import { registerSchedule, unregisterSchedule, isValidCron } from '$lib/server/scheduler';
|
||||||
import { isBackupRunning } from '$lib/server/backups';
|
import { isBackupRunning, forgetSnapshotsForConfig } from '$lib/server/backups';
|
||||||
import { validateRetention, resolveRetentionForUpdate, resolveEnabledOnScheduleChange } from '$lib/server/backups/helpers';
|
import { validateRetention, resolveRetentionForUpdate, resolveEnabledOnScheduleChange } from '$lib/server/backups/helpers';
|
||||||
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
|
import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards';
|
||||||
|
|
||||||
@@ -128,15 +128,16 @@ export const PUT: RequestHandler = async (event) => {
|
|||||||
* DELETE /api/backup/configs/{id} - Delete a backup configuration
|
* DELETE /api/backup/configs/{id} - Delete a backup configuration
|
||||||
*
|
*
|
||||||
* @openapi
|
* @openapi
|
||||||
* summary: Delete a backup configuration and unregister its schedule
|
* summary: Delete a backup configuration and unregister its schedule, optionally forgetting its snapshots too
|
||||||
* description: Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards.
|
* description: Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards. With deleteSnapshots=true the config's snapshots are forgotten and pruned (best-effort; reported in the response, never blocks the delete).
|
||||||
* path: id:integer! Backup configuration id (from GET /api/backup/configs)
|
* path: id:integer! Backup configuration id (from GET /api/backup/configs)
|
||||||
* resp-200: Returns { success: true } once the configuration is deleted
|
* query: deleteSnapshots:boolean Also forget and prune this config's snapshots (default false; snapshots survive so a restore is still possible)
|
||||||
* resp-200-example: {"success":true}
|
* resp-200: Returns { success: true }, plus { snapshots: { deleted, skipped, error? } } when deleteSnapshots=true
|
||||||
|
* resp-200-example: {"success":true,"snapshots":{"deleted":3,"skipped":0}}
|
||||||
* resp-409: A backup is currently running for this config — stop it before deleting
|
* resp-409: A backup is currently running for this config — stop it before deleting
|
||||||
*/
|
*/
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
const { params, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
const denied = await requireBackups(auth, 'manage');
|
const denied = await requireBackups(auth, 'manage');
|
||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
@@ -153,10 +154,20 @@ export const DELETE: RequestHandler = async (event) => {
|
|||||||
return json({ error: 'A backup is currently running for this config — stop it before deleting' }, { status: 409 });
|
return json({ error: 'A backup is currently running for this config — stop it before deleting' }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optionally forget this config's snapshots too (opt-in; snapshots survive by default so
|
||||||
|
// a restore is still possible after removing the schedule). Do it BEFORE deleting the row
|
||||||
|
// while destinationId is still known; matched by the snapshots' own dockhand:configid tag.
|
||||||
|
// Best-effort: a restic failure is reported in the response but never blocks the delete.
|
||||||
|
let snapshotsResult: { deleted: number; skipped: number; error?: string } | undefined;
|
||||||
|
if (url.searchParams.get('deleteSnapshots') === 'true') {
|
||||||
|
const r = await forgetSnapshotsForConfig(id, existing.destinationId).catch((e) => ({ deleted: [], skipped: [], error: e instanceof Error ? e.message : String(e) }));
|
||||||
|
snapshotsResult = { deleted: r.deleted.length, skipped: r.skipped.length, ...(r.error ? { error: r.error } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
// Unregister schedule before deleting
|
// Unregister schedule before deleting
|
||||||
unregisterSchedule(id, 'backup');
|
unregisterSchedule(id, 'backup');
|
||||||
|
|
||||||
await deleteBackupConfig(id);
|
await deleteBackupConfig(id);
|
||||||
await auditBackup(event, 'delete', existing.targetName, existing.environmentId, { configId: id });
|
await auditBackup(event, 'delete', existing.targetName, existing.environmentId, { configId: id, ...(snapshotsResult ? { snapshotsDeleted: snapshotsResult.deleted, snapshotsSkipped: snapshotsResult.skipped } : {}) });
|
||||||
return json({ success: true });
|
return json({ success: true, ...(snapshotsResult ? { snapshots: snapshotsResult } : {}) });
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { validateSnapshotId } from '$lib/server/docker-validation';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { requireBackups, guardSnapshotEnvAccess } from '$lib/server/backups/route-guards';
|
||||||
|
import { auditBackup } from '$lib/server/audit';
|
||||||
|
import { getBackupDestination } from '$lib/server/db';
|
||||||
|
import { forgetSnapshots } from '$lib/server/backups';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/backup/snapshots/batch-delete - Forget many snapshots from ONE destination
|
||||||
|
* in a single restic forget --prune (prunes once). Each id is ownership- and (enterprise)
|
||||||
|
* environment-access-checked; ids that fail a check are skipped, never forgotten.
|
||||||
|
*
|
||||||
|
* @openapi
|
||||||
|
* summary: Forget and prune several snapshots from one destination in a single restic forget --prune, with per-snapshot ownership and environment access gates
|
||||||
|
* description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. Per-snapshot ownership/environment failures are skipped (returned in `skipped`), not 403d.
|
||||||
|
* body: {destinationId:integer!, snapshotIds:array<string>!}
|
||||||
|
* body-example: {"destinationId":3,"snapshotIds":["1a2b3c4d","5e6f7a8b"]}
|
||||||
|
* resp-200: Returns { success: true, deleted: string[], skipped: string[] } - deleted were forgotten, skipped were not owned or not accessible
|
||||||
|
* resp-200-example: {"success":true,"deleted":["1a2b3c4d"],"skipped":["5e6f7a8b"]}
|
||||||
|
* resp-400: Missing/invalid destinationId, empty snapshotIds, or an invalid snapshot id
|
||||||
|
* resp-500: Failed to forget the snapshots (restic error)
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
const { request, cookies } = event;
|
||||||
|
const auth = await authorize(cookies);
|
||||||
|
const rbacDenied = await requireBackups(auth, 'manage');
|
||||||
|
if (rbacDenied) return rbacDenied;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({})) as { destinationId?: number; snapshotIds?: string[] };
|
||||||
|
const destinationId = Number(body.destinationId);
|
||||||
|
if (!Number.isInteger(destinationId) || destinationId <= 0) {
|
||||||
|
return json({ error: 'destinationId is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const snapshotIds = Array.isArray(body.snapshotIds) ? body.snapshotIds : [];
|
||||||
|
if (snapshotIds.length === 0) {
|
||||||
|
return json({ error: 'snapshotIds must be a non-empty array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
for (const id of snapshotIds) {
|
||||||
|
const invalid = validateSnapshotId(id);
|
||||||
|
if (invalid) return invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-authoritative env gate per snapshot (same as the single-delete route): resolve
|
||||||
|
// each snapshot's owning env from its own tag and drop the ones the caller can't access.
|
||||||
|
// In enterprise this resolve lists the snapshot instance-scoped, which ALSO proves this
|
||||||
|
// instance owns it, so the accessible ids are already vetted (ownership + env) and are
|
||||||
|
// forgotten preVetted - no second per-id ownership listing. In free edition the guard is
|
||||||
|
// a no-op (no listing), so the engine must do the ownership check itself (preVetted=false).
|
||||||
|
const accessible: string[] = [];
|
||||||
|
const skippedByEnv: string[] = [];
|
||||||
|
for (const id of snapshotIds) {
|
||||||
|
const denied = await guardSnapshotEnvAccess(auth, destinationId, id);
|
||||||
|
if (denied) skippedByEnv.push(id);
|
||||||
|
else accessible.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = accessible.length > 0
|
||||||
|
? await forgetSnapshots(destinationId, accessible, { preVetted: auth.isEnterprise })
|
||||||
|
: { deleted: [], skipped: [] as string[] };
|
||||||
|
const skipped = [...result.skipped, ...skippedByEnv];
|
||||||
|
const dest = await getBackupDestination(destinationId);
|
||||||
|
await auditBackup(event, 'delete', dest?.name ?? `destination#${destinationId}`, null, {
|
||||||
|
destinationId, kind: 'snapshot', deleted: result.deleted, skipped, ...(result.error ? { failed: true, reason: result.error } : {})
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
return json({ error: result.error, deleted: result.deleted, skipped }, { status: 500 });
|
||||||
|
}
|
||||||
|
return json({ success: true, deleted: result.deleted, skipped });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return json({ error: errorMsg }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -26,7 +26,9 @@
|
|||||||
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
||||||
import { currentEnvironment } from '$lib/stores/environment';
|
import { currentEnvironment } from '$lib/stores/environment';
|
||||||
import { watchJob, readJobResponse } from '$lib/utils/sse-fetch';
|
import { watchJob, readJobResponse } from '$lib/utils/sse-fetch';
|
||||||
import { getRepoTypeIcon, formatCron, retentionSummary, classifyJobResult, tagLogLine } from '$lib/utils/backup';
|
import { getRepoTypeIcon, formatCron, retentionSummary, classifyJobResult, tagLogLine, bulkDeleteSnapshots, bulkDeleteToast } from '$lib/utils/backup';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
|
||||||
import SnapshotBrowser from '../containers/SnapshotBrowser.svelte';
|
import SnapshotBrowser from '../containers/SnapshotBrowser.svelte';
|
||||||
import RestoreModal from '../containers/RestoreModal.svelte';
|
import RestoreModal from '../containers/RestoreModal.svelte';
|
||||||
import BackupLogModal from './BackupLogModal.svelte';
|
import BackupLogModal from './BackupLogModal.svelte';
|
||||||
@@ -105,6 +107,50 @@
|
|||||||
const SNAPSHOT_PAGE_SIZE = 10;
|
const SNAPSHOT_PAGE_SIZE = 10;
|
||||||
let snapshotLimits = $state<Map<string, number>>(new Map());
|
let snapshotLimits = $state<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
// Multi-select for bulk delete (shared helper + dialog). One global Set of snapshot ids;
|
||||||
|
// each expanded config renders its own checkboxes and bulk bar over its own snapshots.
|
||||||
|
let selectedSnapshots = $state<Set<string>>(new Set());
|
||||||
|
let bulkDialogOpen = $state(false);
|
||||||
|
let bulkDeleting = $state(false);
|
||||||
|
let bulkConfig = $state<BackupConfig | null>(null);
|
||||||
|
// Snapshots of a config, normalized to {id, _destinationId} for the shared helper.
|
||||||
|
function configSelectedTargets(config: BackupConfig) {
|
||||||
|
return (snapshotsMap.get(config.key) || [])
|
||||||
|
.filter((s) => selectedSnapshots.has(s.id))
|
||||||
|
.map((s) => ({ id: s.id, _destinationId: s._destinationId ?? config.destinationId }));
|
||||||
|
}
|
||||||
|
function toggleSnapshotSel(id: string) {
|
||||||
|
const next = new Set(selectedSnapshots);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
selectedSnapshots = next;
|
||||||
|
}
|
||||||
|
function toggleConfigAll(config: BackupConfig) {
|
||||||
|
const snaps = snapshotsMap.get(config.key) || [];
|
||||||
|
const all = snaps.length > 0 && snaps.every((s) => selectedSnapshots.has(s.id));
|
||||||
|
const next = new Set(selectedSnapshots);
|
||||||
|
if (all) for (const s of snaps) next.delete(s.id);
|
||||||
|
else for (const s of snaps) next.add(s.id);
|
||||||
|
selectedSnapshots = next;
|
||||||
|
}
|
||||||
|
function openConfigBulk(config: BackupConfig) { bulkConfig = config; bulkDialogOpen = true; }
|
||||||
|
async function bulkDeleteConfig() {
|
||||||
|
const config = bulkConfig;
|
||||||
|
if (!config) return;
|
||||||
|
bulkDeleting = true;
|
||||||
|
try {
|
||||||
|
const result = await bulkDeleteSnapshots(configSelectedTargets(config));
|
||||||
|
const t = bulkDeleteToast(result);
|
||||||
|
toast[t.type](t.message);
|
||||||
|
selectedSnapshots = new Set();
|
||||||
|
await loadSnapshots(config);
|
||||||
|
const loaded = snapshotsMap.get(config.key);
|
||||||
|
if (loaded !== undefined) snapshotCounts = new Map(snapshotCounts).set(config.key, loaded.length);
|
||||||
|
} finally {
|
||||||
|
bulkDeleting = false;
|
||||||
|
bulkDialogOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -160,6 +206,8 @@
|
|||||||
let deletingSnapshot = $state<string | null>(null);
|
let deletingSnapshot = $state<string | null>(null);
|
||||||
let confirmDeleteConfig = $state<number | null>(null);
|
let confirmDeleteConfig = $state<number | null>(null);
|
||||||
let deletingConfig = $state<number | null>(null);
|
let deletingConfig = $state<number | null>(null);
|
||||||
|
// Opt-in: also forget the config's snapshots on delete (off by default; resets per open).
|
||||||
|
let deleteConfigSnapshots = $state(false);
|
||||||
let togglingConfig = $state<number | null>(null);
|
let togglingConfig = $state<number | null>(null);
|
||||||
// Edit-config modal (reuses the container/stack Backups tab — BackupPanel).
|
// Edit-config modal (reuses the container/stack Backups tab — BackupPanel).
|
||||||
let editModalOpen = $state(false);
|
let editModalOpen = $state(false);
|
||||||
@@ -596,10 +644,13 @@
|
|||||||
// orphan if any snapshots remain.
|
// orphan if any snapshots remain.
|
||||||
async function deleteConfig(config: BackupConfig) {
|
async function deleteConfig(config: BackupConfig) {
|
||||||
deletingConfig = config.id;
|
deletingConfig = config.id;
|
||||||
|
const withSnaps = deleteConfigSnapshots;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/backup/configs/${config.id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/backup/configs/${config.id}${withSnaps ? '?deleteSnapshots=true' : ''}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success(`Backup config removed for ${config.targetName}`);
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const n = data.snapshots?.deleted ?? 0;
|
||||||
|
toast.success(`Backup config removed for ${config.targetName}${withSnaps ? ` (${n} snapshot${n === 1 ? '' : 's'} deleted)` : ''}`);
|
||||||
fetchData();
|
fetchData();
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -610,6 +661,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
deletingConfig = null;
|
deletingConfig = null;
|
||||||
confirmDeleteConfig = null;
|
confirmDeleteConfig = null;
|
||||||
|
deleteConfigSnapshots = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,10 +981,10 @@
|
|||||||
action="Delete"
|
action="Delete"
|
||||||
itemType="backup config"
|
itemType="backup config"
|
||||||
itemName={config.targetName}
|
itemName={config.targetName}
|
||||||
title="Existing snapshots are kept."
|
title={deleteConfigSnapshots ? 'Snapshots will be deleted too.' : 'Existing snapshots are kept.'}
|
||||||
position="left"
|
position="left"
|
||||||
onConfirm={() => deleteConfig(config)}
|
onConfirm={() => deleteConfig(config)}
|
||||||
onOpenChange={(open) => confirmDeleteConfig = open ? config.id : null}
|
onOpenChange={(open) => { confirmDeleteConfig = open ? config.id : null; if (open) deleteConfigSnapshots = false; }}
|
||||||
>
|
>
|
||||||
{#snippet children({ open })}
|
{#snippet children({ open })}
|
||||||
{#if deletingConfig === config.id}
|
{#if deletingConfig === config.id}
|
||||||
@@ -941,6 +993,12 @@
|
|||||||
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
{#snippet extraContent()}
|
||||||
|
<label class="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Checkbox bind:checked={deleteConfigSnapshots} aria-label="Also delete snapshots" />
|
||||||
|
Also delete this config's snapshots
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
</ConfirmPopover>
|
</ConfirmPopover>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -970,6 +1028,12 @@
|
|||||||
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" onclick={() => loadSnapshots(config)} disabled={isLoading}>
|
<button type="button" class="p-0.5 rounded hover:bg-muted transition-colors" onclick={() => loadSnapshots(config)} disabled={isLoading}>
|
||||||
<RefreshCw class="w-2.5 h-2.5 text-muted-foreground {isLoading ? 'animate-spin' : ''}" />
|
<RefreshCw class="w-2.5 h-2.5 text-muted-foreground {isLoading ? 'animate-spin' : ''}" />
|
||||||
</button>
|
</button>
|
||||||
|
{#if snapshots.some((s) => selectedSnapshots.has(s.id))}
|
||||||
|
{@const n = snapshots.filter((s) => selectedSnapshots.has(s.id)).length}
|
||||||
|
<button type="button" class="ml-2 inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2 py-0.5 text-xs text-destructive transition-colors hover:bg-destructive/10" onclick={() => openConfigBulk(config)}>
|
||||||
|
<Trash2 class="h-3 w-3" /> Delete selected ({n})
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isLoading && snapshots.length === 0}
|
{#if isLoading && snapshots.length === 0}
|
||||||
<!-- First load only: a quiet inline spinner, no skeleton. On a refresh
|
<!-- First load only: a quiet inline spinner, no skeleton. On a refresh
|
||||||
@@ -986,6 +1050,14 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead class="sticky top-0 bg-background z-10">
|
<thead class="sticky top-0 bg-background z-10">
|
||||||
<tr class="text-xs text-muted-foreground border-b">
|
<tr class="text-xs text-muted-foreground border-b">
|
||||||
|
<th class="w-8 py-1.5" style="padding-left:8px">
|
||||||
|
<Checkbox
|
||||||
|
checked={snapshots.length > 0 && snapshots.every((s) => selectedSnapshots.has(s.id))}
|
||||||
|
indeterminate={snapshots.some((s) => selectedSnapshots.has(s.id)) && !snapshots.every((s) => selectedSnapshots.has(s.id))}
|
||||||
|
onCheckedChange={() => toggleConfigAll(config)}
|
||||||
|
aria-label="Select all snapshots"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th class="text-left py-1.5 w-24" style="padding-left:8px">ID</th>
|
<th class="text-left py-1.5 w-24" style="padding-left:8px">ID</th>
|
||||||
<th class="text-left py-1.5 w-40" style="padding-left:8px">Created</th>
|
<th class="text-left py-1.5 w-40" style="padding-left:8px">Created</th>
|
||||||
<th class="text-left py-1.5 w-64" style="padding-left:8px">Stats</th>
|
<th class="text-left py-1.5 w-64" style="padding-left:8px">Stats</th>
|
||||||
@@ -997,7 +1069,10 @@
|
|||||||
{#each snapshots.slice(0, snapshotLimits.get(key) || SNAPSHOT_PAGE_SIZE) as snapshot}
|
{#each snapshots.slice(0, snapshotLimits.get(key) || SNAPSHOT_PAGE_SIZE) as snapshot}
|
||||||
{@const stats = snapshotStats.get(snapshot.id)}
|
{@const stats = snapshotStats.get(snapshot.id)}
|
||||||
{@const isDiffPending = diffPending?.snapshot.id === snapshot.id}
|
{@const isDiffPending = diffPending?.snapshot.id === snapshot.id}
|
||||||
<tr class="border-b last:border-0 hover:bg-muted/30 text-xs {isDiffPending ? 'bg-primary/10' : ''}">
|
<tr class="border-b last:border-0 hover:bg-muted/30 text-xs {isDiffPending ? 'bg-primary/10' : ''} {selectedSnapshots.has(snapshot.id) ? 'bg-primary/5' : ''}">
|
||||||
|
<td class="py-1.5" style="padding-left:8px">
|
||||||
|
<Checkbox checked={selectedSnapshots.has(snapshot.id)} onCheckedChange={() => toggleSnapshotSel(snapshot.id)} aria-label="Select snapshot {snapshot.shortId}" />
|
||||||
|
</td>
|
||||||
<td class="py-1.5 font-mono text-muted-foreground" style="padding-left:8px">{snapshot.shortId}</td>
|
<td class="py-1.5 font-mono text-muted-foreground" style="padding-left:8px">{snapshot.shortId}</td>
|
||||||
<td class="py-1.5" style="padding-left:8px">{formatDateTime(snapshot.time)} <span class="text-muted-foreground opacity-60">({formatRelativeTime(snapshot.time)})</span></td>
|
<td class="py-1.5" style="padding-left:8px">{formatDateTime(snapshot.time)} <span class="text-muted-foreground opacity-60">({formatRelativeTime(snapshot.time)})</span></td>
|
||||||
<td class="py-1.5 text-muted-foreground" style="padding-left:8px">
|
<td class="py-1.5 text-muted-foreground" style="padding-left:8px">
|
||||||
@@ -1070,6 +1145,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={bulkConfig ? configSelectedTargets(bulkConfig).length : 0} busy={bulkDeleting} onConfirm={bulkDeleteConfig} />
|
||||||
|
|
||||||
<SnapshotBrowser
|
<SnapshotBrowser
|
||||||
bind:open={showBrowser}
|
bind:open={showBrowser}
|
||||||
destinationId={browserDestId}
|
destinationId={browserDestId}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import { formatDateTime } from '$lib/stores/settings';
|
import { formatDateTime } from '$lib/stores/settings';
|
||||||
import { watchJob } from '$lib/utils/sse-fetch';
|
import { watchJob } from '$lib/utils/sse-fetch';
|
||||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { getRepoTypeIcon, parseRetention, parseOptions, retentionSummary as getRetentionSummary, formatCron, runBackupAction, classifyJobResult, tagLogLine, fetchBackupExecutions, type BackupAction, type BackupFormState } from '$lib/utils/backup';
|
import { getRepoTypeIcon, parseRetention, parseOptions, retentionSummary as getRetentionSummary, formatCron, runBackupAction, classifyJobResult, tagLogLine, fetchBackupExecutions, type BackupAction, type BackupFormState } from '$lib/utils/backup';
|
||||||
import { reconcileSelectedVolumeKeys } from '$lib/utils/mounts';
|
import { reconcileSelectedVolumeKeys } from '$lib/utils/mounts';
|
||||||
|
|
||||||
@@ -90,6 +91,8 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let runningBackup = $state<number | null>(null);
|
let runningBackup = $state<number | null>(null);
|
||||||
let confirmDeleteId = $state<number | null>(null);
|
let confirmDeleteId = $state<number | null>(null);
|
||||||
|
// Opt-in: also forget the config's snapshots on delete (off by default; resets per open).
|
||||||
|
let deleteConfigSnapshots = $state(false);
|
||||||
|
|
||||||
// Edit form state
|
// Edit form state
|
||||||
let editingConfig = $state<BackupConfig | null>(null);
|
let editingConfig = $state<BackupConfig | null>(null);
|
||||||
@@ -393,10 +396,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteConfig(id: number) {
|
async function deleteConfig(id: number) {
|
||||||
|
const withSnaps = deleteConfigSnapshots;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/backup/configs/${id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/backup/configs/${id}${withSnaps ? '?deleteSnapshots=true' : ''}`, { method: 'DELETE' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('Backup schedule removed');
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const n = data.snapshots?.deleted ?? 0;
|
||||||
|
toast.success(`Backup schedule removed${withSnaps ? ` (${n} snapshot${n === 1 ? '' : 's'} deleted)` : ''}`);
|
||||||
fetchConfigs();
|
fetchConfigs();
|
||||||
onConfigSaved?.();
|
onConfigSaved?.();
|
||||||
} else {
|
} else {
|
||||||
@@ -404,6 +410,7 @@
|
|||||||
}
|
}
|
||||||
} catch { toast.error('Failed to delete'); }
|
} catch { toast.error('Failed to delete'); }
|
||||||
confirmDeleteId = null;
|
confirmDeleteId = null;
|
||||||
|
deleteConfigSnapshots = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pause/resume a scheduled backup. A minimal PUT with only `enabled` is a
|
// Pause/resume a scheduled backup. A minimal PUT with only `enabled` is a
|
||||||
@@ -616,14 +623,20 @@
|
|||||||
action="Delete"
|
action="Delete"
|
||||||
itemType="backup schedule"
|
itemType="backup schedule"
|
||||||
itemName={dest?.name || ''}
|
itemName={dest?.name || ''}
|
||||||
title="Remove schedule"
|
title={deleteConfigSnapshots ? 'Snapshots will be deleted too.' : 'Remove schedule (snapshots are kept)'}
|
||||||
position="left"
|
position="left"
|
||||||
onConfirm={() => deleteConfig(cfg.id)}
|
onConfirm={() => deleteConfig(cfg.id)}
|
||||||
onOpenChange={(open) => confirmDeleteId = open ? cfg.id : null}
|
onOpenChange={(open) => { confirmDeleteId = open ? cfg.id : null; if (open) deleteConfigSnapshots = false; }}
|
||||||
>
|
>
|
||||||
{#snippet children({ open })}
|
{#snippet children({ open })}
|
||||||
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
{#snippet extraContent()}
|
||||||
|
<label class="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Checkbox bind:checked={deleteConfigSnapshots} aria-label="Also delete snapshots" />
|
||||||
|
Also delete this config's snapshots
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
</ConfirmPopover>
|
</ConfirmPopover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { Loader2, FolderOpen, RotateCcw, Trash2, ArrowLeftRight, HardDrive, Archive, RefreshCw } from 'lucide-svelte';
|
import { Loader2, FolderOpen, RotateCcw, Trash2, ArrowLeftRight, HardDrive, Archive, RefreshCw } from 'lucide-svelte';
|
||||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { LoadingState } from '$lib/components/ui/loading-state';
|
import { LoadingState } from '$lib/components/ui/loading-state';
|
||||||
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { readJobResponse } from '$lib/utils/sse-fetch';
|
import { readJobResponse } from '$lib/utils/sse-fetch';
|
||||||
import { getRepoTypeIcon, selectOwnSnapshotsFromDestination } from '$lib/utils/backup';
|
import { getRepoTypeIcon, selectOwnSnapshotsFromDestination, bulkDeleteSnapshots, bulkDeleteToast } from '$lib/utils/backup';
|
||||||
|
import BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
|
||||||
import SnapshotBrowser from './SnapshotBrowser.svelte';
|
import SnapshotBrowser from './SnapshotBrowser.svelte';
|
||||||
import RestoreModal from './RestoreModal.svelte';
|
import RestoreModal from './RestoreModal.svelte';
|
||||||
import SnapshotDiffModal from '../backups/SnapshotDiffModal.svelte';
|
import SnapshotDiffModal from '../backups/SnapshotDiffModal.svelte';
|
||||||
@@ -49,6 +51,12 @@
|
|||||||
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);
|
||||||
|
// Multi-select for bulk delete (same Set model as the containers grid), keyed by
|
||||||
|
// snapshot id. Bulk delete groups by destination and calls the batch endpoint.
|
||||||
|
let selectedSnapshots = $state<Set<string>>(new Set());
|
||||||
|
let bulkDeleting = $state(false);
|
||||||
|
let bulkDialogOpen = $state(false);
|
||||||
|
let bulkScope = $state<'selected' | 'all'>('selected');
|
||||||
// snapshotId → { filesNew, filesChanged, dataAdded } from schedule executions.
|
// snapshotId → { filesNew, filesChanged, dataAdded } from schedule executions.
|
||||||
let stats = $state<Map<string, { filesNew: number; filesChanged: number; dataAdded: number }>>(new Map());
|
let stats = $state<Map<string, { filesNew: number; filesChanged: number; dataAdded: number }>>(new Map());
|
||||||
|
|
||||||
@@ -86,6 +94,16 @@
|
|||||||
const visibleSnapshots = $derived(
|
const visibleSnapshots = $derived(
|
||||||
repoFilter == null ? snapshots : snapshots.filter((s) => s._destinationId === repoFilter)
|
repoFilter == null ? snapshots : snapshots.filter((s) => s._destinationId === repoFilter)
|
||||||
);
|
);
|
||||||
|
// Selection is scoped to what's currently visible (respects the repo filter), like the
|
||||||
|
// containers grid's select-all only covering the filtered rows.
|
||||||
|
const allVisibleSelected = $derived(
|
||||||
|
visibleSnapshots.length > 0 && visibleSnapshots.every((s) => selectedSnapshots.has(s.id))
|
||||||
|
);
|
||||||
|
const someVisibleSelected = $derived(
|
||||||
|
visibleSnapshots.some((s) => selectedSnapshots.has(s.id)) && !allVisibleSelected
|
||||||
|
);
|
||||||
|
const selectedCount = $derived(visibleSnapshots.filter((s) => selectedSnapshots.has(s.id)).length);
|
||||||
|
|
||||||
// A dropped repo filter (e.g. after a delete removed the last snapshot of a repo)
|
// A dropped repo filter (e.g. after a delete removed the last snapshot of a repo)
|
||||||
// falls back to "all" so the list never shows an empty filtered view.
|
// falls back to "all" so the list never shows an empty filtered view.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -213,6 +231,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSnapshot(id: string) {
|
||||||
|
const next = new Set(selectedSnapshots);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
selectedSnapshots = next;
|
||||||
|
}
|
||||||
|
function toggleSelectAllVisible() {
|
||||||
|
const next = new Set(selectedSnapshots);
|
||||||
|
if (allVisibleSelected) for (const s of visibleSnapshots) next.delete(s.id);
|
||||||
|
else for (const s of visibleSnapshots) next.add(s.id);
|
||||||
|
selectedSnapshots = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk delete via the shared helper (groups by destination, one restic forget per repo).
|
||||||
|
// `scope` = the currently-selected snapshots, or ALL visible ones.
|
||||||
|
async function bulkDelete(scope: 'selected' | 'all') {
|
||||||
|
const targets = scope === 'all' ? visibleSnapshots : visibleSnapshots.filter((s) => selectedSnapshots.has(s.id));
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
bulkDeleting = true;
|
||||||
|
try {
|
||||||
|
const result = await bulkDeleteSnapshots(targets);
|
||||||
|
const t = bulkDeleteToast(result);
|
||||||
|
toast[t.type](t.message);
|
||||||
|
selectedSnapshots = new Set();
|
||||||
|
await loadSnapshots();
|
||||||
|
} finally {
|
||||||
|
bulkDeleting = false;
|
||||||
|
bulkDialogOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function openBulk(scope: 'selected' | 'all') { bulkScope = scope; bulkDialogOpen = true; }
|
||||||
|
const bulkCount = $derived(bulkScope === 'all' ? visibleSnapshots.length : selectedCount);
|
||||||
|
|
||||||
// `hasBackups` arrives from the parent AFTER this panel mounts (the parent
|
// `hasBackups` arrives from the parent AFTER this panel mounts (the parent
|
||||||
// fetches its configs async), so it starts false and flips true once configs
|
// fetches its configs async), so it starts false and flips true once configs
|
||||||
// load. A plain onMount would run with hasBackups=false and early-return,
|
// load. A plain onMount would run with hasBackups=false and early-return,
|
||||||
@@ -277,10 +327,27 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Bulk actions: delete the selected snapshots, or all in the current view. -->
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<button type="button" class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10" onclick={() => openBulk('selected')}>
|
||||||
|
<Trash2 class="h-3 w-3" /> Delete selected ({selectedCount})
|
||||||
|
</button>
|
||||||
|
<button type="button" class="text-xs text-muted-foreground hover:text-foreground" onclick={() => (selectedSnapshots = new Set())}>Clear</button>
|
||||||
|
{/if}
|
||||||
|
{#if visibleSnapshots.length > 0}
|
||||||
|
<button type="button" class="ml-auto inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10" onclick={() => openBulk('all')}>
|
||||||
|
<Trash2 class="h-3 w-3" /> Delete all ({visibleSnapshots.length})
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-xs">
|
<table class="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b text-left text-muted-foreground">
|
<tr class="border-b text-left text-muted-foreground">
|
||||||
|
<th class="w-8 py-1.5 pl-2">
|
||||||
|
<Checkbox checked={allVisibleSelected} indeterminate={someVisibleSelected} onCheckedChange={toggleSelectAllVisible} aria-label="Select all snapshots" />
|
||||||
|
</th>
|
||||||
<th class="py-1.5 pl-2 font-medium">Snapshot</th>
|
<th class="py-1.5 pl-2 font-medium">Snapshot</th>
|
||||||
<th class="py-1.5 pl-2 font-medium">Taken</th>
|
<th class="py-1.5 pl-2 font-medium">Taken</th>
|
||||||
<th class="py-1.5 pl-2 font-medium">Added</th>
|
<th class="py-1.5 pl-2 font-medium">Added</th>
|
||||||
@@ -293,7 +360,10 @@
|
|||||||
{@const isDiffPending = diffPending?.id === s.id}
|
{@const isDiffPending = diffPending?.id === s.id}
|
||||||
{@const RepoIcon = getRepoTypeIcon(s._destinationRepository)}
|
{@const RepoIcon = getRepoTypeIcon(s._destinationRepository)}
|
||||||
{@const st = stats.get(s.id)}
|
{@const st = stats.get(s.id)}
|
||||||
<tr class="border-b text-xs last:border-0 hover:bg-muted/30 {isDiffPending ? 'bg-primary/10' : ''}">
|
<tr class="border-b text-xs last:border-0 hover:bg-muted/30 {isDiffPending ? 'bg-primary/10' : ''} {selectedSnapshots.has(s.id) ? 'bg-primary/5' : ''}">
|
||||||
|
<td class="py-1.5 pl-2">
|
||||||
|
<Checkbox checked={selectedSnapshots.has(s.id)} onCheckedChange={() => toggleSnapshot(s.id)} aria-label="Select snapshot {s.shortId}" />
|
||||||
|
</td>
|
||||||
<td class="py-1.5 pl-2 font-mono text-muted-foreground">{s.shortId}</td>
|
<td class="py-1.5 pl-2 font-mono text-muted-foreground">{s.shortId}</td>
|
||||||
<td class="py-1.5 pl-2">{formatDateTime(s.time)} <span class="text-muted-foreground opacity-60">({formatRelativeTime(s.time)})</span></td>
|
<td class="py-1.5 pl-2">{formatDateTime(s.time)} <span class="text-muted-foreground opacity-60">({formatRelativeTime(s.time)})</span></td>
|
||||||
<td class="py-1.5 pl-2 text-muted-foreground" title={st ? `${st.filesNew} new, ${st.filesChanged} changed files` : ''}>{st ? formatBytes(st.dataAdded) : '—'}</td>
|
<td class="py-1.5 pl-2 text-muted-foreground" title={st ? `${st.filesNew} new, ${st.filesChanged} changed files` : ''}>{st ? formatBytes(st.dataAdded) : '—'}</td>
|
||||||
@@ -340,6 +410,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={bulkCount} busy={bulkDeleting} onConfirm={() => bulkDelete(bulkScope)} />
|
||||||
|
|
||||||
<SnapshotBrowser
|
<SnapshotBrowser
|
||||||
bind:open={showBrowser}
|
bind:open={showBrowser}
|
||||||
destinationId={browserDestId}
|
destinationId={browserDestId}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
||||||
import { FolderOpen, Box, Layers, FileStack, Camera, ChevronRight, ChevronDown } from 'lucide-svelte';
|
import { FolderOpen, Box, Layers, FileStack, Camera, ChevronRight, ChevronDown } from 'lucide-svelte';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import { bulkDeleteSnapshots, bulkDeleteToast } from '$lib/utils/backup';
|
||||||
|
import BulkDeleteSnapshotsDialog from '$lib/components/BulkDeleteSnapshotsDialog.svelte';
|
||||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||||
import { LoadingState } from '$lib/components/ui/loading-state';
|
import { LoadingState } from '$lib/components/ui/loading-state';
|
||||||
import { getRepoTypeIcon, getRepoTypeLabel } from '$lib/utils/backup';
|
import { getRepoTypeIcon, getRepoTypeLabel } from '$lib/utils/backup';
|
||||||
@@ -85,7 +88,9 @@
|
|||||||
const s = new Set(loadingStats); s.add(destId); loadingStats = s;
|
const s = new Set(loadingStats); s.add(destId); loadingStats = s;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/backup/destinations/${destId}/task`, {
|
const res = await fetch(`/api/backup/destinations/${destId}/task`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
// Accept: json makes the job endpoint run the read synchronously and return
|
||||||
|
// {success, stats} instead of {jobId} (which this call doesn't poll).
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({ task: 'stats' })
|
body: JSON.stringify({ task: 'stats' })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -198,7 +203,8 @@
|
|||||||
if (task === 'stats') {
|
if (task === 'stats') {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/backup/destinations/${destId}/task`, {
|
const res = await fetch(`/api/backup/destinations/${destId}/task`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task })
|
// Accept: json runs the read synchronously so we get {success, stats}, not {jobId}.
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ task })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success && data.stats) {
|
if (data.success && data.stats) {
|
||||||
@@ -279,6 +285,43 @@
|
|||||||
let browseSnapshots = $state<any[]>([]);
|
let browseSnapshots = $state<any[]>([]);
|
||||||
let browseLoading = $state(false);
|
let browseLoading = $state(false);
|
||||||
|
|
||||||
|
// Multi-select bulk delete for the repo browser (shared helper + dialog). All snapshots
|
||||||
|
// here belong to the one browsed destination (browseDestId).
|
||||||
|
let selectedSnapshots = $state<Set<string>>(new Set());
|
||||||
|
let bulkDialogOpen = $state(false);
|
||||||
|
let bulkDeleting = $state(false);
|
||||||
|
function toggleSnapshotSel(id: string) {
|
||||||
|
const next = new Set(selectedSnapshots);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
selectedSnapshots = next;
|
||||||
|
}
|
||||||
|
function toggleGroupAll(snaps: any[]) {
|
||||||
|
const all = snaps.length > 0 && snaps.every((s) => selectedSnapshots.has(s.id));
|
||||||
|
const next = new Set(selectedSnapshots);
|
||||||
|
if (all) for (const s of snaps) next.delete(s.id);
|
||||||
|
else for (const s of snaps) next.add(s.id);
|
||||||
|
selectedSnapshots = next;
|
||||||
|
}
|
||||||
|
async function bulkDeleteBrowsed() {
|
||||||
|
const targets = browseSnapshots
|
||||||
|
.filter((s) => selectedSnapshots.has(s.id))
|
||||||
|
.map((s) => ({ id: s.id, _destinationId: browseDestId }));
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
bulkDeleting = true;
|
||||||
|
try {
|
||||||
|
const result = await bulkDeleteSnapshots(targets);
|
||||||
|
const t = bulkDeleteToast(result);
|
||||||
|
toast[t.type](t.message);
|
||||||
|
selectedSnapshots = new Set();
|
||||||
|
const dest = destinations.find((d) => d.id === browseDestId);
|
||||||
|
if (dest) await browseDestination(dest);
|
||||||
|
} finally {
|
||||||
|
bulkDeleting = false;
|
||||||
|
bulkDialogOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const browseSelectedCount = $derived(browseSnapshots.filter((s) => selectedSnapshots.has(s.id)).length);
|
||||||
|
|
||||||
function snapName(snap: any): string {
|
function snapName(snap: any): string {
|
||||||
return (snap.tags || []).find((t: string) => t.startsWith('dockhand:name='))?.replace('dockhand:name=', '') || '';
|
return (snap.tags || []).find((t: string) => t.startsWith('dockhand:name='))?.replace('dockhand:name=', '') || '';
|
||||||
}
|
}
|
||||||
@@ -367,6 +410,7 @@
|
|||||||
browseDestName = dest.name;
|
browseDestName = dest.name;
|
||||||
browseDestRepo = dest.repository;
|
browseDestRepo = dest.repository;
|
||||||
browseSnapshots = [];
|
browseSnapshots = [];
|
||||||
|
selectedSnapshots = new Set(); // don't carry a prior destination's selection over
|
||||||
browseOpen = true;
|
browseOpen = true;
|
||||||
browseLoading = true;
|
browseLoading = true;
|
||||||
try {
|
try {
|
||||||
@@ -771,9 +815,14 @@
|
|||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
{#if !browseLoading && browseSnapshots.length > 0}
|
{#if !browseLoading && browseSnapshots.length > 0}
|
||||||
<div class="flex gap-2 pb-3 flex-shrink-0">
|
<div class="flex items-center gap-2 pb-3 flex-shrink-0">
|
||||||
<Input bind:value={browseFilterName} placeholder="Filter by name..." class="h-8 max-w-xs" />
|
<Input bind:value={browseFilterName} placeholder="Filter by name..." class="h-8 max-w-xs" />
|
||||||
<Input bind:value={browseFilterEnv} placeholder="Filter by environment..." class="h-8 max-w-xs" />
|
<Input bind:value={browseFilterEnv} placeholder="Filter by environment..." class="h-8 max-w-xs" />
|
||||||
|
{#if browseSelectedCount > 0}
|
||||||
|
<button type="button" class="ml-auto inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10" onclick={() => (bulkDialogOpen = true)}>
|
||||||
|
<Trash2 class="h-3 w-3" /> Delete selected ({browseSelectedCount})
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex-1 min-h-0">
|
<div class="flex-1 min-h-0">
|
||||||
@@ -841,6 +890,14 @@
|
|||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header class="sticky top-0 z-10 bg-background">
|
<Table.Header class="sticky top-0 z-10 bg-background">
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
|
<Table.Head class="w-8 py-1.5" style="padding-left:8px">
|
||||||
|
<Checkbox
|
||||||
|
checked={group.snapshots.length > 0 && group.snapshots.every((s) => selectedSnapshots.has(s.id))}
|
||||||
|
indeterminate={group.snapshots.some((s) => selectedSnapshots.has(s.id)) && !group.snapshots.every((s) => selectedSnapshots.has(s.id))}
|
||||||
|
onCheckedChange={() => toggleGroupAll(group.snapshots)}
|
||||||
|
aria-label="Select all snapshots"
|
||||||
|
/>
|
||||||
|
</Table.Head>
|
||||||
<Table.Head class="w-28 py-1.5 text-xs" style="padding-left:8px">ID</Table.Head>
|
<Table.Head class="w-28 py-1.5 text-xs" style="padding-left:8px">ID</Table.Head>
|
||||||
<Table.Head class="py-1.5 text-xs" style="padding-left:8px">Created</Table.Head>
|
<Table.Head class="py-1.5 text-xs" style="padding-left:8px">Created</Table.Head>
|
||||||
<Table.Head class="w-16 py-1.5 text-xs text-right" style="padding-right:8px">Browse</Table.Head>
|
<Table.Head class="w-16 py-1.5 text-xs text-right" style="padding-right:8px">Browse</Table.Head>
|
||||||
@@ -848,8 +905,11 @@
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each group.snapshots as snap}
|
{#each group.snapshots as snap}
|
||||||
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = group.name; snapshotBrowseOpen = true; }}>
|
<Table.Row class="hover:bg-muted/50 {selectedSnapshots.has(snap.id) ? 'bg-primary/5' : ''}">
|
||||||
<Table.Cell class="font-mono text-xs text-muted-foreground py-1" style="padding-left:8px">{snap.shortId}</Table.Cell>
|
<Table.Cell class="py-1" style="padding-left:8px" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox checked={selectedSnapshots.has(snap.id)} onCheckedChange={() => toggleSnapshotSel(snap.id)} aria-label="Select snapshot {snap.shortId}" />
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="cursor-pointer font-mono text-xs text-muted-foreground py-1" style="padding-left:8px" onclick={() => { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = group.name; snapshotBrowseOpen = true; }}>{snap.shortId}</Table.Cell>
|
||||||
<Table.Cell class="text-xs py-1" style="padding-left:8px">{formatDateTime(snap.time)} <span class="text-muted-foreground opacity-60">({formatRelativeTime(snap.time)})</span></Table.Cell>
|
<Table.Cell class="text-xs py-1" style="padding-left:8px">{formatDateTime(snap.time)} <span class="text-muted-foreground opacity-60">({formatRelativeTime(snap.time)})</span></Table.Cell>
|
||||||
<Table.Cell class="text-right py-1">
|
<Table.Cell class="text-right py-1">
|
||||||
<span class="inline-flex p-1 rounded hover:bg-muted transition-colors text-muted-foreground" title="Browse snapshot content">
|
<span class="inline-flex p-1 rounded hover:bg-muted transition-colors text-muted-foreground" title="Browse snapshot content">
|
||||||
@@ -872,6 +932,8 @@
|
|||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<BulkDeleteSnapshotsDialog bind:open={bulkDialogOpen} count={browseSelectedCount} busy={bulkDeleting} onConfirm={bulkDeleteBrowsed} />
|
||||||
|
|
||||||
<SnapshotBrowser
|
<SnapshotBrowser
|
||||||
bind:open={snapshotBrowseOpen}
|
bind:open={snapshotBrowseOpen}
|
||||||
destinationId={snapshotBrowseDestId}
|
destinationId={snapshotBrowseDestId}
|
||||||
|
|||||||
+84
-4
@@ -3425,7 +3425,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"backup"
|
"backup"
|
||||||
],
|
],
|
||||||
"summary": "Delete a backup configuration and unregister its schedule",
|
"summary": "Delete a backup configuration and unregister its schedule, optionally forgetting its snapshots too",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -3435,11 +3435,20 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"description": "Backup configuration id (from GET /api/backup/configs)"
|
"description": "Backup configuration id (from GET /api/backup/configs)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deleteSnapshots",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"description": "Also forget and prune this config's snapshots (default false; snapshots survive so a restore is still possible)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Returns { success: true } once the configuration is deleted"
|
"description": "Returns { success: true }, plus { snapshots: { deleted, skipped, error? } } when deleteSnapshots=true"
|
||||||
},
|
},
|
||||||
"409": {
|
"409": {
|
||||||
"description": "A backup is currently running for this config — stop it before deleting"
|
"description": "A backup is currently running for this config — stop it before deleting"
|
||||||
@@ -3453,7 +3462,7 @@
|
|||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards."
|
"description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards. With deleteSnapshots=true the config's snapshots are forgotten and pruned (best-effort; reported in the response, never blocks the delete)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/backup/configs/{id}/run": {
|
"/api/backup/configs/{id}/run": {
|
||||||
@@ -4779,6 +4788,68 @@
|
|||||||
"description": "Job-polled so a proxy can't abort the restic dump at ~15s. The layout is redacted (stack secrets and container Config.Env/Labels stripped) before it leaves the server."
|
"description": "Job-polled so a proxy can't abort the restic dump at ~15s. The layout is redacted (stack secrets and container Config.Env/Labels stripped) before it leaves the server."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/backup/snapshots/batch-delete": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_api_backup_snapshots_batch-delete",
|
||||||
|
"tags": [
|
||||||
|
"backup"
|
||||||
|
],
|
||||||
|
"summary": "Forget and prune several snapshots from one destination in a single restic forget --prune, with per-snapshot ownership and environment access gates",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Returns { success: true, deleted: string[], skipped: string[] } - deleted were forgotten, skipped were not owned or not accessible"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Missing/invalid destinationId, empty snapshotIds, or an invalid snapshot id"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Failed to forget the snapshots (restic error)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard. Per-snapshot ownership/environment failures are skipped (returned in `skipped`), not 403d.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"destinationId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"snapshotIds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"destinationId",
|
||||||
|
"snapshotIds"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"destinationId": 3,
|
||||||
|
"snapshotIds": [
|
||||||
|
"1a2b3c4d",
|
||||||
|
"5e6f7a8b"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/backup/snapshots/diff": {
|
"/api/backup/snapshots/diff": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "get_api_backup_snapshots_diff",
|
"operationId": "get_api_backup_snapshots_diff",
|
||||||
@@ -23897,6 +23968,12 @@
|
|||||||
},
|
},
|
||||||
"useSelfhstIcons": {
|
"useSelfhstIcons": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"stackLogOperations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -23955,7 +24032,10 @@
|
|||||||
"showWhatsNew": true,
|
"showWhatsNew": true,
|
||||||
"terminalFont": "string",
|
"terminalFont": "string",
|
||||||
"timeFormat": "string",
|
"timeFormat": "string",
|
||||||
"useSelfhstIcons": true
|
"useSelfhstIcons": true,
|
||||||
|
"stackLogOperations": [
|
||||||
|
"string"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
assertInstanceOwned,
|
assertInstanceOwned,
|
||||||
assertEnvAccess,
|
assertEnvAccess,
|
||||||
guardSnapshotAccess,
|
guardSnapshotAccess,
|
||||||
|
selectOwnedForForget,
|
||||||
listSnapshots,
|
listSnapshots,
|
||||||
resolveSnapshotEnvId,
|
resolveSnapshotEnvId,
|
||||||
filterSnapshotsByAccessibleEnv,
|
filterSnapshotsByAccessibleEnv,
|
||||||
@@ -102,6 +103,33 @@ describe('guardSnapshotAccess — instance AND env, in order', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('selectOwnedForForget — the bulk-delete preVetted contract', () => {
|
||||||
|
const ids = ['a'.repeat(64), 'b'.repeat(64), 'c'.repeat(64)];
|
||||||
|
|
||||||
|
it('preVetted forwards every id and NEVER calls the guard (route already vetted them)', async () => {
|
||||||
|
let guardCalls = 0;
|
||||||
|
const guard = async () => { guardCalls++; };
|
||||||
|
const out = await selectOwnedForForget(ids, { preVetted: true, guard });
|
||||||
|
expect(out.owned).toEqual(ids);
|
||||||
|
expect(out.skipped).toEqual([]);
|
||||||
|
expect(guardCalls).toBe(0); // the whole point: no per-id ownership re-listing
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without preVetted, a guard rejection drops that id to skipped (never forgotten)', async () => {
|
||||||
|
// 'b' is not owned / not accessible -> the guard throws -> it must be skipped, not owned.
|
||||||
|
const guard = async (id: string) => { if (id === ids[1]) throw new Error('not owned'); };
|
||||||
|
const out = await selectOwnedForForget(ids, { preVetted: false, guard });
|
||||||
|
expect(out.owned).toEqual([ids[0], ids[2]]);
|
||||||
|
expect(out.skipped).toEqual([ids[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without preVetted, all-rejected yields no owned ids (nothing reaches restic)', async () => {
|
||||||
|
const out = await selectOwnedForForget(ids, { preVetted: false, guard: async () => { throw new Error('x'); } });
|
||||||
|
expect(out.owned).toEqual([]);
|
||||||
|
expect(out.skipped).toEqual(ids);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('listSnapshots — filters to accessible environments (enterprise)', () => {
|
describe('listSnapshots — filters to accessible environments (enterprise)', () => {
|
||||||
const snaps = [
|
const snaps = [
|
||||||
{ id: 'x'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=3'] },
|
{ id: 'x'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=3'] },
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'bun:test';
|
||||||
|
import { groupSnapshotIdsByDestination, bulkDeleteSnapshots, bulkDeleteToast } from '../src/lib/utils/backup';
|
||||||
|
|
||||||
|
const snap = (id: string, dest: number) => ({ id, _destinationId: dest });
|
||||||
|
|
||||||
|
describe('groupSnapshotIdsByDestination', () => {
|
||||||
|
it('groups snapshot ids by their destination (one batch per repo)', () => {
|
||||||
|
const g = groupSnapshotIdsByDestination([
|
||||||
|
snap('a', 1), snap('b', 2), snap('c', 1), snap('d', 2), snap('e', 3),
|
||||||
|
]);
|
||||||
|
const byId = Object.fromEntries(g.map((x) => [x.destinationId, x.snapshotIds.sort()]));
|
||||||
|
expect(byId[1]).toEqual(['a', 'c']);
|
||||||
|
expect(byId[2]).toEqual(['b', 'd']);
|
||||||
|
expect(byId[3]).toEqual(['e']);
|
||||||
|
expect(g.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a single-destination selection is one batch', () => {
|
||||||
|
const g = groupSnapshotIdsByDestination([snap('a', 5), snap('b', 5)]);
|
||||||
|
expect(g).toEqual([{ destinationId: 5, snapshotIds: ['a', 'b'] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty input yields no batches', () => {
|
||||||
|
expect(groupSnapshotIdsByDestination([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves per-destination order (restic forget order is stable)', () => {
|
||||||
|
const g = groupSnapshotIdsByDestination([snap('z', 1), snap('a', 1), snap('m', 1)]);
|
||||||
|
expect(g[0].snapshotIds).toEqual(['z', 'a', 'm']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkDeleteToast', () => {
|
||||||
|
it('failed with some deleted -> error naming the count', () => {
|
||||||
|
expect(bulkDeleteToast({ deleted: 2, skipped: 0, failed: true }))
|
||||||
|
.toEqual({ type: 'error', message: 'Deleted 2, but some snapshots could not be deleted' });
|
||||||
|
});
|
||||||
|
it('failed with nothing deleted -> plain error', () => {
|
||||||
|
expect(bulkDeleteToast({ deleted: 0, skipped: 0, failed: true }))
|
||||||
|
.toEqual({ type: 'error', message: 'Snapshots could not be deleted' });
|
||||||
|
});
|
||||||
|
it('nothing deleted but skipped -> error (not owned / no access), pluralized', () => {
|
||||||
|
expect(bulkDeleteToast({ deleted: 0, skipped: 1, failed: false }))
|
||||||
|
.toEqual({ type: 'error', message: '1 snapshot could not be deleted (not owned or no access)' });
|
||||||
|
expect(bulkDeleteToast({ deleted: 0, skipped: 3, failed: false }).message)
|
||||||
|
.toBe('3 snapshots could not be deleted (not owned or no access)');
|
||||||
|
});
|
||||||
|
it('success -> singular vs plural and optional skipped suffix', () => {
|
||||||
|
expect(bulkDeleteToast({ deleted: 1, skipped: 0, failed: false }))
|
||||||
|
.toEqual({ type: 'success', message: 'Deleted 1 snapshot' });
|
||||||
|
expect(bulkDeleteToast({ deleted: 3, skipped: 0, failed: false }).message).toBe('Deleted 3 snapshots');
|
||||||
|
expect(bulkDeleteToast({ deleted: 3, skipped: 2, failed: false }).message).toBe('Deleted 3 snapshots (2 skipped)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulkDeleteSnapshots', () => {
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
afterEach(() => { globalThis.fetch = realFetch; });
|
||||||
|
|
||||||
|
it('one batch call per destination; sums deleted and skipped across repos', async () => {
|
||||||
|
const calls: any[] = [];
|
||||||
|
// dest 1 -> deletes a,c; dest 2 -> deletes b, skips d
|
||||||
|
globalThis.fetch = (async (_url: string, init: any) => {
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
calls.push(body);
|
||||||
|
const per: Record<number, any> = {
|
||||||
|
1: { deleted: ['a', 'c'], skipped: [] },
|
||||||
|
2: { deleted: ['b'], skipped: ['d'] }
|
||||||
|
};
|
||||||
|
return { ok: true, json: async () => per[body.destinationId] } as any;
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const r = await bulkDeleteSnapshots([snap('a', 1), snap('b', 2), snap('c', 1), snap('d', 2)]);
|
||||||
|
expect(calls.length).toBe(2);
|
||||||
|
expect(r).toEqual({ deleted: 3, skipped: 1, failed: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a non-ok response marks failed and does not count that repo\'s skipped', async () => {
|
||||||
|
globalThis.fetch = (async (_url: string, init: any) => {
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
if (body.destinationId === 2) return { ok: false, json: async () => ({ deleted: ['b'], skipped: ['x'] }) } as any;
|
||||||
|
return { ok: true, json: async () => ({ deleted: ['a'], skipped: [] }) } as any;
|
||||||
|
}) as any;
|
||||||
|
const r = await bulkDeleteSnapshots([snap('a', 1), snap('b', 2)]);
|
||||||
|
// deleted still counts what the failed repo reported forgotten; skipped from a failed repo is not added
|
||||||
|
expect(r).toEqual({ deleted: 2, skipped: 0, failed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a thrown fetch marks failed without aborting the other repos', async () => {
|
||||||
|
globalThis.fetch = (async (_url: string, init: any) => {
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
if (body.destinationId === 1) throw new Error('network');
|
||||||
|
return { ok: true, json: async () => ({ deleted: ['b'], skipped: [] }) } as any;
|
||||||
|
}) as any;
|
||||||
|
const r = await bulkDeleteSnapshots([snap('a', 1), snap('b', 2)]);
|
||||||
|
expect(r.failed).toBe(true);
|
||||||
|
expect(r.deleted).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user