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:
co-authored by
Claude Fable 5.1
parent
24d64245ec
commit
3c6f4d4639
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user