Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
395 lines
19 KiB
TypeScript
395 lines
19 KiB
TypeScript
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
|
import Sparkline from './Sparkline'
|
|
|
|
const MAX = 300
|
|
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
|
|
|
// ---- module registry: the customizable blocks, in default order ----
|
|
type Kind = 'tile' | 'card'
|
|
interface Module { id: string; label: string; kind: Kind }
|
|
const MODULES: Module[] = [
|
|
{ id: 'cpu', label: 'CPU', kind: 'tile' },
|
|
{ id: 'mem', label: 'Memory', kind: 'tile' },
|
|
{ id: 'load', label: 'Load', kind: 'tile' },
|
|
{ id: 'uptime', label: 'Uptime', kind: 'tile' },
|
|
{ id: 'temps', label: 'Temperatures', kind: 'card' },
|
|
{ id: 'fans', label: 'Fans', kind: 'card' },
|
|
{ id: 'net', label: 'Network', kind: 'card' },
|
|
{ id: 'disks', label: 'Disks', kind: 'card' },
|
|
{ id: 'fs', label: 'Filesystems', kind: 'card' },
|
|
{ id: 'ups', label: 'UPS', kind: 'card' },
|
|
]
|
|
const byId = (id: string) => MODULES.find((m) => m.id === id)
|
|
|
|
const INTERVALS = [100, 250, 500, 1000, 2000] as const
|
|
const DEFAULT_INTERVAL = 2000
|
|
|
|
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number }
|
|
|
|
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
|
|
function normalize(p: DashPrefs): Layout {
|
|
const known = new Set(MODULES.map((m) => m.id))
|
|
const order = (p.order ?? []).filter((id) => known.has(id))
|
|
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id)
|
|
const iv = p.intervalMs && p.intervalMs >= 100 && p.intervalMs <= 2000 ? p.intervalMs : DEFAULT_INTERVAL
|
|
return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv }
|
|
}
|
|
const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs })
|
|
const fmtMs = (ms: number) => (ms >= 1000 ? `${ms / 1000} s` : `${ms} ms`)
|
|
const fmtWindow = (ms: number) => { const s = Math.round((MAX * ms) / 1000); return s >= 60 ? `${Math.round(s / 60)} min` : `${s} s` }
|
|
|
|
// ---- formatting ----
|
|
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' : '' }
|
|
|
|
// Chips with many sensors (per-core temps on a big CPU) collapse to one row showing the hottest.
|
|
function collapseTemps(temps: Temp[]): (Temp & { key: string })[] {
|
|
const byChip = new Map<string, Temp[]>()
|
|
for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t)
|
|
const out: (Temp & { key: string })[] = []
|
|
for (const [chip, list] of byChip) {
|
|
if (list.length <= 8) { out.push(...list.map((t) => ({ ...t, key: `${t.chip}/${t.label}` }))); continue }
|
|
const hot = list.reduce((a, b) => (b.c > a.c ? b : a))
|
|
out.push({ ...hot, label: `${list.length} sensors · hottest ${hot.label}`, chip, key: `${chip}/*` })
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- small building blocks ----
|
|
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 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>
|
|
}
|
|
|
|
interface Edit {
|
|
on: boolean
|
|
hideItem: (key: string) => void
|
|
unhideItem: (key: string) => void
|
|
hiddenItems: Set<string>
|
|
}
|
|
// In customize mode every row gets a ✕; hidden rows come back as chips under the module.
|
|
function HideBtn({ k, edit }: { k: string; edit: Edit }) {
|
|
return edit.on ? <button className="hide-item" title="hide" onClick={() => edit.hideItem(k)}>✕</button> : null
|
|
}
|
|
function HiddenChips({ prefix, edit, label }: { prefix: string; edit: Edit; label: (k: string) => string }) {
|
|
if (!edit.on) return null
|
|
const keys = [...edit.hiddenItems].filter((k) => k.startsWith(prefix + ':'))
|
|
if (keys.length === 0) return null
|
|
return <div className="chips inline">{keys.map((k) => <button key={k} className="chip" title="show again" onClick={() => edit.unhideItem(k)}>+ {label(k.slice(prefix.length + 1))}</button>)}</div>
|
|
}
|
|
|
|
// ---- module bodies ----
|
|
interface Ctx { s: Sample; h: Series | null; edit: Edit }
|
|
|
|
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
|
|
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
|
|
}
|
|
|
|
function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
|
const shown = (key: string) => !edit.hiddenItems.has(key)
|
|
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
|
|
switch (id) {
|
|
case 'cpu':
|
|
return (
|
|
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length === 0 ? undefined : s.cores.length <= 16 ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}>
|
|
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
|
|
</Tile>
|
|
)
|
|
case 'mem':
|
|
return (
|
|
<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>
|
|
)
|
|
case 'load':
|
|
return <Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
|
|
case 'uptime':
|
|
return <Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
|
|
|
|
case 'temps': {
|
|
const rows = collapseTemps(s.temps).filter((t) => shown(`temp:${t.key}`))
|
|
return (
|
|
<>
|
|
<h2>Temperatures</h2>
|
|
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
|
|
{rows.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.key}>
|
|
<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)}` : ''}<HideBtn k={`temp:${t.key}`} edit={edit} /></span>
|
|
</div>
|
|
)
|
|
})}
|
|
<HiddenChips prefix="temp" edit={edit} label={(k) => k.replace('/*', '')} />
|
|
</>
|
|
)
|
|
}
|
|
case 'fans': {
|
|
const rows = s.fans.filter((f) => shown(`fan:${f.chip}/${f.label}`))
|
|
return (
|
|
<>
|
|
<h2>Fans</h2>
|
|
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
|
|
{rows.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<HideBtn k={`fan:${f.chip}/${f.label}`} edit={edit} /></span>
|
|
</div>
|
|
))}
|
|
<HiddenChips prefix="fan" edit={edit} label={(k) => k} />
|
|
</>
|
|
)
|
|
}
|
|
case 'net':
|
|
return (
|
|
<>
|
|
<h2>Network</h2>
|
|
{s.net.filter((x) => shown(`net:${x.iface}`)).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>
|
|
<HideBtn k={`net:${x.iface}`} edit={edit} />
|
|
</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>
|
|
))}
|
|
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
|
|
</>
|
|
)
|
|
case 'disks':
|
|
return (
|
|
<>
|
|
<h2>Disks</h2>
|
|
{s.disks.length === 0 && <div className="empty">No block devices found.</div>}
|
|
{s.disks.filter((d) => shown(`disk:${d.name}`)).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">{fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
|
|
<HideBtn k={`disk:${d.name}`} edit={edit} />
|
|
</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>
|
|
))}
|
|
<HiddenChips prefix="disk" edit={edit} label={(k) => k} />
|
|
</>
|
|
)
|
|
case 'fs':
|
|
return (
|
|
<>
|
|
<h2>Filesystems</h2>
|
|
{s.fs.filter((f) => shown(`fs:${f.mount}`)).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)}` : ''}<HideBtn k={`fs:${f.mount}`} edit={edit} /></span>
|
|
</div>
|
|
))}
|
|
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
|
|
</>
|
|
)
|
|
case 'ups':
|
|
return (
|
|
<>
|
|
<h2>UPS</h2>
|
|
{!s.ups && <div className="empty">No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).</div>}
|
|
{s.ups && (
|
|
<>
|
|
<div className="sub-head">
|
|
<span className={'dot ' + (s.ups.on_battery ? 'bad' : 'ok')} />
|
|
<span>{s.ups.state} · {s.ups.source}</span>
|
|
<span className="spacer" />
|
|
<span className="model">{s.ups.model} · {s.ups.driver}</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>}
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ---- the dashboard ----
|
|
export default function Dashboard() {
|
|
const [latest, setLatest] = useState<Sample | null>(null)
|
|
const [err, setErr] = useState('')
|
|
const [layout, setLayout] = useState<Layout>(() => normalize({}))
|
|
const [editing, setEditing] = useState(false)
|
|
const hist = useRef<Series | null>(null)
|
|
const saveTimer = useRef<number | undefined>(undefined)
|
|
const intervalRef = useRef(DEFAULT_INTERVAL)
|
|
intervalRef.current = layout.intervalMs
|
|
|
|
// Persist layout changes server-side (debounced) so every device sees the same dashboard.
|
|
const update = useCallback((fn: (l: Layout) => Layout) => {
|
|
setLayout((prev) => {
|
|
const next = fn(prev)
|
|
clearTimeout(saveTimer.current)
|
|
saveTimer.current = window.setTimeout(() => { api.saveDashPrefs(toPrefs(next)).catch(() => setErr('could not save layout')) }, 300)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let stop = false
|
|
let timer: number | undefined
|
|
api.dashPrefs().then((p) => { if (!stop) setLayout(normalize(p)) }).catch(() => { /* defaults */ })
|
|
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, intervalRef.current)
|
|
}
|
|
seed().then(() => { timer = window.setTimeout(tick, intervalRef.current) })
|
|
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 edit: Edit = {
|
|
on: editing,
|
|
hiddenItems: layout.hiddenItems,
|
|
hideItem: (k) => update((l) => ({ ...l, hiddenItems: new Set([...l.hiddenItems, k]) })),
|
|
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
|
|
}
|
|
const ctx: Ctx = { s: latest, h: hist.current, edit }
|
|
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id))
|
|
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
|
|
|
|
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
|
|
const move = (id: string, dir: -1 | 1) => update((l) => {
|
|
const kind = byId(id)!.kind
|
|
const same = l.order.filter((x) => byId(x)?.kind === kind && !l.hidden.has(x))
|
|
const i = same.indexOf(id), j = i + dir
|
|
if (j < 0 || j >= same.length) return l
|
|
const order = [...l.order]
|
|
const a = order.indexOf(id), b = order.indexOf(same[j])
|
|
;[order[a], order[b]] = [order[b], order[a]]
|
|
return { ...l, order }
|
|
})
|
|
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
|
|
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
|
|
const reset = () => update(() => normalize({}))
|
|
|
|
const tools = (id: string, kind: Kind) => {
|
|
if (!editing) return null
|
|
const same = visible(kind), i = same.indexOf(id)
|
|
return (
|
|
<div className="mod-tools">
|
|
<button title="move up" disabled={i === 0} onClick={() => move(id, -1)}>▲</button>
|
|
<button title="move down" disabled={i === same.length - 1} onClick={() => move(id, 1)}>▼</button>
|
|
<button title="hide module" onClick={() => hideMod(id)}>hide</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={'dash' + (editing ? ' editing' : '')}>
|
|
<div className="dash-bar">
|
|
{err && <span className="error">{err}</span>}
|
|
{editing ? (
|
|
<label className="hint">Update every
|
|
<select className="btn" value={layout.intervalMs} onChange={(e) => update((l) => ({ ...l, intervalMs: Number(e.target.value) }))}>
|
|
{INTERVALS.map((ms) => <option key={ms} value={ms}>{fmtMs(ms)}</option>)}
|
|
</select>
|
|
· charts show the last {fmtWindow(layout.intervalMs)} · ▲▼ reorder, ✕ hides a module or row
|
|
</label>
|
|
) : <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
|
|
<span className="spacer" />
|
|
{editing && <button className="btn" onClick={reset}>Reset</button>}
|
|
<button className={'btn' + (editing ? ' on' : '')} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
|
|
</div>
|
|
|
|
{visible('tile').length > 0 && (
|
|
<div className="kpis">
|
|
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
|
|
</div>
|
|
)}
|
|
{visible('card').length > 0 && (
|
|
<div className="cards">
|
|
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
|
|
</div>
|
|
)}
|
|
|
|
{editing && hiddenMods.length > 0 && (
|
|
<div className="card">
|
|
<h2>Hidden modules</h2>
|
|
<div className="chips">{hiddenMods.map((id) => <button key={id} className="chip" onClick={() => showMod(id)}>+ {byId(id)!.label}</button>)}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|