import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { api, DashPrefs, Sample, Series, Temp } from './api' import Sparkline from './Sparkline' import { Loading, Spinner, askYes, toast } from './ui' import { fmtMs, fmtWindowSec, nextPoint, strideMs, useHistorySec, useRefreshMs } from './prefs' import { fetchCatalog, fetchHistories, useSensorStream } from './sensors' import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter' import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets' import WidgetPicker from './WidgetPicker' 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 DEFAULT_INTERVAL = 2000 const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms)) interface Layout { order: string[]; hidden: Set; hiddenItems: Set; intervalMs: number; widgets: WidgetSpec[] } // Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules. function normalize(raw: DashPrefs): Layout { // Saved prefs are whatever some client last wrote; a wrong shape must not take the page down. const list = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []) const p = { order: list(raw?.order), hidden: list(raw?.hidden), hiddenItems: list(raw?.hiddenItems), widgets: list(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined } 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, widgets: (p.widgets ?? []).filter((w) => w && typeof w.id === 'string' && typeof w.sensor === 'string') } } const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs, widgets: l.widgets }) // ---- formatting ---- // A live value either starts a new point or is averaged into the one being built — the same // rule the machine itself uses to keep its history, so the tail of a six-hour graph isn't a // minute of 2-second samples crushed against the right edge. n is how many values this point // has had, including this one. function pushPoint(arr: number[], v: number, fresh: boolean, n: number) { if (!fresh && arr.length) { arr[arr.length - 1] += (v - arr[arr.length - 1]) / n; return } 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} {!x.up && down} {x.up && ↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}}
{x.up && h?.net[x.iface] && ( )} {x.up &&
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 [loaded, setLoaded] = useState(false) const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('') const savedTimer = useRef(undefined) const hist = useRef(null) const saveTimer = useRef(undefined) const refreshMs = useRefreshMs() const historySec = useHistorySec() // 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) setSaving('saving') saveTimer.current = window.setTimeout(async () => { try { await api.saveDashPrefs(toPrefs(next)) setSaving('saved') clearTimeout(savedTimer.current) savedTimer.current = window.setTimeout(() => setSaving(''), 1500) } catch (e) { setSaving(''); toast(`layout not saved — ${e instanceof Error ? e.message : 'failed'}`, 'err') } }, 300) return next }) }, []) useEffect(() => { let stop = false api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout')) return () => { stop = true } }, []) // Everything on this page comes from the sensor API: the catalog for names, labels and // thresholds; one batch of histories to seed the curves; one stream for the values. const catalog = useRef(new Map()) const refetching = useRef(false) const binN = useRef(1) // values averaged into the point currently being built const lastPush = useRef(0) // when that point was started, by this browser's clock const stock = useSensorStream(MONITOR_SHAPE, { full: false, everyMs: refreshMs }) const seed = useCallback(async () => { try { catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x])) hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current) lastPush.current = Date.now(); binN.current = 1 setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) useEffect(() => { seed() }, [seed, stock.resumed]) // on load, and again after the page was hidden: the curves have a gap to refill useEffect(() => { if (!stock.data || catalog.current.size === 0) return const v: Values = mergeValues(stock.data) if (stale(v, catalog.current) && !refetching.current) { // something appeared (a disk, an interface): learn its labels refetching.current = true fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000)) } const s = sampleOf(v, catalog.current) const h = hist.current if (h) { // The stride is timed on this clock alone (lastPush), never against the history's own // timestamps: those are the machine's, and a viewer whose clock runs minutes slow would // otherwise wait those minutes before its curves gained a point. const np = nextPoint(s.t, h.t.length === 0 ? 0 : lastPush.current, strideMs(historySec, refreshMs), refreshMs) const fresh = np.fresh lastPush.current = np.start binN.current = fresh ? 1 : binN.current + 1 if (fresh) pushPoint(h.t, s.t, true, 1) const n = h.t.length // a curve that has no point in this one yet starts one, whatever the others are doing: // an interface or disk that appeared mid-point must still line up with the time axis const push = (arr: number[], x: number) => pushPoint(arr, x, fresh || arr.length < n, binN.current) push(h.cpu, s.cpu); push(h.mem, s.mem.used) for (const x of s.net) { const c = (h.net[x.iface] ??= { rx: [], tx: [] }) pad(c.rx, n - 1); pad(c.tx, n - 1); push(c.rx, x.rx_bps); push(c.tx, x.tx_bps) } for (const d of s.disks) { const c = (h.disk[d.name] ??= { r: [], w: [], u: [] }) pad(c.r, n - 1); pad(c.w, n - 1); pad(c.u, n - 1); push(c.r, d.read_bps); push(c.w, d.write_bps); push(c.u, d.util_pct) } for (const t of s.temps) { const c = (h.temp[`${t.chip}/${t.label}`] ??= []) pad(c, n - 1); push(c, t.c) } } setLatest(s) }, [stock.data, historySec, refreshMs]) useEffect(() => { if (stock.error && !latest) setErr(stock.error) }, [stock.error, latest]) // The sampler on this machine follows the global rate (clamped to what it supports). useEffect(() => { if (!loaded) return const want = clampSampler(refreshMs) if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want })) }, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps // Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses. const [picking, setPicking] = useState(false) const [editingWidget, setEditingWidget] = useState(null) const live = useSensorStream>(shapeOf(layout.widgets)) if (!latest) return
{err ?
{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 } // Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing. const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0) const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(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 = async () => { if (await askYes(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) 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 {fmtWindowSec(historySec)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings : last {fmtWindowSec(historySec)} · {fmtMs(latest.interval_ms || layout.intervalMs)}} {saving === 'saving' && saving} {saving === 'saved' && saved ✓} {editing && }
{visible('tile').length > 0 && (
{visible('tile').map((id) =>
{tools(id, 'tile')}{body(id, ctx)}
)}
)} {(layout.widgets.length > 0 || editing) && (
{layout.widgets.map((w, i) => (
{editing && (
)}
))} {editing && } {live.error && !live.data && layout.widgets.length > 0 &&
The widgets' stream isn't answering: {live.error}.
}
)} {editingWidget && setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />} {picking && setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />} {visible('card').length > 0 && (
{visible('card').map((id) =>
{tools(id, 'card')}{body(id, ctx)}
)}
)} {editing && hiddenMods.length > 0 && (

Hidden modules

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