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); }