+ |
+ toggleSnapshot(s.id)} aria-label="Select snapshot {s.shortId}" />
+ |
{s.shortId} |
{formatDateTime(s.time)} ({formatRelativeTime(s.time)}) |
{st ? formatBytes(st.dataAdded) : '—'} |
@@ -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);
+ });
+});