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 = { 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
} // ---- SMART panel ---- function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) { const [s, setS] = useState(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
{err}
if (!s) return if (!s.available) return
smartmontools isn't installed, so there's no SMART data. Install it from the button above.
if (s.standby) return
Disk is spun down; SMART wasn't read so it stays asleep. load(true)}>Wake and read
if (s.error) return
{s.error}
const ok = s.passed === true && s.warnings.length === 0 return (
Health{s.passed === null ? 'unknown' : s.passed ? 'PASSED' : 'FAILED'}{s.warnings.length > 0 && ` · ${s.warnings.length} warning${s.warnings.length > 1 ? 's' : ''}`}
{s.temp > 0 &&
Temperature{s.temp}°C
} {s.power_on_hours > 0 &&
Powered on{hours(s.power_on_hours)} · {s.power_cycles} cycles
} {s.firmware &&
Firmware{s.firmware}
} {s.serial &&
Serial{s.serial}
} {s.self_test &&
Self-test{s.self_test}{s.self_test_remaining ? ` (${s.self_test_remaining}% left)` : ''}
}
Read{ago(s.fetched_at)}
{s.warnings.length > 0 &&
{s.warnings.join(' · ')}
} {s.mmc && (
Wear{[['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'}
Reserve{['not reported', 'normal', 'warning — 80% of spare blocks used', 'urgent — 90% of spare blocks used'][s.mmc.pre_eol] ?? s.mmc.pre_eol}
)} {s.nvme && (
Endurance{s.nvme.percentage_used}% used · spare {s.nvme.available_spare}% (threshold {s.nvme.spare_threshold}%)
Written{fmtBytes(s.nvme.data_written_bytes)} · read {fmtBytes(s.nvme.data_read_bytes)}
Errors{s.nvme.media_errors} media · {s.nvme.error_log_entries} logged · {s.nvme.unsafe_shutdowns} unsafe shutdowns
)} {s.attrs.length > 0 && ( {s.attrs.map((a) => ( ))}
IDAttributeRawValueWorstThresh
{a.id}{a.name.replace(/_/g, ' ')}{a.failed && {a.failed}} {a.raw}{a.value}{a.worst}{a.thresh}
)} {!s.self_test_supported &&
Self-tests: {s.self_test_note || 'not available on this drive'}.
} {s.self_test_supported &&
{ 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 { 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
}
) } // ---- 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 (
{KIND[d.kind] ?? d.kind} {d.name} {[d.vendor, d.model].filter(Boolean).join(' ')} {fmtBytes(d.size)}{d.temp ? ` · ${d.temp.toFixed(0)}°C` : ''}
{live &&
read {fmtRate(live.read_bps)} · write {fmtRate(live.write_bps)} · {pct(live.util_pct)} busy
}
{d.serial && S/N {d.serial}} {d.namespaces && d.namespaces.length > 1 && {d.namespaces.length} namespaces} {d.parts.length} partition{d.parts.length === 1 ? '' : 's'} {d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').length > 0 && mounted at {d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').map((p) => p.mount).join(', ')}} {d.parts.some((p) => p.mount === '[SWAP]') && swap}
{forceOpen === null &&
} {open && }
) } // ---- 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 (
{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}
}
) } function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) { const [snaps, setSnaps] = useState(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 ( <>
{open ? '▾' : '▸'}{d.name.split('/').pop()}{d.type === 'volume' && zvol} {fmtBytes(d.used)} {fmtBytes(d.refer)} {d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'} {d.snapshots} {d.compressratio} {d.mount}
{open && (
Snapshot nowavail {fmtBytes(d.avail)}
{snaps === null ?
listing…
: snaps.length === 0 ?
No snapshots.
: (
{snaps.slice().reverse().map((s) =>
{s.name.split('@')[1]}{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}
)}
)}
)} ) } // ---- page ---- export default function Disks() { const [devs, setDevs] = useState(null) const [zfs, setZfs] = useState(null) const [hasSmart, setHasSmart] = useState(true) const [err, setErr] = useState('') const [task, setTask] = useState(null) const [allSmart, setAllSmart] = useState(() => 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 }>({ d: 'disk.*' }, { everyMs: rate }) const live = useMemo(() => { const by: Record = {} 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
{err ?
{err}
: }
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 (
{err &&
{err}
}

Devices · {devs.length}

{hasSmart && } {!hasSmart && { try { const t = await api.installPackages(['smartmontools']); setTask(t.id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>Install smartmontools} ↻
{!hasSmart &&
SMART health needs smartmontools on this host. Install it with the button, then reload.
} {task && { toast(`${t.title}: ${t.exit_code === 0 ? 'finished' : `exited ${t.exit_code}`}`, t.exit_code === 0 ? 'ok' : 'err'); load() }} />}
{devs.map((d) => )}

ZFS

{zfs && !zfs.available &&
{zfs.message || 'No ZFS pools on this host.'}
} {zfs?.available && zfs.pools.length === 0 &&
ZFS is present but no pools are imported.
} {zfs?.available && zfs.pools.length > 0 && ( <>
{zfs.pools.map((p) => )}
DatasetUsedReferIn snapsSnapsRatioMount
{zfs.datasets.map((d) => )}
)}
) }