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; hiddenItems: Set; 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() 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
} function Legend({ items }: { items: { name: string; color: string }[] }) { return
{items.map((i) => {i.name})}
} interface Edit { on: boolean hideItem: (key: string) => void unhideItem: (key: string) => void hiddenItems: Set } // 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 ? : 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
{keys.map((k) => )}
} // ---- 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 <>
{label}
{value}
{sub &&
{sub}
}{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 ( Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}> {h && } ) case 'mem': return ( {h && } ) case 'load': return l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} /> case 'uptime': return case 'temps': { const rows = collapseTemps(s.temps).filter((t) => shown(`temp:${t.key}`)) return ( <>

Temperatures

{s.temps.length === 0 &&
No temperature sensors reported by this host.
} {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 (
{t.label}{t.chip} {t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}
) })} k.replace('/*', '')} /> ) } case 'fans': { const rows = s.fans.filter((f) => shown(`fan:${f.chip}/${f.label}`)) return ( <>

Fans

{s.fans.length === 0 &&
No fan sensors reported by this host.
} {rows.map((f) => (
{f.label}{f.chip} {f.rpm} rpm
))} k} /> ) } case 'net': return ( <>

Network

{s.net.filter((x) => shown(`net:${x.iface}`)).map((x) => (
{x.iface} ↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}
{h?.net[x.iface] && ( )}
rx · {fmtBytes(x.rx_total)} total tx · {fmtBytes(x.tx_total)} total
))} k} /> ) case 'disks': return ( <>

Disks

{s.disks.length === 0 &&
No block devices found.
} {s.disks.filter((d) => shown(`disk:${d.name}`)).map((d) => (
{d.name} {d.model} {fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy
read {fmtRate(d.read_bps)}write {fmtRate(d.write_bps)}
{h?.disk[d.name] && ( )}
))} k} /> ) case 'fs': return ( <>

Filesystems

{s.fs.filter((f) => shown(`fs:${f.mount}`)).map((f) => (
{f.mount}{f.type} {f.size ? : unreachable} {f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}
))} k} /> ) case 'ups': return ( <>

UPS

{!s.ups &&
No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).
} {s.ups && ( <>
{s.ups.state} · {s.ups.source} {s.ups.model} · {s.ups.driver}
Battery{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min
Load{s.ups.load_w} W · {pct(s.ups.load_pct)}
Voltagein {s.ups.in_v} V · out {s.ups.out_v} V
{s.ups.last_event &&
Last event{s.ups.last_event}
} )} ) } return null } // ---- the dashboard ---- export default function Dashboard() { const [latest, setLatest] = useState(null) const [err, setErr] = useState('') const [layout, setLayout] = useState(() => normalize({})) const [editing, setEditing] = useState(false) const hist = useRef(null) const saveTimer = useRef(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
{err || '…'}
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 (
) } return (
{err && {err}} {editing ? ( ) : last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}} {editing && }
{visible('tile').length > 0 && (
{visible('tile').map((id) =>
{tools(id, 'tile')}{body(id, ctx)}
)}
)} {visible('card').length > 0 && (
{visible('card').map((id) =>
{tools(id, 'card')}{body(id, ctx)}
)}
)} {editing && hiddenMods.length > 0 && (

Hidden modules

{hiddenMods.map((id) => )}
)}
) }