Files
hamlab/web/src/Disks.tsx
T
Mitchell-JohnsonandClaude Opus 5 7d0ec5e018 ZFS gets its own tab, with scheduled snapshots and a retention period
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>
2026-09-24 13:01:53 -07:00

166 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, SmartInfo, StorageDevice } from './api'
import { fmtBytes, fmtRate } from './fmt'
import TaskOutput from './TaskOutput'
import { askText, Btn, Loading, Meter, Spinner, toast } from './ui'
import { useRefreshMs } from './prefs'
import { Sensor, useSensorStream } from './sensors'
const pct = (v: number) => `${Math.round(v)}%`
const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', emmc: 'eMMC', sd: 'SD', other: 'disk' }
const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`)
const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
// ---- SMART panel ----
function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
const [s, setS] = useState<SmartInfo | null>(null)
const [err, setErr] = useState('')
const load = useCallback(async (force = false) => {
setS(null)
try { setS(await api.smart(dev, force)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [dev])
useEffect(() => { load() }, [load])
if (err) return <div className="error">{err}</div>
if (!s) return <Loading label="Reading health…" />
if (!s.available) return <div className="notice">smartmontools isn't installed, so there's no SMART data. Install it from the button above.</div>
if (s.standby) return <div className="notice">Disk is spun down; SMART wasn't read so it stays asleep. <Btn className="btn" onClick={() => load(true)}>Wake and read</Btn></div>
if (s.error) return <div className="notice">{s.error}</div>
const ok = s.passed === true && s.warnings.length === 0
return (
<div className="smart">
<div className="props">
<div><span className="k">Health</span><span className={ok ? 'good' : 'warn-text'}>{s.passed === null ? 'unknown' : s.passed ? 'PASSED' : 'FAILED'}{s.warnings.length > 0 && ` · ${s.warnings.length} warning${s.warnings.length > 1 ? 's' : ''}`}</span></div>
{s.temp > 0 && <div><span className="k">Temperature</span><span>{s.temp}°C</span></div>}
{s.power_on_hours > 0 && <div><span className="k">Powered on</span><span>{hours(s.power_on_hours)} · {s.power_cycles} cycles</span></div>}
{s.firmware && <div><span className="k">Firmware</span><span className="mono">{s.firmware}</span></div>}
{s.serial && <div><span className="k">Serial</span><span className="mono">{s.serial}</span></div>}
{s.self_test && <div><span className="k">Self-test</span><span>{s.self_test}{s.self_test_remaining ? ` (${s.self_test_remaining}% left)` : ''}</span></div>}
<div><span className="k">Read</span><span className="muted">{ago(s.fetched_at)} <button className="link" onClick={() => load(true)}>refresh</button></span></div>
</div>
{s.warnings.length > 0 && <div className="notice attn">{s.warnings.join(' · ')}</div>}
{s.mmc && (
<div className="props">
<div><span className="k">Wear</span><span>{[['A', s.mmc.life_a], ['B', s.mmc.life_b]].filter(([, v]) => v).map(([k, v]) => `type ${k} ${v === 11 ? 'past rated life' : `${(v as number) * 10 - 10}–${(v as number) * 10}% used`}`).join(' · ') || 'not reported'}</span></div>
<div><span className="k">Reserve</span><span>{['not reported', 'normal', 'warning — 80% of spare blocks used', 'urgent — 90% of spare blocks used'][s.mmc.pre_eol] ?? s.mmc.pre_eol}</span></div>
</div>
)}
{s.nvme && (
<div className="props">
<div><span className="k">Endurance</span><span>{s.nvme.percentage_used}% used · spare {s.nvme.available_spare}% (threshold {s.nvme.spare_threshold}%)</span></div>
<div><span className="k">Written</span><span>{fmtBytes(s.nvme.data_written_bytes)} · read {fmtBytes(s.nvme.data_read_bytes)}</span></div>
<div><span className="k">Errors</span><span>{s.nvme.media_errors} media · {s.nvme.error_log_entries} logged · {s.nvme.unsafe_shutdowns} unsafe shutdowns</span></div>
</div>
)}
{s.attrs.length > 0 && (
<div className="attrs-wrap">
<table className="attrs">
<thead><tr><th>ID</th><th>Attribute</th><th>Raw</th><th>Value</th><th>Worst</th><th>Thresh</th></tr></thead>
<tbody>
{s.attrs.map((a) => (
<tr key={a.id} className={a.warn ? 'warn' : ''}>
<td className="num">{a.id}</td><td>{a.name.replace(/_/g, ' ')}{a.failed && <span className="badge bad">{a.failed}</span>}</td>
<td className="num raw">{a.raw}</td><td className="num">{a.value}</td><td className="num">{a.worst}</td><td className="num">{a.thresh}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!s.self_test_supported && <div className="muted small">Self-tests: {s.self_test_note || 'not available on this drive'}.</div>}
{s.self_test_supported && <div className="svc-actions">
<Btn className="btn" confirm={`Start a short self-test on ${dev}?`} onClick={async () => { if (await api.smartTest(dev, 'short').then(() => true, (e) => { toast(String(e.message ?? e), 'err'); return false })) { toast('short test started'); load(true) } }}>Short test</Btn>
<Btn className="btn" confirm={`Start a long self-test on ${dev}? ${rotational ? 'This can take hours on a spinning disk.' : ''}`} onClick={async () => { if (await api.smartTest(dev, 'long').then(() => true, (e) => { toast(String(e.message ?? e), 'err'); return false })) { toast('long test started'); load(true) } }}>Long test</Btn>
</div>}
</div>
)
}
// ---- device card ----
function DeviceCard({ d, live, forceOpen }: { d: StorageDevice; live?: { read_bps: number; write_bps: number; util_pct: number }; forceOpen: boolean | null }) {
const [own, setOwn] = useState(false)
const open = forceOpen ?? own
const setOpen = (v: boolean) => setOwn(v)
return (
<section className="card disk">
<div className="sub-head">
<span className="badge">{KIND[d.kind] ?? d.kind}</span>
<span className="dname">{d.name}</span>
<span className="model">{[d.vendor, d.model].filter(Boolean).join(' ')}</span>
<span className="spacer" />
<span className="num">{fmtBytes(d.size)}{d.temp ? ` · ${d.temp.toFixed(0)}°C` : ''}</span>
</div>
{live && <div className="sub-head num muted">read {fmtRate(live.read_bps)} · write {fmtRate(live.write_bps)} · {pct(live.util_pct)} busy</div>}
<div className="muted small disk-summary">
{d.serial && <span>S/N {d.serial}</span>}
{d.namespaces && d.namespaces.length > 1 && <span>{d.namespaces.length} namespaces</span>}
<span>{d.parts.length} partition{d.parts.length === 1 ? '' : 's'}</span>
{d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').length > 0 && <span>mounted at {d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').map((p) => p.mount).join(', ')}</span>}
{d.parts.some((p) => p.mount === '[SWAP]') && <span>swap</span>}
</div>
{forceOpen === null && <div className="svc-actions"><button className="btn" onClick={() => setOpen(!open)}>{open ? 'Hide SMART' : 'SMART'}</button></div>}
{open && <SmartPanel dev={d.name} rotational={d.rotational} />}
</section>
)
}
// ---- page ----
export default function Disks() {
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
const [hasSmart, setHasSmart] = useState(true)
const [err, setErr] = useState('')
const [task, setTask] = useState<string | null>(null)
const [allSmart, setAllSmart] = useState<boolean>(() => localStorage.getItem('hamlab.disks.smart') === '1')
const rate = useRefreshMs()
const load = useCallback(async () => {
try {
const r = await api.storage()
setDevs(r.devices); setHasSmart(r.smartctl); setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { load() }, [load])
// Live per-device rates come from the sensor stream, grouped by the kernel name in each sensor's labels.
const stream = useSensorStream<{ d: Record<string, Sensor> }>({ d: 'disk.*' }, { everyMs: rate })
const live = useMemo(() => {
const by: Record<string, { name: string; read_bps: number; write_bps: number; util_pct: number }> = {}
for (const x of Object.values(stream.data?.d ?? {})) {
const name = x.labels?.device
if (!name || typeof x.value !== 'number') continue
const row = (by[name] ??= { name, read_bps: 0, write_bps: 0, util_pct: 0 })
if (x.id.endsWith('.read')) row.read_bps = x.value
else if (x.id.endsWith('.write')) row.write_bps = x.value
else if (x.id.endsWith('.util')) row.util_pct = x.value
}
return { disks: Object.values(by) }
}, [stream.data])
if (!devs) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Scanning disks…" />}</div>
const liveFor = (dev: StorageDevice) => {
const names = dev.namespaces?.length ? dev.namespaces : [dev.name]
const rows = (live?.disks ?? []).filter((x) => names.includes(x.name))
if (rows.length === 0) return undefined
return { read_bps: rows.reduce((a, x) => a + x.read_bps, 0), write_bps: rows.reduce((a, x) => a + x.write_bps, 0), util_pct: Math.max(...rows.map((x) => x.util_pct)) }
}
return (
<div className="dash disks">
{err && <div className="error">{err}</div>}
<div className="dash-bar">
<h2 className="section">Devices · {devs.length}</h2>
<span className="spacer" />
{hasSmart && <button className={'chip' + (allSmart ? ' on' : '')} title="show SMART for every drive" onClick={() => { const v = !allSmart; setAllSmart(v); localStorage.setItem('hamlab.disks.smart', v ? '1' : '0') }}>{allSmart ? 'SMART: all open' : 'SMART: show all'}</button>}
{!hasSmart && <Btn className="btn" onClick={async () => { try { const t = await api.installPackages(['smartmontools']); setTask(t.id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>Install smartmontools</Btn>}
<Btn className="btn" title="reload" onClick={load}>↻</Btn>
</div>
{!hasSmart && <div className="notice">SMART health needs smartmontools on this host. Install it with the button, then reload.</div>}
{task && <TaskOutput id={task} onDone={(t) => { toast(`${t.title}: ${t.exit_code === 0 ? 'finished' : `exited ${t.exit_code}`}`, t.exit_code === 0 ? 'ok' : 'err'); load() }} />}
<div className="cards disk-cards">
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)}
</div>
</div>
)
}