diff --git a/src/lib/components/BulkDeleteSnapshotsDialog.svelte b/src/lib/components/BulkDeleteSnapshotsDialog.svelte new file mode 100644 index 0000000..6fd7c6d --- /dev/null +++ b/src/lib/components/BulkDeleteSnapshotsDialog.svelte @@ -0,0 +1,37 @@ + + + + + + + + Delete {count} snapshot{count === 1 ? '' : 's'}? + + +

+ This permanently forgets and prunes the selected snapshot{count === 1 ? '' : 's'} from + {count === 1 ? 'its' : 'their'} backup repository. This cannot be undone. +

+ + + + +
+
diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index 864f78e..ae46218 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -3425,7 +3425,7 @@ "tags": [ "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": [ { "name": "id", @@ -3435,11 +3435,20 @@ "type": "integer" }, "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": { "200": { - "description": "Returns { success: true } once the configuration is deleted" + "description": "Returns { success: true }, plus { snapshots: { deleted, skipped, error? } } when deleteSnapshots=true" }, "409": { "description": "A backup is currently running for this config — stop it before deleting" @@ -3453,7 +3462,7 @@ "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": { @@ -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." } }, + "/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": { "get": { "operationId": "get_api_backup_snapshots_diff", @@ -23897,6 +23968,12 @@ }, "useSelfhstIcons": { "type": "boolean" + }, + "stackLogOperations": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -23955,7 +24032,10 @@ "showWhatsNew": true, "terminalFont": "string", "timeFormat": "string", - "useSelfhstIcons": true + "useSelfhstIcons": true, + "stackLogOperations": [ + "string" + ] } } } diff --git a/src/lib/server/backups/index.ts b/src/lib/server/backups/index.ts index 7f9272e..aa443d9 100644 --- a/src/lib/server/backups/index.ts +++ b/src/lib/server/backups/index.ts @@ -15,7 +15,7 @@ import { RestoreService, type RestorePorts, type RestoreJob } from './restore-se import { openOperation } from './operations'; import { LiveTargetLocks, DestinationSerializer } from './locks'; 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 { parseRetention, buildForgetArgs, checkWouldWipe } from './retention'; 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 }; } +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 }; + 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 { + 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 { + 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 --- /** diff --git a/src/lib/server/backups/snapshots.ts b/src/lib/server/backups/snapshots.ts index 571ed2f..9fefe19 100644 --- a/src/lib/server/backups/snapshots.ts +++ b/src/lib/server/backups/snapshots.ts @@ -159,6 +159,36 @@ export async function filterSnapshotsByAccessibleEnv Promise }, +): 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 * config). Enterprise callers only see snapshots whose env they can access. diff --git a/src/lib/utils/backup.ts b/src/lib/utils/backup.ts index 58d0d49..018dd92 100644 --- a/src/lib/utils/backup.ts +++ b/src/lib/utils/backup.ts @@ -434,3 +434,66 @@ export function selectOwnSnapshotsFromDestination .filter((s) => snapshotMatchesConfigScope(s.tags, configIds, targetName)) .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( + snaps: S[] +): { destinationId: number; snapshotIds: string[] }[] { + const byDest = new Map(); + 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 { + 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)` : ''}` }; +} diff --git a/src/routes/api/backup/configs/[id]/+server.ts b/src/routes/api/backup/configs/[id]/+server.ts index 2a80ea5..a234ba9 100644 --- a/src/routes/api/backup/configs/[id]/+server.ts +++ b/src/routes/api/backup/configs/[id]/+server.ts @@ -7,7 +7,7 @@ import { deleteBackupConfig } from '$lib/server/db'; 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 { 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 * * @openapi - * summary: Delete a backup configuration and unregister its schedule - * description: Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards. + * 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. 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) - * resp-200: Returns { success: true } once the configuration is deleted - * resp-200-example: {"success":true} + * query: deleteSnapshots:boolean Also forget and prune this config's snapshots (default false; snapshots survive so a restore is still possible) + * 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 */ export const DELETE: RequestHandler = async (event) => { - const { params, cookies } = event; + const { params, url, cookies } = event; const auth = await authorize(cookies); const denied = await requireBackups(auth, 'manage'); 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 }); } + // 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 unregisterSchedule(id, 'backup'); await deleteBackupConfig(id); - await auditBackup(event, 'delete', existing.targetName, existing.environmentId, { configId: id }); - return json({ success: true }); + await auditBackup(event, 'delete', existing.targetName, existing.environmentId, { configId: id, ...(snapshotsResult ? { snapshotsDeleted: snapshotsResult.deleted, snapshotsSkipped: snapshotsResult.skipped } : {}) }); + return json({ success: true, ...(snapshotsResult ? { snapshots: snapshotsResult } : {}) }); }; diff --git a/src/routes/api/backup/snapshots/batch-delete/+server.ts b/src/routes/api/backup/snapshots/batch-delete/+server.ts new file mode 100644 index 0000000..6643c28 --- /dev/null +++ b/src/routes/api/backup/snapshots/batch-delete/+server.ts @@ -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!} + * 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 }); + } +}; diff --git a/src/routes/backups/+page.svelte b/src/routes/backups/+page.svelte index 3dc3ea5..881cc43 100644 --- a/src/routes/backups/+page.svelte +++ b/src/routes/backups/+page.svelte @@ -26,7 +26,9 @@ import { formatDateTime, formatRelativeTime } from '$lib/stores/settings'; import { currentEnvironment } from '$lib/stores/environment'; 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 RestoreModal from '../containers/RestoreModal.svelte'; import BackupLogModal from './BackupLogModal.svelte'; @@ -105,6 +107,50 @@ const SNAPSHOT_PAGE_SIZE = 10; let snapshotLimits = $state>(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>(new Set()); + let bulkDialogOpen = $state(false); + let bulkDeleting = $state(false); + let bulkConfig = $state(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 let snapshotCounts = $state>(new Map()); let snapshotCountsLoading = $state(true); @@ -160,6 +206,8 @@ let deletingSnapshot = $state(null); let confirmDeleteConfig = $state(null); let deletingConfig = $state(null); + // Opt-in: also forget the config's snapshots on delete (off by default; resets per open). + let deleteConfigSnapshots = $state(false); let togglingConfig = $state(null); // Edit-config modal (reuses the container/stack Backups tab — BackupPanel). let editModalOpen = $state(false); @@ -596,10 +644,13 @@ // orphan if any snapshots remain. async function deleteConfig(config: BackupConfig) { deletingConfig = config.id; + const withSnaps = deleteConfigSnapshots; 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) { - 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(); } else { const data = await res.json().catch(() => ({})); @@ -610,6 +661,7 @@ } finally { deletingConfig = null; confirmDeleteConfig = null; + deleteConfigSnapshots = false; } } @@ -929,10 +981,10 @@ action="Delete" itemType="backup config" itemName={config.targetName} - title="Existing snapshots are kept." + title={deleteConfigSnapshots ? 'Snapshots will be deleted too.' : 'Existing snapshots are kept.'} position="left" onConfirm={() => deleteConfig(config)} - onOpenChange={(open) => confirmDeleteConfig = open ? config.id : null} + onOpenChange={(open) => { confirmDeleteConfig = open ? config.id : null; if (open) deleteConfigSnapshots = false; }} > {#snippet children({ open })} {#if deletingConfig === config.id} @@ -941,6 +993,12 @@ {/if} {/snippet} + {#snippet extraContent()} + + {/snippet} {/if} @@ -970,6 +1028,12 @@ + {#if snapshots.some((s) => selectedSnapshots.has(s.id))} + {@const n = snapshots.filter((s) => selectedSnapshots.has(s.id)).length} + + {/if} {#if isLoading && snapshots.length === 0} +
+ {#if selectedCount > 0} + + + {/if} + {#if visibleSnapshots.length > 0} + + {/if} +
+ @@ -293,7 +360,10 @@ {@const isDiffPending = diffPending?.id === s.id} {@const RepoIcon = getRepoTypeIcon(s._destinationRepository)} {@const st = stats.get(s.id)} - + + @@ -340,6 +410,8 @@ {/if} + bulkDelete(bulkScope)} /> + ([]); 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>(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 { return (snap.tags || []).find((t: string) => t.startsWith('dockhand:name='))?.replace('dockhand:name=', '') || ''; } @@ -367,6 +410,7 @@ browseDestName = dest.name; browseDestRepo = dest.repository; browseSnapshots = []; + selectedSnapshots = new Set(); // don't carry a prior destination's selection over browseOpen = true; browseLoading = true; try { @@ -771,9 +815,14 @@ {#if !browseLoading && browseSnapshots.length > 0} -
+
+ {#if browseSelectedCount > 0} + + {/if}
{/if}
@@ -841,6 +890,14 @@ + + 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" + /> + ID Created Browse @@ -848,8 +905,11 @@ {#each group.snapshots as snap} - { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = group.name; snapshotBrowseOpen = true; }}> - {snap.shortId} + + e.stopPropagation()}> + toggleSnapshotSel(snap.id)} aria-label="Select snapshot {snap.shortId}" /> + + { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = group.name; snapshotBrowseOpen = true; }}>{snap.shortId} {formatDateTime(snap.time)} ({formatRelativeTime(snap.time)}) @@ -872,6 +932,8 @@ + + { }); }); +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)', () => { const snaps = [ { id: 'x'.repeat(64), tags: ['dockhand:instance=me', 'dockhand:envid=3'] }, diff --git a/tests/group-snapshots-by-destination.test.ts b/tests/group-snapshots-by-destination.test.ts new file mode 100644 index 0000000..caa010f --- /dev/null +++ b/tests/group-snapshots-by-destination.test.ts @@ -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 = { + 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); + }); +});
+ + Snapshot Taken Added
+ toggleSnapshot(s.id)} aria-label="Select snapshot {s.shortId}" /> + {s.shortId} {formatDateTime(s.time)} ({formatRelativeTime(s.time)}) {st ? formatBytes(st.dataAdded) : '—'}