Dashboard tab: CPU/mem/load, hwmon temps+fans, net, disks, filesystems, UPS with 10-min sparklines

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 13:37:39 -07:00
co-authored by Claude Fable 5.1
parent 24d64245ec
commit 3c6f4d4639
10 changed files with 982 additions and 34 deletions
+215
View File
@@ -0,0 +1,215 @@
import { ReactNode, useEffect, useRef, useState } from 'react'
import { api, Sample, Series } from './api'
import Sparkline from './Sparkline'
const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)'
function push(arr: number[], v: number) { arr.push(v); if (arr.length > MAX) arr.shift() }
function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) }
export function fmtBytes(b: number): string {
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let i = 0
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
}
export function fmtRate(bps: number): string {
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
let i = 0
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
}
function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
}
const pct = (v: number) => `${Math.round(v)}%`
function level(frac: number, warn = 0.85, bad = 0.95): '' | 'warn' | 'bad' {
return frac >= bad ? 'bad' : frac >= warn ? 'warn' : ''
}
function Meter({ frac, cls }: { frac: number; cls?: string }) {
return <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
return (
<div className="tile">
<div className="label">{label}</div>
<div className="value">{value}</div>
{sub && <div className="sub">{sub}</div>}
{children}
</div>
)
}
function Card({ title, children }: { title: string; children: ReactNode }) {
return <section className="card"><h2>{title}</h2>{children}</section>
}
function Legend({ items }: { items: { name: string; color: string }[] }) {
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
}
export default function Dashboard() {
const [latest, setLatest] = useState<Sample | null>(null)
const [err, setErr] = useState('')
const hist = useRef<Series | null>(null)
useEffect(() => {
let stop = false
let timer: number | undefined
const seed = async () => {
try { hist.current = await api.metricsHistory(); setLatest(await api.metrics()); setErr('') }
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
const tick = async () => {
if (stop) return
if (document.visibilityState === 'visible') {
try {
const s = await api.metrics()
const h = hist.current
if (h) {
push(h.t, s.t); push(h.cpu, s.cpu); push(h.mem, s.mem.used)
const n = h.t.length
for (const x of s.net) {
const v = (h.net[x.iface] ??= { rx: [], tx: [] })
pad(v.rx, n - 1); pad(v.tx, n - 1); push(v.rx, x.rx_bps); push(v.tx, x.tx_bps)
}
for (const d of s.disks) {
const v = (h.disk[d.name] ??= { r: [], w: [], u: [] })
pad(v.r, n - 1); pad(v.w, n - 1); pad(v.u, n - 1); push(v.r, d.read_bps); push(v.w, d.write_bps); push(v.u, d.util_pct)
}
for (const t of s.temps) {
const v = (h.temp[`${t.chip}/${t.label}`] ??= [])
pad(v, n - 1); push(v, t.c)
}
}
setLatest(s); setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
timer = window.setTimeout(tick, 2000)
}
seed().then(() => { timer = window.setTimeout(tick, 2000) })
const onVis = () => { if (document.visibilityState === 'visible' && !hist.current) seed() }
document.addEventListener('visibilitychange', onVis)
return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) }
}, [])
if (!latest) return <div className="dash"><div className="center muted">{err || '…'}</div></div>
const h = hist.current
const s = latest
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
return (
<div className="dash">
{err && <div className="error">{err}</div>}
<div className="kpis">
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : undefined}>
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
</Tile>
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
</Tile>
<Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
<Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
</div>
<div className="cards">
<Card title="Temperatures">
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
{s.temps.map((t) => {
const limit = t.crit || t.max || 100
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
return (
<div className="row" key={`${t.chip}/${t.label}`}>
<span className="k">{t.label}<small>{t.chip}</small></span>
<Meter frac={t.c / limit} cls={cls} />
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}</span>
</div>
)
})}
</Card>
<Card title="Fans">
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
{s.fans.map((f) => (
<div className="row" key={`${f.chip}/${f.label}`}>
<span className="k">{f.label}<small>{f.chip}</small></span>
<span />
<span className="num">{f.rpm} rpm</span>
</div>
))}
</Card>
<Card title="Network">
{s.net.map((x) => (
<div className="sub-card" key={x.iface}>
<div className="sub-head">
<span className={'dot ' + (x.up ? 'ok' : '')} />
<span>{x.iface}</span>
<span className="spacer" />
<span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>
</div>
{h?.net[x.iface] && (
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
)}
<div className="legend">
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
</div>
</div>
))}
</Card>
<Card title="Disks">
{s.disks.map((d) => (
<div className="sub-card" key={d.name}>
<div className="sub-head">
<span>{d.name}</span>
<span className="model">{d.model}</span>
<span className="spacer" />
<span className="num">{d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
</div>
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
{h?.disk[d.name] && (
<Sparkline series={[{ name: 'read', values: h.disk[d.name].r, color: S1 }, { name: 'write', values: h.disk[d.name].w, color: S2 }]} format={fmtRate} />
)}
<Legend items={[{ name: 'read', color: S1 }, { name: 'write', color: S2 }]} />
</div>
))}
</Card>
<Card title="Filesystems">
{s.fs.map((f) => (
<div className="row" key={f.mount}>
<span className="k">{f.mount}<small>{f.type}</small></span>
{f.size ? <Meter frac={f.used / f.size} /> : <span className="muted">unreachable</span>}
<span className="num">{f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}</span>
</div>
))}
</Card>
<Card title="UPS">
{!s.ups && <div className="empty">No UPS data (pwrstat not available on this host).</div>}
{s.ups && (
<>
<div className="sub-head">
<span className={'dot ' + (s.ups.source === 'Utility Power' ? 'ok' : 'bad')} />
<span>{s.ups.state} · {s.ups.source}</span>
<span className="spacer" />
<span className="model">{s.ups.model}</span>
</div>
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
<div className="row"><span className="k">Voltage</span><span /><span className="num">in {s.ups.in_v} V · out {s.ups.out_v} V</span></div>
{s.ups.last_event && <div className="row"><span className="k">Last event</span><span className="num" style={{ gridColumn: '2 / 4', whiteSpace: 'normal' }}>{s.ups.last_event}</span></div>}
</>
)}
</Card>
</div>
</div>
)
}