import { useCallback, useEffect, useState } from 'react' import { api, Sample, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' import { fmtBytes, fmtRate } from './Dashboard' import TaskOutput from './TaskOutput' import { Btn, Loading, Spinner, toast } from './ui' const pct = (v: number) => `${Math.round(v)}%` const KIND: Record = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', 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.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}
)}
{ 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 }: { d: StorageDevice; live?: { read_bps: number; write_bps: number; util_pct: number } }) { const [open, setOpen] = useState(false) 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.parts.map((p) => (
{p.name}{p.fstype || p.type}{p.label ? ` · ${p.label}` : ''} {p.fs_size ? : {p.mount === '[SWAP]' ? 'swap' : p.mount ? '' : 'not mounted'}} {p.fs_size ? `${fmtBytes(p.used ?? 0)} / ${fmtBytes(p.fs_size)} · ${p.mount}` : fmtBytes(p.size)}
))} {d.parts.length === 0 &&
No partitions.
}
{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 = window.prompt(`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 [live, setLive] = useState(null) const [err, setErr] = useState('') const [task, setTask] = useState(null) 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]) useEffect(() => { let stop = false const tick = async () => { if (!stop && document.visibilityState === 'visible') { try { setLive(await api.metrics()) } catch { /* ignore */ } } } tick() const t = setInterval(tick, 2000) return () => { stop = true; clearInterval(t) } }, []) if (!devs) return
{err ?
{err}
: }
const liveFor = (n: string) => live?.disks.find((d) => d.name === n) return (
{err &&
{err}
}

Devices · {devs.length}

{!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) => )}
)}
) }