The datasets table is a snapshot explorer, and snapshots can be deleted
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c3854bafda
commit
daf33a44b2
+54
-32
@@ -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<ZFSSnapshot[] | null>(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
|
||||
<>
|
||||
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
|
||||
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
|
||||
<span className="num">{fmtBytes(d.used)}</span>
|
||||
<span className="num muted">{fmtBytes(d.refer)}</span>
|
||||
<span className="num">{d.snapshots}</span>
|
||||
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
|
||||
<span className="num muted">{d.snapshots}</span>
|
||||
<span className="num muted">{d.compressratio}</span>
|
||||
<span className="num muted">{fmtBytes(d.used)}</span>
|
||||
<span className="num muted">{fmtBytes(d.refer)}</span>
|
||||
<span className="ds-mount muted">{d.mount}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="ds-snaps">
|
||||
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted">avail {fmtBytes(d.avail)}</span></div>
|
||||
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted small">{d.snapshots} snapshot{d.snapshots === 1 ? '' : 's'} · {fmtBytes(d.avail)} free on this dataset</span></div>
|
||||
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
|
||||
<div className="snap-list">{snaps.slice().reverse().map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}</span></div>)}</div>
|
||||
<div className="snap-list">{snaps.slice().reverse().map((s) => {
|
||||
const { taken, expires } = snapMeta(s.name)
|
||||
const gone = expires && expires.getTime() < Date.now()
|
||||
return (
|
||||
<div key={s.name} className="snap">
|
||||
<span className="mono snap-name" title={s.name}>{s.name.split('@')[1]}</span>
|
||||
<span className="muted snap-when">{taken ? taken.toLocaleString() : new Date(s.created).toLocaleString()}</span>
|
||||
<span className={'muted snap-exp' + (gone ? ' warn' : '')}>{expires ? (gone ? 'expired' : `expires ${expires.toLocaleDateString()}`) : 'kept'}</span>
|
||||
<span className="num muted">{fmtBytes(s.used)}</span>
|
||||
<Btn className="btn danger snap-del" onClick={() => destroy(s)}>Delete</Btn>
|
||||
</div>
|
||||
)
|
||||
})}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -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<ZFSSnapshot[] | null>(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 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>}
|
||||
</div>
|
||||
{last.error && <div className="error" style={{ marginTop: 8 }}>{last.error}</div>}
|
||||
{expired && (
|
||||
<div className="notice" style={{ marginTop: 8 }}>
|
||||
{expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : (
|
||||
<>
|
||||
<b>{expired.length}</b> snapshot{expired.length === 1 ? '' : 's'} would be destroyed by a run now:
|
||||
<div className="snap-list" style={{ marginTop: 6, maxHeight: 180, overflow: 'auto' }}>
|
||||
{expired.map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleDateString()} · {fmtBytes(s.used)}</span></div>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn" busy={busy === 'preview'} onClick={preview}>What would it delete?</Btn>
|
||||
<Btn className="btn on" busy={busy === 'run'} onClick={run}>Run now</Btn>
|
||||
<button className="btn" onClick={onEdit}>Edit</button>
|
||||
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
|
||||
@@ -326,8 +342,14 @@ export default function ZFS() {
|
||||
<ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||
))}</div>
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Datasets</h2><span className="spacer" /><span className="muted small">used · referenced · in snapshots · count · compression · mount</span></div>
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshots</h2><span className="spacer" />
|
||||
<span className="muted small">{zfs.datasets.reduce((a, d) => a + d.snapshots, 0)} across {zfs.datasets.length} datasets · open one to browse, take or delete</span>
|
||||
</div>
|
||||
<section className="card ds-table">
|
||||
<div className="ds-row head">
|
||||
<span>Dataset</span><span className="num">Snapshots</span><span className="num">They use</span>
|
||||
<span className="num">Dataset uses</span><span className="num">Referenced</span><span>Mount</span>
|
||||
</div>
|
||||
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user