Pools, datasets and snapshots move out of Disks — Disks is the hardware, this is the filesystem on top of it. New: snapshot schedules, hourly to monthly, with a retention period and a floor that keeps the newest few whatever their age. The naming is zfSnap's, deliberately. grid has run zfSnap from root cron since before this repo existed and it works, so rather than invent a second convention this writes and reads exactly what zfSnap does: 2026-09-24_02.30.01--6w, with the retention in the name. Ham Lab can therefore show and expire the snapshots zfSnap already took, the two cannot disagree about what a snapshot is, and changing a schedule never re-dates old ones — each carries the rule it was taken under. An existing cron job is detected and shown on the page, with a warning if a Ham Lab schedule is running as well. Nothing is destroyed unseen. Every schedule answers what it would delete, with the list, before Run now touches anything, and only names that parse as a stamp and a TTL are ever candidates. A snapshot taken by hand is invisible to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
18 KiB
TypeScript
304 lines
18 KiB
TypeScript
// 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, 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 === '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 (
|
|
<section className="card">
|
|
<div className="sub-head">
|
|
<span className={'dot ' + (p.health === 'ONLINE' ? 'ok' : p.health === 'DEGRADED' ? 'warn' : 'bad')} />
|
|
<span className="dname">{p.name}</span>
|
|
<span className="badge">{p.health}</span>
|
|
<span className="spacer" />
|
|
<span className="num">{fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}%</span>
|
|
</div>
|
|
<Meter frac={p.cap / 100} cls={p.cap >= 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} />
|
|
<div className="props" style={{ marginTop: 8 }}>
|
|
<div><span className="k">Free</span><span>{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}</span></div>
|
|
{p.scan && <div><span className="k">Scan</span><span style={{ whiteSpace: 'normal' }}>{p.scan}</span></div>}
|
|
{p.errors && <div><span className="k">Errors</span><span className={p.errors.startsWith('No known') ? '' : 'warn-text'}>{p.errors}</span></div>}
|
|
</div>
|
|
<div className="svc-actions">
|
|
{!scrubbing && <Btn className="btn" confirm={`Start a scrub of ${p.name}? It reads the whole pool and can take many hours.`} onClick={act('start')}>{paused ? 'Resume scrub' : 'Scrub'}</Btn>}
|
|
{scrubbing && <Btn className="btn" onClick={act('pause')}>Pause scrub</Btn>}
|
|
{(scrubbing || paused) && <Btn className="btn" confirm="Stop the scrub?" onClick={act('stop')}>Stop scrub</Btn>}
|
|
<button className="btn" onClick={() => setCfg(!cfg)}>{cfg ? 'Hide vdevs' : 'Vdevs'}</button>
|
|
</div>
|
|
{cfg && <pre className="unit-file">{p.config}</pre>}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
|
|
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
|
|
const [open, setOpen] = useState(false)
|
|
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([]) } }
|
|
}
|
|
async function snapshot() {
|
|
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
|
|
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() }
|
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
|
}
|
|
const depth = d.name.split('/').length - 1
|
|
return (
|
|
<>
|
|
<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 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="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>
|
|
{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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ---- schedules ----
|
|
|
|
function ExternalNotice({ x, mine }: { x: ExternalJob; mine: number }) {
|
|
return (
|
|
<div className={'notice' + (mine > 0 ? ' attn' : '')} style={{ marginBottom: 8 }}>
|
|
<b>{x.tool}</b> is already taking snapshots on this machine, from cron as <b>{x.user}</b>:
|
|
<div className="mono small" style={{ margin: '4px 0', wordBreak: 'break-all' }}>{x.schedule} {x.command}</div>
|
|
<span className="muted">in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''}</span>
|
|
{mine > 0
|
|
? <div className="warn" style={{ marginTop: 6 }}>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.</div>
|
|
: <div className="muted small" style={{ marginTop: 6 }}>Ham Lab reads the snapshots it makes and shows when each expires. Add a schedule here only if you mean to replace it; edit this one on the Jobs tab.</div>}
|
|
</div>
|
|
)
|
|
}
|
|
type ExternalJob = ZFSExternal
|
|
|
|
function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: string; 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
|
|
setBusy('run')
|
|
try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); setExpired(null); 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 (
|
|
<section className="card">
|
|
<div className="sub-head">
|
|
<span className={'dot ' + (p.last_error ? 'bad' : p.enabled ? 'ok' : '')} />
|
|
<span className="dname">{p.name || p.dataset}</span>
|
|
{!p.enabled && <span className="badge">paused</span>}
|
|
<span className="spacer" />
|
|
<span className="muted small">{p.enabled ? soon(next) : 'not scheduled'}</span>
|
|
</div>
|
|
<div className="props" style={{ marginTop: 6 }}>
|
|
<div><span className="k">Dataset</span><span className="mono">{p.dataset}</span></div>
|
|
<div><span className="k">When</span><span>{describe(p)}</span></div>
|
|
<div><span className="k">Last run</span><span>{ago(p.last_run)}{p.pruned ? ` · ${p.pruned} expired removed` : ''}</span></div>
|
|
{p.last_taken && <div><span className="k">Last snapshot</span><span className="mono small">{p.last_taken.split('@')[1]}</span></div>}
|
|
{p.keep_min > 0 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>}
|
|
</div>
|
|
{p.last_error && <div className="error" style={{ marginTop: 8 }}>{p.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>
|
|
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const BLANK: Partial<ZFSPolicy> = { 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<ZFSPolicy>; datasets: string[]; onClose: () => void; onSaved: () => void }) {
|
|
const [f, setF] = useState<Partial<ZFSPolicy>>({ ...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 (
|
|
<div className="modal-back" onClick={onClose}>
|
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
|
<h3>{f.id ? 'Edit schedule' : 'New snapshot schedule'}</h3>
|
|
<label className="muted">Dataset
|
|
<select value={f.dataset} onChange={(e) => set('dataset', e.target.value)} required>
|
|
<option value="">choose…</option>
|
|
{datasets.map((d) => <option key={d} value={d}>{d}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="muted">Name — what this schedule is for
|
|
<input value={f.name} onChange={(e) => set('name', e.target.value)} placeholder={f.dataset || 'nightly'} maxLength={64} />
|
|
</label>
|
|
<label className="muted">How often
|
|
<span className="chips">
|
|
{(['hourly', 'daily', 'weekly', 'monthly'] as const).map((k) => (
|
|
<button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button>
|
|
))}
|
|
</span>
|
|
</label>
|
|
<label className="muted">At
|
|
<input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} />
|
|
</label>
|
|
{f.every === 'weekly' && (
|
|
<label className="muted">On
|
|
<select value={f.weekday} onChange={(e) => set('weekday', Number(e.target.value))}>
|
|
{WEEKDAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
|
|
</select>
|
|
</label>
|
|
)}
|
|
{f.every === 'monthly' && (
|
|
<label className="muted">Day of the month — 1 to 28, so every month has one
|
|
<input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} />
|
|
</label>
|
|
)}
|
|
<label className="muted">Keep for — written into each snapshot's name, as zfSnap does it: 6w, 30d, 12h
|
|
<input value={f.keep_for} onChange={(e) => set('keep_for', e.target.value)} placeholder="6w" spellCheck={false} required />
|
|
</label>
|
|
<label className="muted">Always keep the newest — a floor, whatever their age
|
|
<input type="number" min={0} max={99} value={f.keep_min} onChange={(e) => set('keep_min', Number(e.target.value))} />
|
|
</label>
|
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
<input type="checkbox" checked={!!f.recursive} onChange={(e) => set('recursive', e.target.checked)} style={{ width: 'auto' }} />
|
|
Recursive — one atomic snapshot of this dataset and everything under it
|
|
</label>
|
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
|
|
Enabled
|
|
</label>
|
|
{err && <div className="error">{err}</div>}
|
|
<div className="svc-actions">
|
|
<button type="submit" className="btn on" disabled={!f.dataset || !f.keep_for}>Save</button>
|
|
<span className="spacer" />
|
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---- page ----
|
|
|
|
export default function ZFS() {
|
|
const [ov, setOv] = useState<ZFSOverview | null>(null)
|
|
const [err, setErr] = useState('')
|
|
const [editing, setEditing] = useState<Partial<ZFSPolicy> | 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 <div className="dash"><div className="error">{err}</div></div>
|
|
if (!ov) return <div className="dash"><Loading label="Reading ZFS…" /></div>
|
|
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 <div className="dash"><div className="notice">{zfs.message || 'No ZFS on this host.'}</div></div>
|
|
|
|
return (
|
|
<div className="dash">
|
|
<div className="dash-bar"><h2 className="section">Pools</h2></div>
|
|
{zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
|
|
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
|
|
|
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
|
|
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
|
|
</div>
|
|
{ov.external && <ExternalNotice x={ov.external} mine={mine} />}
|
|
{ov.policies.length === 0 && !ov.external && <div className="notice">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.</div>}
|
|
<div className="cards">{ov.policies.map(({ policy, next }) => (
|
|
<ScheduleCard key={policy.id} p={policy} next={next} 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>
|
|
<section className="card ds-table">
|
|
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
|
|
</section>
|
|
|
|
{editing && <ScheduleDialog p={editing} datasets={datasets.length ? datasets : zfs.pools.map((p) => p.name)} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
|
|
</div>
|
|
)
|
|
}
|