diff --git a/src/lib/config/grid-columns.ts b/src/lib/config/grid-columns.ts index fbd95a5..f87de0f 100644 --- a/src/lib/config/grid-columns.ts +++ b/src/lib/config/grid-columns.ts @@ -151,6 +151,19 @@ export const backupColumns: ColumnConfig[] = [ { id: 'actions', label: '', fixed: 'end', width: 100, resizable: false } ]; +// Repository-snapshots grid columns (the "Repository snapshots on X" dialog). +// Groups the repo's snapshots by environment + target name; source is the repo's +// own snapshot tags, NOT backup_configs (so it works on a fresh instance with no +// configs). No repository column (the dialog IS one repo), no select column. +export const repoSnapshotColumns: ColumnConfig[] = [ + { id: 'expand', label: '', fixed: 'start', width: 24, resizable: false }, + { id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 200, minWidth: 120, grow: true }, + { id: 'type', label: 'Type', sortable: true, sortField: 'type', width: 50, minWidth: 40, resizable: false, align: 'center' as any, hint: 'Container or stack backup' }, + { id: 'environment', label: 'Environment', sortable: true, sortField: 'envLabel', width: 160, minWidth: 100 }, + { id: 'latest', label: 'Latest', sortable: true, sortField: 'latest', width: 150, minWidth: 100 }, + { id: 'snapshots', label: 'Snapshots', sortable: true, sortField: 'count', width: 90, minWidth: 60, align: 'center' as any } +]; + // Backup destination grid columns export const backupDestinationColumns: ColumnConfig[] = [ { id: 'type', label: 'Type', width: 80, minWidth: 60, resizable: false }, @@ -191,6 +204,7 @@ export const gridColumnConfigs: Record = { environments: environmentColumns, backupDestinations: backupDestinationColumns, backups: backupColumns, + repoSnapshots: repoSnapshotColumns, vulnerabilities: vulnerabilityColumns }; diff --git a/src/lib/types.ts b/src/lib/types.ts index ffacf90..207f7b1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -173,7 +173,7 @@ export interface GitRepository { } // Grid column configuration types -export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit' | 'environments' | 'backupDestinations' | 'backups' | 'vulnerabilities'; +export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit' | 'environments' | 'backupDestinations' | 'backups' | 'repoSnapshots' | 'vulnerabilities'; export interface ColumnConfig { id: string; diff --git a/src/routes/backups/+page.svelte b/src/routes/backups/+page.svelte index da6941f..d040abc 100644 --- a/src/routes/backups/+page.svelte +++ b/src/routes/backups/+page.svelte @@ -22,7 +22,7 @@ CheckCircle, XCircle, AlertCircle, Loader2, Clock, X, ArrowLeftRight, Package, Pencil } from 'lucide-svelte'; import RotateCwFadingClock from '$lib/components/icons/RotateCwFadingClock.svelte'; - import { formatDateTime } from '$lib/stores/settings'; + import { formatDateTime, formatRelativeTime } from '$lib/stores/settings'; import { currentEnvironment } from '$lib/stores/environment'; import { watchJob } from '$lib/utils/sse-fetch'; import { getRepoTypeIcon, formatCron, retentionSummary, classifyJobResult, tagLogLine } from '$lib/utils/backup'; @@ -123,14 +123,29 @@ let logModalLogs = $state([]); let logModalError = $state(''); let logModalConfigId = $state(null); + // True once the backup passes the point cancel can still stop it: restic is done, + // the snapshot exists, and only repo-only phases (verify / retention) remain — these + // run in-process and aren't killable, so we disable Cancel rather than let it lie. + let logModalPastCancel = $state(false); + const canCancelBackup = $derived(logModalStatus === 'running' && !logModalPastCancel); async function stopBackup() { if (!logModalConfigId) return; try { - await fetch(`/api/backup/configs/${logModalConfigId}/stop`, { method: 'POST' }); - logModalLogs = [...logModalLogs, '⚠ Backup cancelled by user']; - logModalStatus = 'error'; - logModalError = 'Cancelled'; + const res = await fetch(`/api/backup/configs/${logModalConfigId}/stop`, { method: 'POST' }); + const body = await res.json().catch(() => ({})); + // Only declare it cancelled if the backend actually signalled a running + // helper. If it was already past the point of no return (restic done, + // applying retention), `stopped` is false — let the real progress stream + // finish and report the true 'Completed' status instead of a false 'Cancelled'. + if (body?.stopped) { + logModalLogs = [...logModalLogs, '⚠ Backup cancelled by user']; + logModalStatus = 'error'; + logModalError = 'Cancelled'; + } else { + logModalLogs = [...logModalLogs, 'Backup is finishing and can no longer be cancelled.']; + toast.info('Backup was already finishing — it will complete.'); + } } catch { toast.error('Failed to stop backup'); } } let confirmDeleteSnapshot = $state(null); @@ -460,6 +475,7 @@ logModalLogs = []; logModalError = ''; logModalProgress = 0; + logModalPastCancel = false; logModalOpen = true; try { const res = await fetch(`/api/backup/configs/${config.id}/run`, { method: 'POST' }); @@ -469,6 +485,11 @@ const d = line.data as any; if (line.event === 'progress' && d?.message) { logModalLogs = [...logModalLogs, tagLogLine(d.message)]; + // Once restic is done and only repo-only phases remain, cancel can no + // longer stop the backup — the snapshot already exists. Disable Cancel. + if (d.status === 'verifying' || d.status === 'pruning' || d.status === 'restarted') { + logModalPastCancel = true; + } // (audit #15) Prefer the structured restic percent from detail; // fall back to the free-form "XX% done" message regex. if (d.status === 'progress' && typeof d.detail?.percent_done === 'number') { @@ -770,9 +791,9 @@ {:else if column.id === 'type'}
{#if config.type === 'container'} - Container backup + Container backup {:else} - Stack backup + Stack backup {/if}
{:else if column.id === 'environment'} @@ -794,7 +815,7 @@ {config.destinationName} - {config.destinationRepository} + {config.destinationRepository} {:else} — @@ -812,7 +833,7 @@ {:else if column.id === 'lastBackup'} {#if config.lastBackupAt} - {formatDateTime(config.lastBackupAt)} + {formatDateTime(config.lastBackupAt)} ({formatRelativeTime(config.lastBackupAt)}) {:else} Never {/if} @@ -834,15 +855,15 @@ {:else if column.id === 'status'}
{#if config.isOrphan} - No schedule — snapshots found in repository + No schedule — snapshots found in repository {:else if runningBackup === config.id} - Backup in progress + Backup in progress {:else if config.lastBackupStatus === 'success'} - Last backup succeeded + Last backup succeeded {:else if config.lastBackupStatus === 'failed'} - Last backup failed + Last backup failed {:else} - No backup run yet + No backup run yet {/if}
{:else if column.id === 'actions'} @@ -936,7 +957,7 @@ {@const isDiffPending = diffPending?.snapshot.id === snapshot.id} {snapshot.shortId} - {formatDateTime(snapshot.time)} + {formatDateTime(snapshot.time)} ({formatRelativeTime(snapshot.time)}) {#if stats} {stats.filesNew} new, {stats.filesChanged} changed · {formatBytes(stats.dataAdded)} @@ -1037,7 +1058,7 @@ progress={logModalProgress} logs={logModalLogs} error={logModalError} - onStop={logModalStatus === 'running' ? stopBackup : undefined} + onStop={canCancelBackup ? stopBackup : undefined} /> -
- -

Loading snapshots…

-
+ {:else if snapshots.length === 0} -
+

No snapshots yet for {targetName}.

Run a backup from the Schedules tab to create one.

@@ -276,7 +274,7 @@ {@const st = stats.get(s.id)} {s.shortId} - {formatDateTime(s.time)} + {formatDateTime(s.time)} ({formatRelativeTime(s.time)}) {st ? formatBytes(st.dataAdded) : '—'} {s._destinationName} diff --git a/src/routes/settings/backups/BackupsTab.svelte b/src/routes/settings/backups/BackupsTab.svelte index 7211683..7cee933 100644 --- a/src/routes/settings/backups/BackupsTab.svelte +++ b/src/routes/settings/backups/BackupsTab.svelte @@ -11,9 +11,11 @@ import { appSettings } from '$lib/stores/settings'; import * as Dialog from '$lib/components/ui/dialog'; import * as Table from '$lib/components/ui/table'; - import { formatDateTime } from '$lib/stores/settings'; - import { FolderOpen, Box, Layers, FileStack, Camera, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-svelte'; + import { formatDateTime, formatRelativeTime } from '$lib/stores/settings'; + import { FolderOpen, Box, Layers, FileStack, Camera, ChevronRight, ChevronDown } from 'lucide-svelte'; import { formatBytes } from '$lib/utils/format'; + import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; + import { LoadingState } from '$lib/components/ui/loading-state'; import { getRepoTypeIcon, getRepoTypeLabel } from '$lib/utils/backup'; import { shouldSaveBackupImage } from '$lib/utils/backup-image'; import SnapshotBrowser from '../../containers/SnapshotBrowser.svelte'; @@ -47,6 +49,7 @@ let destinations = $state([]); let configs = $state([]); + let environments = $state>([]); let loading = $state(true); let showModal = $state(false); let editingDest = $state(null); @@ -204,33 +207,83 @@ let browseSnapshots = $state([]); let browseLoading = $state(false); - // Snapshot list sorting — default newest first (by snapshot timestamp). - type BrowseSortField = 'shortId' | 'time' | 'type' | 'name'; - let browseSort = $state<{ field: BrowseSortField; dir: 'asc' | 'desc' }>({ field: 'time', dir: 'desc' }); function snapName(snap: any): string { return (snap.tags || []).find((t: string) => t.startsWith('dockhand:name='))?.replace('dockhand:name=', '') || ''; } function snapType(snap: any): string { return (snap.tags || []).find((t: string) => t.startsWith('dockhand:type='))?.replace('dockhand:type=', '') || ''; } - function toggleBrowseSort(field: BrowseSortField) { - if (browseSort.field === field) browseSort = { field, dir: browseSort.dir === 'asc' ? 'desc' : 'asc' }; - else browseSort = { field, dir: field === 'time' ? 'desc' : 'asc' }; + // Environment a snapshot belongs to, from its dockhand:envid tag. Returns the + // resolved env (name + icon) when it still exists, { missing: true } when the + // env was deleted, or null when the snapshot is unscoped ('local', no envid tag). + function snapEnv(snap: any): { id: number; name: string; icon: string } | { missing: true } | null { + const tag = (snap.tags || []).find((t: string) => t.startsWith('dockhand:envid=')); + if (!tag) return null; + const id = parseInt(tag.replace('dockhand:envid=', '')); + if (isNaN(id)) return null; + const env = environments.find((e) => e.id === id); + return env ? { id: env.id, name: env.name, icon: env.icon || 'globe' } : { missing: true }; } - const sortedBrowseSnapshots = $derived.by(() => { - const { field, dir } = browseSort; - const key = (s: any) => - field === 'shortId' ? (s.shortId ?? '') : - field === 'time' ? (s.time ?? '') : - field === 'type' ? snapType(s) : - snapName(s); - return [...browseSnapshots].sort((a, b) => { + let browseFilterName = $state(''); + let browseFilterEnv = $state(''); + // DataGrid sort + expand state for the repo-snapshots grid. + let browseGridSort = $state<{ field: string; direction: 'asc' | 'desc' }>({ field: 'latest', direction: 'desc' }); + let browseExpandedKeys = $state>(new Set()); + + type SnapGroup = { + key: string; name: string; type: string; + env: { id: number; name: string; icon: string } | { missing: true } | null; + envLabel: string; snapshots: any[]; latest: string; count: number; + }; + + // Group the repo's snapshots by owning environment + target name. Source is the + // snapshot tags (name/type/envid), NOT backup_configs — so this works on a fresh + // instance with no configs (the restore-on-new-box case). Env is resolved eagerly + // in-memory from the already-fetched tags + environments list (no per-snapshot I/O). + const browseGroups = $derived.by(() => { + const nf = browseFilterName.trim().toLowerCase(); + const ef = browseFilterEnv.trim().toLowerCase(); + const groups = new Map(); + for (const s of browseSnapshots) { + const name = snapName(s); + const e = snapEnv(s); + const envLabel = e ? ('missing' in e ? 'missing' : e.name) : ''; + if (nf && !name.toLowerCase().includes(nf)) continue; + if (ef && !envLabel.toLowerCase().includes(ef)) continue; + const envKey = e ? ('missing' in e ? 'missing' : String(e.id)) : 'local'; + const gkey = `${envKey}:${name}`; + let g = groups.get(gkey); + if (!g) { g = { key: gkey, name, type: snapType(s), env: e, envLabel, snapshots: [], latest: '', count: 0 }; groups.set(gkey, g); } + g.snapshots.push(s); + } + const arr = [...groups.values()]; + for (const g of arr) { + g.snapshots.sort((a, b) => (b.time ?? '').localeCompare(a.time ?? '')); + g.latest = g.snapshots[0]?.time ?? ''; + g.count = g.snapshots.length; + } + const { field, direction } = browseGridSort; + const key = (g: SnapGroup) => + field === 'type' ? g.type : + field === 'envLabel' ? g.envLabel : + field === 'latest' ? g.latest : + field === 'count' ? String(g.count).padStart(6, '0') : + g.name; + arr.sort((a, b) => { const ka = key(a), kb = key(b); const cmp = ka < kb ? -1 : ka > kb ? 1 : 0; - return dir === 'asc' ? cmp : -cmp; + return direction === 'asc' ? cmp : -cmp; }); + return arr; }); + function toggleBrowseGroup(g: SnapGroup) { + const next = new Set(browseExpandedKeys); + next.has(g.key) ? next.delete(g.key) : next.add(g.key); + browseExpandedKeys = next; + } + + // Snapshot file browser let snapshotBrowseOpen = $state(false); let snapshotBrowseId = $state(''); @@ -282,13 +335,15 @@ loading = true; repoStats = new Map(); try { - const [destRes, configRes] = await Promise.all([ + const [destRes, configRes, envRes] = await Promise.all([ fetch('/api/backup/destinations'), - fetch('/api/backup/configs') + fetch('/api/backup/configs'), + fetch('/api/environments') ]); destinations = await destRes.json(); const configData = await configRes.json(); configs = Array.isArray(configData) ? configData : []; + if (envRes.ok) { const envData = await envRes.json(); environments = Array.isArray(envData) ? envData : []; } } catch (error) { console.error('Failed to fetch backup data:', error); toast.error('Failed to fetch backup destinations'); @@ -602,7 +657,7 @@ /> - + @@ -611,60 +666,100 @@ {browseDestName} -
+ {#if !browseLoading && browseSnapshots.length > 0} +
+ + +
+ {/if} +
{#if browseLoading} -
+ {:else if browseSnapshots.length === 0} -

No snapshots in this repository.

+

No snapshots in this repository.

{:else} - {#snippet sortHead(field: BrowseSortField, label: string, extra = '')} - - + {:else if column.id === 'name'} + + {group.name} + {group.count} + + {:else if column.id === 'type'} +
+ {#if group.type === 'stack'} + + {:else} + + {/if} +
+ {:else if column.id === 'environment'} + {#if group.env && !('missing' in group.env)} + + + {group.env.name} + + {:else if group.env} + missing {:else} - + — {/if} - -
- {/snippet} - - - - {@render sortHead('shortId', 'ID', 'w-24')} - {@render sortHead('time', 'Created')} - {@render sortHead('type', 'Type', 'w-16')} - {@render sortHead('name', 'Name')} - Browse - - - - {#each sortedBrowseSnapshots as snap} - {@const name = snapName(snap)} - {@const type = snapType(snap)} - { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = name; snapshotBrowseOpen = true; }}> - {snap.shortId} - {formatDateTime(snap.time)} - - {#if type === 'container'} - - {:else if type === 'stack'} - - {:else} - - {/if} - - {name} - - - - - - - {/each} - - + {:else if column.id === 'latest'} + {#if group.latest} + {formatDateTime(group.latest)} ({formatRelativeTime(group.latest)}) + {:else} + — + {/if} + {:else if column.id === 'snapshots'} + {group.count} + {/if} + {/snippet} + + {#snippet expandedRow(group)} +
+
+ + + + ID + Created + Browse + + + + {#each group.snapshots as snap} + { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = group.name; snapshotBrowseOpen = true; }}> + {snap.shortId} + {formatDateTime(snap.time)} ({formatRelativeTime(snap.time)}) + + + + + + + {/each} + + +
+
+ {/snippet} + {/if}