// ZFS: pools, datasets, snapshots, and a schedule for taking and expiring them. // // Split out of the Disks tab on 2026-09-24. Disks is the hardware; this is the filesystem // on top of it, which has enough of its own to say. // // On the schedule: `grid` has run zfSnap from root cron since before Ham Lab existed, and it // works. So this reads and writes the same snapshot names — `2026-09-24_02.30.01--6w`, with // the retention in the name — rather than inventing a second convention for the two tools to // disagree over. An existing cron job is detected and shown, so the page tells the truth // about the machine whether Ham Lab or something else is doing the work. import { FormEvent, useCallback, useEffect, useState } from 'react' import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api' import { fmtBytes } from './fmt' import { useRefreshMs } from './prefs' import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui' const ago = (t?: string | number) => { if (!t) return 'never' const s = Math.max(0, (Date.now() - new Date(t).getTime()) / 1000) return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago` } const soon = (t?: string) => { if (!t) return '—' const s = (new Date(t).getTime() - Date.now()) / 1000 return s < 60 ? 'any moment' : s < 3600 ? `in ${Math.round(s / 60)} min` : s < 86400 ? `in ${Math.round(s / 3600)} h` : `in ${Math.round(s / 86400)} d` } const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] const describe = (p: ZFSPolicy) => { const when = p.every === 'custom' ? `on ${p.cron}` : p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}` : p.every === 'daily' ? `every day at ${p.at}` : p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}` : `on day ${p.day} of the month at ${p.at}` return `${when} · keep ${p.keep_for}${p.recursive ? ' · recursive' : ''}` } // ---- pools and datasets (moved from the Disks tab) ---- function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) { const scrubbing = /scrub in progress/i.test(p.scan ?? '') const paused = /scrub paused/i.test(p.scan ?? '') const [cfg, setCfg] = useState(false) const act = (a: 'start' | 'pause' | 'stop') => async () => { try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } return (
{p.name} {p.health} {fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}%
= 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} />
Free{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}
{p.scan &&
Scan{p.scan}
} {p.errors &&
Errors{p.errors}
}
{!scrubbing && {paused ? 'Resume scrub' : 'Scrub'}} {scrubbing && Pause scrub} {(scrubbing || paused) && Stop scrub}
{cfg &&
{p.config}
}
) } // 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) await reload() } async function snapshot() { 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`); 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 return ( <>
{open ? '▾' : '▸'}{d.name.split('/').pop()}{d.type === 'volume' && zvol} {d.snapshots} {d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'} {fmtBytes(d.used)} {fmtBytes(d.refer)} {d.mount}
{open && (
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) => { 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
) })}
)}
)} ) } // ---- schedules ---- function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; onAdopt: () => void }) { return (
0 ? ' attn' : '')} style={{ marginBottom: 8 }}> {x.tool} is already taking snapshots on this machine, from cron as {x.user}:
{x.schedule}   {x.command}
in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''} {mine > 0 ?
A Ham Lab schedule is running as well. Both use the same names and both only remove snapshots that have passed the expiry written into them, so they will not fight — but they will take twice as many. Keep one.
:
Adopt as a schedule Copies it into a Ham Lab schedule you can edit here. Turn the old cron job off on the Jobs tab once you are happy with it.
}
) } type ExternalJob = ZFSExternal function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) { const [busy, setBusy] = useState('') const run = async () => { 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` : ''}`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') } } const remove = async () => { if (!await askYes(`Remove the schedule "${p.name}"? The snapshots it already took are left exactly as they are.`, 'Remove')) return try { await api.deleteZfsSchedule(p.id); toast('schedule removed'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const toggle = async () => { try { await api.saveZfsSchedule({ ...p, enabled: !p.enabled }); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } return (
{p.name || p.dataset} {!p.enabled && paused} {p.enabled ? soon(next) : 'not scheduled'}
Dataset{p.dataset}
When{describe(p)}
Cron{cron}
Last run{ago(last.at)}{last.pruned?.length ? ` · ${last.pruned.length} expired removed` : ''}
{last.taken &&
Last snapshot{last.taken.split('@')[1]}
} {p.keep_min > 0 &&
Flooralways keep the newest {p.keep_min}
}
{last.error &&
{last.error}
}
Run now {p.enabled ? 'Pause' : 'Resume'} Remove
) } const BLANK: Partial = { name: '', dataset: '', recursive: true, every: 'daily', at: '02:30', weekday: 0, day: 1, keep_for: '6w', keep_min: 1, enabled: true } function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial; datasets: string[]; onClose: () => void; onSaved: () => void }) { const [f, setF] = useState>({ ...BLANK, ...p }) const [err, setErr] = useState('') useEscape(onClose) const set = (k: keyof ZFSPolicy, v: unknown) => setF((x) => ({ ...x, [k]: v })) const save = async (e: FormEvent) => { e.preventDefault(); setErr('') try { await api.saveZfsSchedule({ ...f, name: (f.name || '').trim() || f.dataset }); onSaved() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } return (
e.stopPropagation()} onSubmit={save}>

{f.id ? 'Edit schedule' : 'New snapshot schedule'}

{f.every === 'custom' ? ( ) : ( )} {f.every === 'weekly' && ( )} {f.every === 'monthly' && ( )} {err &&
{err}
}
) } // adopt reads an existing cron job into the fields of a new schedule, so replacing it is a // matter of checking the numbers rather than retyping them. zfSnap's own flags are read // where they can be: -a is the retention, -r is recursive. function adopt(x: ExternalJob, fallbackDataset: string): Partial { const cmd = x.command const keep = /-a\s+(\d+[mhdwMy])/.exec(cmd)?.[1] ?? /--\s*(\d+[mhdw])\b/.exec(cmd)?.[1] ?? '6w' const f = x.schedule.trim().split(/\s+/) const min = Number(f[0]), hour = Number(f[1]) const simple = f.length === 5 && Number.isInteger(min) && Number.isInteger(hour) && f[2] === '*' && f[3] === '*' && f[4] === '*' const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset return { ...BLANK, name: `${x.tool} (adopted)`, dataset: ds, recursive: /\s-\w*r/.test(cmd) || true, every: simple ? 'daily' : 'custom', at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30', cron: simple ? '' : x.schedule.trim(), keep_for: keep, keep_min: 1, enabled: true, } } // ---- page ---- export default function ZFS() { const [ov, setOv] = useState(null) const [err, setErr] = useState('') const [editing, setEditing] = useState | null>(null) const rate = useRefreshMs() const load = useCallback(async () => { try { setOv(await api.zfsOverview()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) useEffect(() => { load() }, [load]) useEffect(() => { const t = setInterval(load, Math.max(5000, rate * 10)); return () => clearInterval(t) }, [load, rate]) if (err && !ov) return
{err}
if (!ov) return
const zfs: ZFSInfo = ov.zfs const datasets = zfs.datasets.filter((d) => d.type !== 'volume').map((d) => d.name) const mine = ov.policies.filter((x) => x.policy.enabled).length if (!zfs.available) return
{zfs.message || 'No ZFS on this host.'}
return (

Pools

{zfs.pools.length === 0 &&
ZFS is present but no pools are imported.
}
{zfs.pools.map((p) => )}

Snapshot schedules

setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule
{ov.external && setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />} {ov.policies.length === 0 && !ov.external &&
Nothing is taking snapshots on a schedule. Snapshots are written the way zfSnap writes them, with the retention in the name, so anything already here is understood.
}
{ov.policies.map(({ policy, cron, next, last }) => ( setEditing(policy)} /> ))}

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) => )}
{editing && p.name)} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
) }