A txg records its bytes only when it syncs and the mover reports every 2 s, so a per-sample delta read 0 most of the time. Rates are now taken over the gap between syncs (or mover updates) and held until the next one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
641 lines
42 KiB
TypeScript
641 lines
42 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, Fragment, useCallback, useEffect, useState } from 'react'
|
|
import { api, ARCView, CacheDev, Landing, PoolCaching, PoolWrites, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot, ZFSWrites } from './api'
|
|
import { fmtBytes, fmtRate } from './fmt'
|
|
import { useRefreshMs } from './prefs'
|
|
import { fetchHistories } from './sensors'
|
|
import Sparkline from './Sparkline'
|
|
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 (
|
|
<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>
|
|
)
|
|
}
|
|
|
|
// 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) await reload()
|
|
}
|
|
async function snapshot() {
|
|
// zfSnap's name is the machine's local time, and snapMeta reads it back as local time — so the
|
|
// default is built from local fields, not toISOString(), which is UTC and read seven hours wrong.
|
|
const n = new Date(), two = (x: number) => String(x).padStart(2, '0')
|
|
const def = `${n.getFullYear()}-${two(n.getMonth() + 1)}-${two(n.getDate())}_${two(n.getHours())}.${two(n.getMinutes())}.${two(n.getSeconds())}`
|
|
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 (
|
|
<>
|
|
<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">{d.snapshots}</span>
|
|
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</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 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) => {
|
|
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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ---- schedules ----
|
|
|
|
function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; onAdopt: () => void }) {
|
|
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="svc-actions" style={{ marginTop: 6 }}>
|
|
<Btn className="btn" onClick={onAdopt}>Adopt as a schedule</Btn>
|
|
<span className="muted small">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.</span>
|
|
</div>}
|
|
</div>
|
|
)
|
|
}
|
|
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 (
|
|
<section className="card">
|
|
<div className="sub-head">
|
|
<span className={'dot ' + (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">Cron</span><span className="mono small" title={`in ${'/etc/cron.d/hamlab-zfs'}`}>{cron}</span></div>
|
|
<div><span className="k">Last run</span><span>{ago(last.at)}{last.pruned?.length ? ` · ${last.pruned.length} expired removed` : ''}</span></div>
|
|
{last.taken && <div><span className="k">Last snapshot</span><span className="mono small">{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>
|
|
{last.error && <div className="error" style={{ marginTop: 8 }}>{last.error}</div>}
|
|
<div className="svc-actions">
|
|
<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', 'custom'] as const).map((k) => (
|
|
<button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button>
|
|
))}
|
|
</span>
|
|
</label>
|
|
{f.every === 'custom' ? (
|
|
<label className="muted">Cron expression — minute hour day month weekday
|
|
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 */6 * * *" spellCheck={false} required />
|
|
</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">Snapshot name prefix — optional; the timestamp and retention follow it
|
|
<input value={f.prefix ?? ''} onChange={(e) => set('prefix', e.target.value)} placeholder="none" spellCheck={false} />
|
|
</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>
|
|
)
|
|
}
|
|
|
|
// 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 <age> is the retention, -r is recursive.
|
|
function adopt(x: ExternalJob, fallbackDataset: string): Partial<ZFSPolicy> {
|
|
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-[a-zA-Z]*r\b/.test(cmd), // -r, or -r folded into another flag group
|
|
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,
|
|
}
|
|
}
|
|
|
|
// ---- the cache: the ARC in RAM, and what each pool has in front of its disks ----
|
|
//
|
|
// Two kinds of number. The live ones (size, the hit rate over the last sampling interval)
|
|
// come from the sampler, which also keeps their history for the sparklines; the since-boot
|
|
// counters, the tunables and the pool layout come with the same request from the storage
|
|
// package. A tier a pool does not have is named and said to be absent, with what that means,
|
|
// because "nothing shown" and "none fitted" are different answers.
|
|
|
|
const hitPct = (h: number, m: number) => (h + m > 0 ? (h / (h + m)) * 100 : NaN)
|
|
const fmtPct = (v: number) => (Number.isNaN(v) ? '—' : `${v.toFixed(1)}%`)
|
|
const fmtN = (n: number) => n.toLocaleString()
|
|
const cssVar = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || '#3987e5'
|
|
|
|
function Tier({ name, what, devs, none, note }: { name: string; what: string; devs: CacheDev[]; none: string; note?: string }) {
|
|
return (
|
|
<div className="arc-tier">
|
|
<span className="k" title={what}>{name}</span>
|
|
<span className="arc-tier-v">
|
|
{devs.length === 0 ? <span className="none">{none}</span> : devs.map((x) => (
|
|
<span key={x.name} className="mono" title={x.name}>
|
|
{x.name}{x.size ? ` · ${fmtBytes(x.alloc ?? 0)} of ${fmtBytes(x.size)} used` : ''}{x.health && x.health !== 'ONLINE' ? ` · ${x.health}` : ''}
|
|
</span>
|
|
))}
|
|
{devs.length > 0 && note && <span className="muted small">{note}</span>}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PoolTiers({ p, v }: { p: PoolCaching; v: ARCView }) {
|
|
const L = v.detail.lifetime, z = v.detail.zil, a = v.live
|
|
return (
|
|
<section className="card">
|
|
<div className="sub-head">
|
|
<span className="dname">{p.pool}</span>
|
|
<span className="badge">caching tiers</span>
|
|
<span className="spacer" />
|
|
<span className="muted small mono" title="primarycache: what the ARC may hold for this pool · secondarycache: what an L2ARC may hold">primarycache={p.primarycache || '?'} · secondarycache={p.secondarycache || '?'}</span>
|
|
</div>
|
|
<div className="arc-tiers">
|
|
<Tier name="L2ARC" what="cache devices: a second-level read cache on flash, fed with what the ARC evicts" devs={p.cache}
|
|
none={`none — a read that misses RAM goes to the pool's disks. ${fmtBytes(L.EvictL2Eligible)} evicted from RAM since boot would have been eligible for one.`}
|
|
note={a ? `${fmtBytes(a.l2_size)} cached (${fmtBytes(a.l2_asize)} on device) · ${fmtPct(hitPct(L.L2Hits, L.L2Misses))} hit since boot · ${fmtBytes(L.L2ReadBytes)} read, ${fmtBytes(L.L2WriteBytes)} written` : undefined} />
|
|
<Tier name="SLOG" what="log devices: where synchronous writes are committed before they reach the pool" devs={p.log}
|
|
none={`none — the intent log is on the pool's own vdevs: ${fmtN(z.commits)} commits, ${fmtBytes(z.NormalWrite)} written to it since boot`}
|
|
note={`${fmtN(z.commits)} commits · ${fmtBytes(z.SlogWrite)} written to the log device since boot${z.NormalWrite ? `, ${fmtBytes(z.NormalWrite)} to the pool` : ''}`} />
|
|
<Tier name="Special" what="special allocation class: metadata and small blocks kept on their own (faster) vdev" devs={p.special}
|
|
none="none — metadata lives with the data on the same vdevs" />
|
|
<Tier name="Dedup" what="deduplication: a table of every block's checksum, in the ARC and on disk" devs={p.dedup}
|
|
none={!p.dedup_prop || p.dedup_prop === 'off' ? `off · ratio ${p.dedup_ratio || '1.00x'}` : `${p.dedup_prop} · ratio ${p.dedup_ratio} · the table lives in the ARC and on the data vdevs`} />
|
|
{p.spare.length > 0 && <Tier name="Spares" what="hot spares: take over when a vdev member fails" devs={p.spare} none="" />}
|
|
</div>
|
|
<div className="muted small mono" style={{ marginTop: 8 }}>sync={p.sync || '?'} · logbias={p.logbias || '?'} · compression={p.compression || '?'} · recordsize={p.recordsize || '?'}</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ---- the write path: the dirty buffer in RAM, what each pool writes, and a landing tier ----
|
|
//
|
|
// A write reaches a pool in up to three steps. If a landing tier fronts the pool (a mergerfs
|
|
// pool whose first branch is fast storage and whose last is the pool), new files land there
|
|
// first and a mover drains them. Either way the data then waits in RAM as dirty buffers, and
|
|
// each txg sync writes it to the disks. Each step's live number is here, with its history.
|
|
|
|
const dur = (s: number) => (!Number.isFinite(s) || s <= 0 ? '—' : s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : `${(s / 3600).toFixed(1)} h`)
|
|
type WHist = Record<string, number[]>
|
|
const wIds = (w: ZFSWrites) => ['zfs.write.dirty', ...w.pools.flatMap((p) => {
|
|
const b = `zfs.${p.pool}.`
|
|
return [b + 'write_bps', b + 'logical_bps', ...(p.landing ? [b + 'landing.pending_bytes', b + 'landing.in_bps', b + 'landing.drain_bps'] : [])]
|
|
})]
|
|
|
|
function MoverLine({ l }: { l: Landing }) {
|
|
const m = l.mover
|
|
if (!m) return <div className="notice" style={{ marginTop: 6 }}>No mover is reporting for this landing tier (nothing in <span className="mono">/run/landing-mover/</span>). Files that land here stay here until something moves them.</div>
|
|
const frac = m.current_size > 0 ? m.partial / m.current_size : 0
|
|
return (
|
|
<>
|
|
<div className="props" style={{ marginTop: 6 }}>
|
|
<div><span className="k">Mover</span><span>
|
|
<span className={'badge ' + (m.state === 'moving' ? 'on' : m.state === 'idle' ? '' : 'warn')}>{m.state}</span>
|
|
{' '}{m.state === 'moving' && m.batch_files > 1 ? `batch of ${m.batch_files} files, ${fmtBytes(m.batch_bytes)}` : m.state === 'holding' ? 'a mount it needs is missing — waiting' : m.state === 'stopped' ? `no word since ${ago(m.updated_ms)}` : `queue ${m.queue}`}
|
|
{m.state === 'moving' && m.queue > 0 ? ` · ${m.queue} more queued` : ''}
|
|
</span></div>
|
|
{m.state === 'moving' && m.current && <div><span className="k">Now</span><span className="mono small" title={m.current}>{m.current}{m.current_size ? ` · ${fmtBytes(m.partial)} of ${fmtBytes(m.current_size)}` : ''}</span></div>}
|
|
<div><span className="k">Since start</span><span>{fmtN(m.moved_files)} files · {fmtBytes(m.moved_bytes)} moved · running {ago(m.started_ms).replace(' ago', '')}</span></div>
|
|
{m.last_move && <div><span className="k">Last moved</span><span className="mono small" title={m.last_move}>{m.last_move} · {ago(m.last_move_ms)}</span></div>}
|
|
<div><span className="k">Rules</span><span className="muted">a file moves once it has been closed and left alone for {m.settle} s · limit {m.bwlimit && m.bwlimit !== '0' ? `${m.bwlimit}B/s` : 'none'} · oldest first</span></div>
|
|
{m.last_error && <div><span className="k">Last error</span><span className="warn-text" style={{ whiteSpace: 'normal' }}>{m.last_error} · {ago(m.last_error_ms)}</span></div>}
|
|
</div>
|
|
{m.state === 'moving' && m.current_size > 0 && <Meter frac={frac} cls="" />}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function LandingBlockInner({ l, pending, inn, drain }: { l: Landing; pending: number[]; inn: number[]; drain: number[] }) {
|
|
const eta = l.drain_bps > 0 ? l.pending_bytes / l.drain_bps : NaN
|
|
const fill = l.size > 0 ? l.used / l.size : 0
|
|
return (
|
|
<div className="landing-tier">
|
|
<div className="arc-tier"><span className="k" title="a mergerfs pool puts new files on this branch first; a mover drains them onto the pool">Landing</span>
|
|
<span className="arc-tier-v"><span className="mono" title={`${l.branch} → ${l.pool_branch} via ${l.mount}`}>{l.branch}{l.device ? ` · ${l.device}` : ''}{l.fstype ? ` · ${l.fstype}` : ''} · in front of {l.pool_branch} as {l.mount}</span></span>
|
|
</div>
|
|
<div className="sub-head" style={{ marginTop: 6 }}>
|
|
<span className="muted small">{fmtBytes(l.used)} of {fmtBytes(l.size)} used</span><span className="spacer" />
|
|
<span className="num">{l.pending_files ? `${fmtN(l.pending_files)}${l.pending_partial ? '+' : ''} waiting · ${fmtBytes(l.pending_bytes)}` : 'nothing waiting'}</span>
|
|
</div>
|
|
<Meter frac={fill} />
|
|
<div className="props" style={{ marginTop: 6 }}>
|
|
<div><span className="k">Waiting</span><span>{l.pending_files ? `${fmtN(l.pending_files)}${l.pending_partial ? '+' : ''} files, ${fmtBytes(l.pending_bytes)} · oldest landed ${ago(l.oldest_ms)}` : 'nothing — everything that landed is on the pool'}<span className="muted small"> · counted {ago(l.counted_ms)}</span></span></div>
|
|
<div><span className="k">Arriving</span><span>{fmtRate(l.in_bps)}{l.device ? <span className="muted small"> · writes to {l.device}</span> : ''}</span></div>
|
|
<div><span className="k">To the pool</span><span>{fmtRate(l.drain_bps)}{l.pending_bytes > 0 ? ` · ${Number.isFinite(eta) ? `clear in about ${dur(eta)}` : 'not draining right now'}` : ''}</span></div>
|
|
</div>
|
|
<MoverLine l={l} />
|
|
<div className="arc-sparks">
|
|
<div>
|
|
<div className="muted small">Waiting on the landing tier · {fmtBytes(l.pending_bytes)}</div>
|
|
<Sparkline series={[{ name: 'waiting', values: pending, color: cssVar('--s1') }]} format={fmtBytes} height={40} />
|
|
</div>
|
|
<div>
|
|
<div className="muted small">Arriving {fmtRate(l.in_bps)} · to the pool {fmtRate(l.drain_bps)}</div>
|
|
<Sparkline series={[
|
|
{ name: 'arriving', values: inn, color: cssVar('--s1') },
|
|
{ name: 'to the pool', values: drain, color: cssVar('--ok') },
|
|
]} format={fmtRate} height={40} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function WritePath({ w, p, h }: { w: ZFSWrites; p: PoolWrites; h: WHist }) {
|
|
const b = `zfs.${p.pool}.`
|
|
const throttling = w.delayed_per_sec > 0 || w.over_max_per_sec > 0
|
|
const delayAt = (w.limit * w.delay_min_pct) / 100
|
|
const ratio = p.write_bps > 0 && p.logical_bps > 0 ? p.logical_bps / p.write_bps : 0
|
|
return (
|
|
<section className="card">
|
|
<div className="sub-head">
|
|
<span className={'dot ' + (w.over_max_per_sec > 0 ? 'bad' : throttling ? 'warn' : 'ok')} />
|
|
<span className="dname">{p.pool}</span>
|
|
<span className="badge">write path</span>
|
|
<span className="spacer" />
|
|
<span className="num">{fmtRate(p.write_bps)} to disk</span>
|
|
</div>
|
|
{p.landing
|
|
? <LandingBlockWithHist l={p.landing} h={h} b={b} />
|
|
: <div className="arc-tiers"><div className="arc-tier"><span className="k" title="a fast tier new files land on before the pool">Landing</span><span className="arc-tier-v"><span className="none">none — writes go straight into the pool's RAM buffer</span></span></div></div>}
|
|
<div className="arc-sparks">
|
|
<div>
|
|
<div className="muted small">RAM write buffer · {fmtBytes(w.dirty)} of {fmtBytes(w.limit)}</div>
|
|
<Sparkline series={[{ name: 'waiting in RAM', values: h['zfs.write.dirty'] ?? [], color: cssVar('--s1') }]} max={w.limit || undefined} format={fmtBytes} height={44} />
|
|
</div>
|
|
<div>
|
|
<div className="muted small">Writes · {fmtRate(p.write_bps)} to disk · {fmtRate(p.logical_bps)} from programs</div>
|
|
<Sparkline series={[
|
|
{ name: 'to disk', values: h[b + 'write_bps'] ?? [], color: cssVar('--s1') },
|
|
{ name: 'from programs', values: h[b + 'logical_bps'] ?? [], color: cssVar('--ok') },
|
|
]} format={fmtRate} height={44} />
|
|
</div>
|
|
</div>
|
|
<div className="props" style={{ marginTop: 8 }}>
|
|
<div><span className="k">In RAM</span><span title="dirty data: written by programs, not yet on the disks. For all pools together — ZFS keeps no live per-pool figure">{fmtBytes(w.dirty)} waiting · holds up to {fmtBytes(w.limit)} {w.limit < w.dirty_max ? `(half the ARC target; zfs_dirty_data_max is ${fmtBytes(w.dirty_max)})` : '(zfs_dirty_data_max)'}</span></div>
|
|
<div><span className="k">Throttle</span><span title="past zfs_delay_min_dirty_percent of the buffer, ZFS makes each write wait a little so the disks can catch up; at the limit, writes stop until a sync frees room">
|
|
{w.over_max_per_sec > 0 ? <span className="warn-text">{fmtN(w.over_max_per_sec)}/s writes stopped at the limit</span> : w.delayed_per_sec > 0 ? <span className="warn-text">slowing {fmtN(w.delayed_per_sec)} writes/s — the disks are the limit now</span> : 'not slowing anything'}
|
|
<span className="muted"> · starts at {w.delay_min_pct}% ({fmtBytes(delayAt)})</span></span></div>
|
|
<div><span className="k">To disk</span><span>{fmtRate(p.write_bps)} written by syncs{ratio > 0 ? ` · ${fmtRate(p.logical_bps)} from programs (${ratio >= 1 ? `${ratio.toFixed(2)}x compressed` : 'parity and metadata included'})` : ''}<span className="muted"> · a sync at least every {w.txg_timeout_sec} s, sooner as the buffer fills</span></span></div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function LandingBlockWithHist({ l, h, b }: { l: Landing; h: WHist; b: string }) {
|
|
return <LandingBlockInner l={l} pending={h[b + 'landing.pending_bytes'] ?? []} inn={h[b + 'landing.in_bps'] ?? []} drain={h[b + 'landing.drain_bps'] ?? []} />
|
|
}
|
|
|
|
function ARCSection() {
|
|
const [v, setV] = useState<ARCView | null>(null)
|
|
const [hist, setHist] = useState<{ size: number[]; hit: number[] }>({ size: [], hit: [] })
|
|
const [whist, setWhist] = useState<WHist>({})
|
|
const [err, setErr] = useState('')
|
|
const rate = useRefreshMs()
|
|
useEffect(() => {
|
|
let stop = false
|
|
const poll = async () => {
|
|
let r: ARCView | null = null
|
|
try { r = await api.zfsARC(); if (!stop) { setV(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
|
|
try {
|
|
const extra = r?.writes ? wIds(r.writes) : []
|
|
const hs = await fetchHistories(['zfs.arc.size', 'zfs.arc.hit_pct', ...extra].join(','))
|
|
if (!stop) {
|
|
setHist({ size: hs.find((h) => h.id === 'zfs.arc.size')?.v ?? [], hit: hs.find((h) => h.id === 'zfs.arc.hit_pct')?.v ?? [] })
|
|
setWhist(Object.fromEntries(hs.filter((h) => extra.includes(h.id)).map((h) => [h.id, h.v])))
|
|
}
|
|
} catch { /* the sparklines stay empty; the numbers still show */ }
|
|
}
|
|
poll()
|
|
const t = setInterval(poll, rate)
|
|
return () => { stop = true; clearInterval(t) }
|
|
}, [rate])
|
|
|
|
if (err && !v) return <div className="error">{err}</div>
|
|
if (!v) return <div className="muted"><Spinner /> reading the cache…</div>
|
|
const d = v.detail, a = v.live
|
|
if (!d.available || !a) return <div className="notice">The ZFS module reports no ARC statistics on this machine.</div>
|
|
|
|
const L = d.lifetime
|
|
const ratio = a.compressed > 0 ? a.uncompressed / a.compressed : 0
|
|
const maxSet = d.tunables.zfs_arc_max && d.tunables.zfs_arc_max !== '0'
|
|
const atFloor = a.target <= a.min * 1.02
|
|
const rows: [string, number, number][] = [
|
|
['Demand data', L.DemandDataHits, L.DemandDataMisses], ['Demand metadata', L.DemandMetaHits, L.DemandMetaMisses],
|
|
['Prefetch data', L.PrefetchDataHits, L.PrefetchDataMisses], ['Prefetch metadata', L.PrefetchMetaHits, L.PrefetchMetaMisses],
|
|
]
|
|
return (
|
|
<>
|
|
<section className="card">
|
|
<div className="sub-head">
|
|
<span className={'dot ' + (a.no_grow ? 'warn' : 'ok')} />
|
|
<span className="dname">ARC</span>
|
|
<span className="badge">in RAM</span>
|
|
<span className="spacer" />
|
|
<span className="num">{fmtBytes(a.size)} / {fmtBytes(a.max)} · {Math.round((a.size / a.max) * 100)}%</span>
|
|
</div>
|
|
<Meter frac={a.size / a.max} cls="" />
|
|
{a.no_grow && (
|
|
<div className="notice attn" style={{ marginTop: 8 }}>
|
|
<b>Held by memory pressure.</b> ZFS is not letting the cache grow (<span className="mono">arc_no_grow</span>): the system is short of free memory
|
|
{atFloor ? <>, and the target sits at its floor of <b>{fmtBytes(a.min)}</b> against a ceiling of {fmtBytes(a.max)}</> : ''}.
|
|
{' '}Whatever no longer fits is read from the disks.
|
|
</div>
|
|
)}
|
|
<div className="arc-sparks">
|
|
<div>
|
|
<div className="muted small">Size · ceiling {fmtBytes(a.max)}</div>
|
|
<Sparkline series={[{ name: 'size', values: hist.size, color: cssVar('--s1') }]} max={a.max} format={fmtBytes} height={48} />
|
|
</div>
|
|
<div>
|
|
<div className="muted small">Hit rate · {a.hit_pct_known ? `${a.hit_pct.toFixed(1)}% now` : 'no reads yet'} · {fmtN(Math.round(a.reads_per_sec))} reads/s</div>
|
|
<Sparkline series={[{ name: 'hit rate', values: hist.hit, color: cssVar('--ok') }]} max={100} format={(x) => `${x.toFixed(1)}%`} height={48} />
|
|
</div>
|
|
</div>
|
|
<div className="props" style={{ marginTop: 8 }}>
|
|
<div><span className="k">Target</span><span title="c: what the ARC may hold right now, between its floor (c_min) and ceiling (c_max)">{fmtBytes(a.target)} · floor {fmtBytes(a.min)} · ceiling {fmtBytes(a.max)} {maxSet ? '(zfs_arc_max)' : '(default: half of RAM)'}</span></div>
|
|
<div><span className="k">Hit rate</span><span>{a.hit_pct_known ? `${a.hit_pct.toFixed(1)}% now` : 'idle'} · {a.lifetime_hit_pct.toFixed(1)}% since boot · {fmtN(a.hits)} hits, {fmtN(a.misses)} misses</span></div>
|
|
<div><span className="k">Holds</span><span title="MRU: seen once · MFU: seen again · dirty: written, not yet on disk">MRU {fmtBytes(a.mru)} · MFU {fmtBytes(a.mfu)} · dirty {fmtBytes(a.anon)}</span></div>
|
|
<div><span className="k">Of which</span><span>data {fmtBytes(a.data)} · metadata {fmtBytes(a.metadata)} · headers {fmtBytes(a.headers)}</span></div>
|
|
<div><span className="k">Compressed</span><span>{ratio > 0 ? `${fmtBytes(a.uncompressed)} held in ${fmtBytes(a.compressed)} · ${ratio.toFixed(2)}x` : 'not compressed'}</span></div>
|
|
<div><span className="k">Ghosts</span><span title="what was evicted recently and is still remembered, so a re-read can grow the right list">MRU {fmtBytes(a.mru_ghost)} · MFU {fmtBytes(a.mfu_ghost)}</span></div>
|
|
<div><span className="k">Prefetch</span><span title="zfetch: the read-ahead that guesses the next blocks of a sequential read">{d.tunables.zfs_prefetch_disable === '1' ? 'disabled (zfs_prefetch_disable)' : `${fmtPct(hitPct(d.zfetch.Hits, d.zfetch.Misses))} of ${fmtN(d.zfetch.Hits + d.zfetch.Misses)} predictions since boot`}</span></div>
|
|
<div><span className="k">dbuf cache</span><span title="the small cache of decompressed buffers in front of the ARC">{fmtBytes(d.dbuf.Size)} of {fmtBytes(d.dbuf.Target)} · {fmtPct(hitPct(d.dbuf.Hits, d.dbuf.Misses))} hits</span></div>
|
|
<div><span className="k">Pressure</span><span title="since boot: throttles are allocations ZFS made wait for memory; prunes are metadata evictions asked of the filesystems">{fmtN(a.throttles)} throttles · {fmtN(a.prunes)} prunes · {fmtN(a.evict_skip)} evict skips</span></div>
|
|
</div>
|
|
<div className="arc-break">
|
|
<span className="head">Since boot</span><span className="head num">hit rate</span><span className="head num">hits</span><span className="head num">misses</span>
|
|
{rows.map(([k, h, m]) => <Fragment key={k}><span>{k}</span><span className="num">{fmtPct(hitPct(h, m))}</span><span className="num">{fmtN(h)}</span><span className="num">{fmtN(m)}</span></Fragment>)}
|
|
<span><b>All reads</b></span><span className="num"><b>{fmtPct(hitPct(L.Hits, L.Misses))}</b></span><span className="num">{fmtN(L.Hits)}</span><span className="num">{fmtN(L.Misses)}</span>
|
|
</div>
|
|
</section>
|
|
{d.pools.length > 0 && <div className="cards full" style={{ marginTop: 8 }}>{d.pools.map((p) => <PoolTiers key={p.pool} p={p} v={v} />)}</div>}
|
|
{v.writes && v.writes.pools.length > 0 && (
|
|
<>
|
|
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Writes</h2><span className="spacer" />
|
|
<span className="muted small">what is waiting to reach each pool, and how fast it is getting there</span>
|
|
</div>
|
|
<div className="cards full">{v.writes.pools.map((p) => <WritePath key={p.pool} w={v.writes!} p={p} h={whist} />)}</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 full">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
|
|
|
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Cache</h2><span className="spacer" />
|
|
<span className="muted small">the ARC in RAM, and what each pool has in front of its disks</span>
|
|
</div>
|
|
<ARCSection />
|
|
|
|
<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} onAdopt={() => setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />}
|
|
{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, cron, next, last }) => (
|
|
<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">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>
|
|
|
|
{editing && <ScheduleDialog p={editing} datasets={datasets.length ? datasets : zfs.pools.map((p) => p.name)} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
|
|
</div>
|
|
)
|
|
}
|