Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
253 lines
16 KiB
TypeScript
253 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api'
|
||
import { fmtBytes, fmtRate } from './Dashboard'
|
||
import TaskOutput from './TaskOutput'
|
||
import { Btn, Loading, Spinner, askText, 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` }
|
||
|
||
function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
||
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
|
||
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
||
}
|
||
|
||
// ---- 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 && (
|
||
<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">{a.raw}</td><td className="num">{a.value}</td><td className="num">{a.worst}</td><td className="num">{a.thresh}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
{!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>
|
||
)
|
||
}
|
||
|
||
// ---- ZFS ----
|
||
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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ---- page ----
|
||
export default function Disks() {
|
||
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
|
||
const [zfs, setZfs] = useState<ZFSInfo | 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); setZfs(r.zfs); 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">
|
||
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)}
|
||
</div>
|
||
|
||
<div className="dash-bar"><h2 className="section">ZFS</h2></div>
|
||
{zfs && !zfs.available && <div className="notice">{zfs.message || 'No ZFS pools on this host.'}</div>}
|
||
{zfs?.available && zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
|
||
{zfs?.available && zfs.pools.length > 0 && (
|
||
<>
|
||
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
||
<section className="card datasets">
|
||
<div className="ds-row head"><span>Dataset</span><span className="num">Used</span><span className="num">Refer</span><span className="num">In snaps</span><span className="num">Snaps</span><span className="num">Ratio</span><span>Mount</span></div>
|
||
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
|
||
</section>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|