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 = { 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(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 && }
) } // ---- page ---- export default function Disks() { const [devs, setDevs] = 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); 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) => )}
) }