From daf33a44b21e92f8d0c40dafec11a02350d19c76 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Thu, 24 Sep 2026 13:16:13 -0700 Subject: [PATCH] The datasets table is a snapshot explorer, and snapshots can be deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed Datasets to Snapshots and turned the columns around to match: how many each dataset has and what they cost come first, the dataset's own size after. Opening one lists its snapshots with when it was taken, when it expires — read from the TTL in its own name, so no server call is needed — its size, and a Delete button. Deleting takes the same-named snapshot on descendant datasets too, which is how a recursive schedule made it, and says so before it does anything. Dropped "What would it delete?" from the schedule cards, and the endpoint behind it. Run now says plainly that expired snapshots go at the same time, which is the whole of what the preview was telling anyone. Co-Authored-By: Claude Opus 5 --- internal/api/server.go | 1 - internal/api/zfs.go | 18 --------- web/src/ZFS.tsx | 86 ++++++++++++++++++++++++++---------------- web/src/api.ts | 1 - web/src/styles.css | 11 +++++- 5 files changed, 63 insertions(+), 54 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 5482678..d145c40 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -254,7 +254,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview)) m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut)) m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete)) - m.HandleFunc("GET /api/zfs/schedules/{id}/expired", s.auth(s.zfsPreview)) m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow)) m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete)) m.HandleFunc("GET /api/space", s.auth(s.spaceOverview)) diff --git a/internal/api/zfs.go b/internal/api/zfs.go index e4fb70f..fcf41bb 100644 --- a/internal/api/zfs.go +++ b/internal/api/zfs.go @@ -217,24 +217,6 @@ func (s *Server) zfsPolicy(id string) (storage.Policy, bool) { return storage.Policy{}, false } -// zfsPreview is what this schedule would destroy if it ran now. Nothing is deleted. -func (s *Server) zfsPreview(w http.ResponseWriter, r *http.Request) { - p, ok := s.zfsPolicy(r.PathValue("id")) - if !ok { - jsonErr(w, 404, "no such schedule") - return - } - doomed, err := s.storage.Doomed(r.Context(), &p) - if err != nil { - jsonErr(w, 500, err.Error()) - return - } - if doomed == nil { - doomed = []storage.Snapshot{} - } - jsonOK(w, 200, map[string]any{"expired": doomed}) -} - func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") p, ok := s.zfsPolicy(id) diff --git a/web/src/ZFS.tsx b/web/src/ZFS.tsx index 8b6084b..dc781f1 100644 --- a/web/src/ZFS.tsx +++ b/web/src/ZFS.tsx @@ -68,18 +68,43 @@ function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () ) } +// A snapshot's name carries when it was taken and how long it lives — 2026-09-24_02.30.01--6w +// — so the explorer can say when each one goes without asking the server anything. +function snapMeta(full: string): { taken?: Date; expires?: Date } { + const snap = full.split('@')[1] ?? '' + const [body, ttl] = snap.split('--') + const m = /(\d{4})-(\d{2})-(\d{2})_(\d{2})\.(\d{2})\.(\d{2})$/.exec(body) + if (!m) return {} + const taken = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]) + const a = /^(\d+)([mhdw])$/.exec(ttl ?? '') + if (!a) return { taken } + const ms = { m: 60e3, h: 3600e3, d: 86400e3, w: 7 * 86400e3 }[a[2]]! + return { taken, expires: new Date(taken.getTime() + +a[1] * ms) } +} + +// One dataset in the snapshot explorer: how many it has and what they cost, opening into +// the snapshots themselves, each of which can be taken now or deleted. function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) { const [snaps, setSnaps] = useState(null) const [open, setOpen] = useState(false) + const reload = useCallback(async () => { + try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } + }, [d.name]) async function toggle() { setOpen(!open) - if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } } + if (!open && !snaps) await reload() } async function snapshot() { - const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2') + const def = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '.') const name = await askText(`Snapshot name for ${d.name}@`, def) if (!name) return - try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() } + try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); await reload(); onChanged() } + catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + } + async function destroy(s: ZFSSnapshot) { + const short = s.name.split('@')[1] + if (!await askYes(`Delete ${s.name}? It is removed from this dataset and from every dataset beneath it that has a snapshot of the same name. This cannot be undone.`, 'Delete')) return + try { await api.deleteZfsSnapshot(s.name, true); toast(`${short} deleted`); await reload(); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const depth = d.name.split('/').length - 1 @@ -87,18 +112,29 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange <>
{open ? '▾' : '▸'}{d.name.split('/').pop()}{d.type === 'volume' && zvol} - {fmtBytes(d.used)} - {fmtBytes(d.refer)} + {d.snapshots} {d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'} - {d.snapshots} - {d.compressratio} + {fmtBytes(d.used)} + {fmtBytes(d.refer)} {d.mount}
{open && (
-
Snapshot nowavail {fmtBytes(d.avail)}
+
Snapshot now{d.snapshots} snapshot{d.snapshots === 1 ? '' : 's'} · {fmtBytes(d.avail)} free on this dataset
{snaps === null ?
listing…
: snaps.length === 0 ?
No snapshots.
: ( -
{snaps.slice().reverse().map((s) =>
{s.name.split('@')[1]}{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}
)}
+
{snaps.slice().reverse().map((s) => { + const { taken, expires } = snapMeta(s.name) + const gone = expires && expires.getTime() < Date.now() + return ( +
+ {s.name.split('@')[1]} + {taken ? taken.toLocaleString() : new Date(s.created).toLocaleString()} + {expires ? (gone ? 'expired' : `expires ${expires.toLocaleDateString()}`) : 'kept'} + {fmtBytes(s.used)} + destroy(s)}>Delete +
+ ) + })}
)}
)} @@ -126,18 +162,11 @@ function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; on type ExternalJob = ZFSExternal function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) { - const [expired, setExpired] = useState(null) const [busy, setBusy] = useState('') - const preview = async () => { - setBusy('preview') - try { setExpired((await api.zfsExpired(p.id)).expired) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') } - } const run = async () => { - const n = expired?.length - const warn = n ? ` It will also destroy ${n} expired snapshot${n === 1 ? '' : 's'}.` : '' - if (!await askYes(`Take a snapshot of ${p.dataset} now?${warn}`, 'Run now')) return + if (!await askYes(`Take a snapshot of ${p.dataset} now? Snapshots past their retention are removed at the same time, as they would be on the schedule.`, 'Run now')) return setBusy('run') - try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); setExpired(null); onChanged() } + try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') } } const remove = async () => { @@ -165,20 +194,7 @@ function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy {p.keep_min > 0 &&
Flooralways keep the newest {p.keep_min}
} {last.error &&
{last.error}
} - {expired && ( -
- {expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : ( - <> - {expired.length} snapshot{expired.length === 1 ? '' : 's'} would be destroyed by a run now: -
- {expired.map((s) =>
{s.name.split('@')[1]}{new Date(s.created).toLocaleDateString()} · {fmtBytes(s.used)}
)} -
- - )} -
- )}
- What would it delete? Run now {p.enabled ? 'Pause' : 'Resume'} @@ -326,8 +342,14 @@ export default function ZFS() { setEditing(policy)} /> ))}
-

Datasets

used · referenced · in snapshots · count · compression · mount
+

Snapshots

+ {zfs.datasets.reduce((a, d) => a + d.snapshots, 0)} across {zfs.datasets.length} datasets · open one to browse, take or delete +
+
+ DatasetSnapshotsThey use + Dataset usesReferencedMount +
{zfs.datasets.map((d) => )}
diff --git a/web/src/api.ts b/web/src/api.ts index 8ff68c0..12ca80f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -187,7 +187,6 @@ export const api = { zfsOverview: () => req('GET', '/api/zfs'), saveZfsSchedule: (p: Partial) => req('PUT', '/api/zfs/schedules', p), deleteZfsSchedule: (id: string) => req('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`), - zfsExpired: (id: string) => req<{ expired: ZFSSnapshot[] }>('GET', `/api/zfs/schedules/${encodeURIComponent(id)}/expired`), runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`), deleteZfsSnapshot: (name: string, recursive: boolean) => req('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }), zfsSnapshots: (dataset: string) => req('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`), diff --git a/web/src/styles.css b/web/src/styles.css index a881c58..4513dd9 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -285,14 +285,21 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); the fallback above stays a fallback. */ .disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); } .datasets { padding: 0; overflow-x: auto; } -.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } /* the columns and gaps add up to 692: at 640 the row ended before its own cells did */ +.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 90px 90px 90px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } .ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; } .ds-row .num { text-align: right; } .ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; } .ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; } .snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; } -.snap { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; } +.snap { display: grid; grid-template-columns: minmax(150px, 1fr) 150px 120px 70px auto; gap: 10px; align-items: center; font-size: 12px; padding: 2px 0; border-bottom: 1px solid var(--border); } +.snap .snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.snap .snap-when, .snap .snap-exp { white-space: nowrap; } +.snap .snap-exp.warn { color: var(--s2); } +.snap .num { text-align: right; } +.snap .snap-del { padding: 0 6px; font-size: 11px; opacity: .55; } +.snap:hover .snap-del { opacity: 1; } +@media (max-width: 800px) { .snap { grid-template-columns: minmax(120px, 1fr) auto auto; } .snap .snap-when, .snap .snap-exp { display: none; } } /* files */ .files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }