backups ui: group repo snapshots in a datagrid, gate cancel when unstoppable, relative timestamps

This commit is contained in:
jarek committed 2026-07-31 10:10:55 +02:00
1 parent e3c07f32da
commit 8a32bc377d
5 files changed
+221 -93

No files matched your search

+14
View File
@@ -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<GridId, ColumnConfig[]> = {
environments: environmentColumns,
backupDestinations: backupDestinationColumns,
backups: backupColumns,
repoSnapshots: repoSnapshotColumns,
vulnerabilities: vulnerabilityColumns
};
+1 -1
View File
@@ -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;
+37 -16
View File
@@ -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<string[]>([]);
let logModalError = $state('');
let logModalConfigId = $state<number | null>(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<string | null>(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'}
<div class="flex justify-center">
{#if config.type === 'container'}
<Tooltip.Root><Tooltip.Trigger><Box class="w-3 h-3 text-muted-foreground" /></Tooltip.Trigger><Tooltip.Content class="whitespace-nowrap">Container backup</Tooltip.Content></Tooltip.Root>
<Tooltip.Root><Tooltip.Trigger><Box class="w-3 h-3 text-muted-foreground" /></Tooltip.Trigger><Tooltip.Content>Container backup</Tooltip.Content></Tooltip.Root>
{:else}
<Tooltip.Root><Tooltip.Trigger><Layers class="w-3 h-3 text-muted-foreground" /></Tooltip.Trigger><Tooltip.Content class="whitespace-nowrap">Stack backup</Tooltip.Content></Tooltip.Root>
<Tooltip.Root><Tooltip.Trigger><Layers class="w-3 h-3 text-muted-foreground" /></Tooltip.Trigger><Tooltip.Content>Stack backup</Tooltip.Content></Tooltip.Root>
{/if}
</div>
{:else if column.id === 'environment'}
@@ -794,7 +815,7 @@
<span class="truncate">{config.destinationName}</span>
</div>
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap"><span class="font-mono text-xs">{config.destinationRepository}</span></Tooltip.Content>
<Tooltip.Content><span class="font-mono text-xs">{config.destinationRepository}</span></Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="text-xs text-muted-foreground">—</span>
@@ -812,7 +833,7 @@
</div>
{:else if column.id === 'lastBackup'}
{#if config.lastBackupAt}
<span class="text-xs">{formatDateTime(config.lastBackupAt)}</span>
<span class="text-xs">{formatDateTime(config.lastBackupAt)} <span class="text-muted-foreground opacity-60">({formatRelativeTime(config.lastBackupAt)})</span></span>
{:else}
<span class="text-xs text-muted-foreground">Never</span>
{/if}
@@ -834,15 +855,15 @@
{:else if column.id === 'status'}
<div class="flex items-center justify-center">
{#if config.isOrphan}
<Tooltip.Root><Tooltip.Trigger><AlertCircle class="w-3 h-3 text-amber-500" /></Tooltip.Trigger><Tooltip.Content class="whitespace-nowrap">No schedule — snapshots found in repository</Tooltip.Content></Tooltip.Root>
<Tooltip.Root><Tooltip.Trigger><AlertCircle class="w-3 h-3 text-amber-500" /></Tooltip.Trigger><Tooltip.Content>No schedule — snapshots found in repository</Tooltip.Content></Tooltip.Root>
{:else if runningBackup === config.id}
<Tooltip.Root><Tooltip.Trigger><Loader2 class="w-3 h-3 text-primary animate-spin" /></Tooltip.Trigger><Tooltip.Content class="whitespace-nowrap">Backup in progress</Tooltip.Content></Tooltip.Root>
<Tooltip.Root><Tooltip.Trigger><Loader2 class="w-3 h-3 text-primary animate-spin" /></Tooltip.Trigger><Tooltip.Content>Backup in progress</Tooltip.Content></Tooltip.Root>
{:else if config.lastBackupStatus === 'success'}
<Tooltip.Root><Tooltip.Trigger><CheckCircle class="w-3 h-3 text-green-500" /></Tooltip.Trigger><Tooltip.Content class="whitespace-nowrap">Last backup succeeded</Tooltip.Content></Tooltip.Root>
<Tooltip.Root><Tooltip.Trigger><CheckCircle class="w-3 h-3 text-green-500" /></Tooltip.Trigger><Tooltip.Content>Last backup succeeded</Tooltip.Content></Tooltip.Root>
{:else if config.lastBackupStatus === 'failed'}
<Tooltip.Root><Tooltip.Trigger><XCircle class="w-3 h-3 text-destructive" /></Tooltip.Trigger><Tooltip.Content class="whitespace-nowrap">Last backup failed</Tooltip.Content></Tooltip.Root>
<Tooltip.Root><Tooltip.Trigger><XCircle class="w-3 h-3 text-destructive" /></Tooltip.Trigger><Tooltip.Content>Last backup failed</Tooltip.Content></Tooltip.Root>
{:else}
<Tooltip.Root><Tooltip.Trigger><AlertCircle class="w-3 h-3 text-muted-foreground" /></Tooltip.Trigger><Tooltip.Content class="whitespace-nowrap">No backup run yet</Tooltip.Content></Tooltip.Root>
<Tooltip.Root><Tooltip.Trigger><AlertCircle class="w-3 h-3 text-muted-foreground" /></Tooltip.Trigger><Tooltip.Content>No backup run yet</Tooltip.Content></Tooltip.Root>
{/if}
</div>
{:else if column.id === 'actions'}
@@ -936,7 +957,7 @@
{@const isDiffPending = diffPending?.snapshot.id === snapshot.id}
<tr class="border-b last:border-0 hover:bg-muted/30 text-xs {isDiffPending ? 'bg-primary/10' : ''}">
<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)}</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">
{#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}
/>
<SnapshotDiffModal
+5 -7
View File
@@ -3,7 +3,8 @@
import { toast } from 'svelte-sonner';
import { Loader2, FolderOpen, RotateCcw, Trash2, ArrowLeftRight, HardDrive, Archive, RefreshCw } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { formatDateTime } from '$lib/stores/settings';
import { LoadingState } from '$lib/components/ui/loading-state';
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
import { getRepoTypeIcon } from '$lib/utils/backup';
import SnapshotBrowser from './SnapshotBrowser.svelte';
@@ -225,12 +226,9 @@
<!-- First load only: a quiet spinner, no skeleton. On a refresh where rows already
exist we fall through and keep the real table visible (the header refresh icon
already signals progress). Matches the History tab's empty/loading treatment. -->
<div class="flex flex-col items-center justify-center py-10 text-center">
<Loader2 class="mb-3 h-8 w-8 animate-spin text-muted-foreground/40" />
<p class="text-sm text-muted-foreground">Loading snapshots…</p>
</div>
<LoadingState class="min-h-[50vh]" label="Loading snapshots..." />
{:else if snapshots.length === 0}
<div class="flex flex-col items-center justify-center py-10 text-center">
<div class="flex min-h-[60vh] flex-col items-center justify-center py-10 text-center">
<Archive class="mb-3 h-10 w-10 text-muted-foreground/40" />
<p class="text-sm text-muted-foreground">No snapshots yet for {targetName}.</p>
<p class="mt-1 text-xs text-muted-foreground">Run a backup from the Schedules tab to create one.</p>
@@ -276,7 +274,7 @@
{@const st = stats.get(s.id)}
<tr class="border-b text-xs last:border-0 hover:bg-muted/30 {isDiffPending ? 'bg-primary/10' : ''}">
<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)}</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">
<span class="flex items-center gap-1.5"><RepoIcon class="h-3.5 w-3.5 text-primary/70" />{s._destinationName}</span>
+164 -69
View File
@@ -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<Destination[]>([]);
let configs = $state<BackupConfig[]>([]);
let environments = $state<Array<{ id: number; name: string; icon?: string }>>([]);
let loading = $state(true);
let showModal = $state(false);
let editingDest = $state<Destination | null>(null);
@@ -204,33 +207,83 @@
let browseSnapshots = $state<any[]>([]);
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<Set<unknown>>(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<string, SnapGroup>();
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 @@
/>
<Dialog.Root bind:open={browseOpen}>
<Dialog.Content class="max-w-2xl h-[70vh] flex flex-col overflow-hidden">
<Dialog.Content class="max-w-6xl w-[calc(100vw-4rem)] h-[88vh] flex flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="w-5 h-5" />
@@ -611,60 +666,100 @@
<span class="text-amber-600 dark:text-amber-400">{browseDestName}</span>
</Dialog.Title>
</Dialog.Header>
<div class="flex-1 overflow-y-auto">
{#if !browseLoading && browseSnapshots.length > 0}
<div class="flex gap-2 pb-3 flex-shrink-0">
<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" />
</div>
{/if}
<div class="flex-1 min-h-0">
{#if browseLoading}
<div class="flex items-center justify-center py-12"><Loader2 class="w-6 h-6 animate-spin text-muted-foreground" /></div>
<LoadingState class="h-full" label="Loading snapshots..." />
{:else if browseSnapshots.length === 0}
<p class="text-sm text-muted-foreground p-4">No snapshots in this repository.</p>
<div class="flex h-full items-center justify-center"><p class="text-sm text-muted-foreground">No snapshots in this repository.</p></div>
{:else}
{#snippet sortHead(field: BrowseSortField, label: string, extra = '')}
<Table.Head class={extra}>
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground transition-colors" onclick={() => toggleBrowseSort(field)}>
{label}
{#if browseSort.field === field}
{#if browseSort.dir === 'asc'}<ArrowUp class="w-3 h-3" />{:else}<ArrowDown class="w-3 h-3" />{/if}
<DataGrid
data={browseGroups}
keyField="key"
gridId="repoSnapshots"
rowHeight={36}
sortState={{ field: browseGridSort.field, direction: browseGridSort.direction }}
onSortChange={(s) => browseGridSort = { field: s.field, direction: s.direction }}
expandable
bind:expandedKeys={browseExpandedKeys}
onRowClick={(g) => toggleBrowseGroup(g)}
class="border-none"
wrapperClass="border rounded-lg h-full"
>
{#snippet cell(column, group, rowState)}
{#if column.id === 'expand'}
<button type="button" class="p-0.5 hover:bg-muted rounded transition-colors" onclick={(e) => { e.stopPropagation(); toggleBrowseGroup(group); }}>
{#if rowState.isExpanded}<ChevronDown class="w-3 h-3" />{:else}<ChevronRight class="w-3 h-3" />{/if}
</button>
{:else if column.id === 'name'}
<span class="inline-flex items-center gap-1.5 min-w-0">
<span class="text-xs font-medium truncate">{group.name}</span>
<span class="shrink-0 rounded-full bg-primary/15 px-1.5 text-2xs text-primary">{group.count}</span>
</span>
{:else if column.id === 'type'}
<div class="flex justify-center">
{#if group.type === 'stack'}
<Layers class="w-3.5 h-3.5 text-purple-500" />
{:else}
<Box class="w-3.5 h-3.5 text-blue-500" />
{/if}
</div>
{:else if column.id === 'environment'}
{#if group.env && !('missing' in group.env)}
<span class="inline-flex items-center gap-1.5 text-xs truncate">
<EnvironmentIcon icon={group.env.icon} envId={group.env.id} class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="truncate">{group.env.name}</span>
</span>
{:else if group.env}
<Badge variant="secondary" class="text-2xs font-normal text-muted-foreground">missing</Badge>
{:else}
<ArrowUpDown class="w-3 h-3 opacity-40" />
<span class="text-xs text-muted-foreground">—</span>
{/if}
</button>
</Table.Head>
{/snippet}
<Table.Root>
<Table.Header>
<Table.Row>
{@render sortHead('shortId', 'ID', 'w-24')}
{@render sortHead('time', 'Created')}
{@render sortHead('type', 'Type', 'w-16')}
{@render sortHead('name', 'Name')}
<Table.Head class="w-12 text-right">Browse</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each sortedBrowseSnapshots as snap}
{@const name = snapName(snap)}
{@const type = snapType(snap)}
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => { snapshotBrowseDestId = browseDestId; snapshotBrowseId = snap.id; snapshotBrowseName = name; snapshotBrowseOpen = true; }}>
<Table.Cell class="font-mono text-xs text-muted-foreground">{snap.shortId}</Table.Cell>
<Table.Cell class="text-xs">{formatDateTime(snap.time)}</Table.Cell>
<Table.Cell>
{#if type === 'container'}
<Box class="w-3.5 h-3.5 text-blue-500" />
{:else if type === 'stack'}
<Layers class="w-3.5 h-3.5 text-purple-500" />
{:else}
<Box class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</Table.Cell>
<Table.Cell class="text-xs">{name}</Table.Cell>
<Table.Cell class="text-right">
<span class="inline-flex p-1 rounded hover:bg-muted transition-colors text-muted-foreground" title="Browse snapshot content">
<FolderOpen class="w-3.5 h-3.5" />
</span>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else if column.id === 'latest'}
{#if group.latest}
<span class="text-xs text-muted-foreground">{formatDateTime(group.latest)} <span class="opacity-60">({formatRelativeTime(group.latest)})</span></span>
{:else}
<span class="text-xs text-muted-foreground">—</span>
{/if}
{:else if column.id === 'snapshots'}
<span class="text-xs text-muted-foreground">{group.count}</span>
{/if}
{/snippet}
{#snippet expandedRow(group)}
<div class="px-8 py-2">
<div class="max-h-64 overflow-y-auto pr-2 rounded-md border bg-muted/20">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-background">
<Table.Row>
<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="w-16 py-1.5 text-xs text-right" style="padding-right:8px">Browse</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#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.Cell class="font-mono text-xs text-muted-foreground py-1" style="padding-left:8px">{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-right py-1">
<span class="inline-flex p-1 rounded hover:bg-muted transition-colors text-muted-foreground" title="Browse snapshot content">
<FolderOpen class="w-3.5 h-3.5" />
</span>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</div>
{/snippet}
</DataGrid>
{/if}
</div>
<Dialog.Footer class="pt-4">