The full switch: Monitors and Disks read only the sensor API; /api/metrics retired

- stock Monitors modules fed by one sensor stream, the catalog and one batch of
  histories (GET /api/sensors/history?ids=…), through sampleAdapter.ts
- the stream closes while the page is hidden and refills its curves on return
- Disks tab live rates from the stream
- new sensors: hamlab.sample_interval, ups.last_event
- stream: asking for about the sampling rate means every sample
- NUT runtime rounded at the source

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 07:52:14 -07:00
co-authored by Claude Fable 5.1
parent f01175ceca
commit 4ddf10be51
13 changed files with 283 additions and 173 deletions
+45 -39
View File
@@ -3,7 +3,8 @@ import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline'
import { Loading, Spinner, toast } from './ui'
import { fmtMs, useRefreshMs } from './prefs'
import { useSensorStream } from './sensors'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, sampleOf, seriesOf, stale } from './sampleAdapter'
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
import WidgetPicker from './WidgetPicker'
@@ -272,8 +273,6 @@ export default function Dashboard() {
const hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(undefined)
const refreshMs = useRefreshMs()
const intervalRef = useRef(DEFAULT_INTERVAL)
intervalRef.current = refreshMs
// Persist layout changes server-side (debounced) so every device sees the same dashboard.
const update = useCallback((fn: (l: Layout) => Layout) => {
@@ -295,45 +294,52 @@ export default function Dashboard() {
useEffect(() => {
let stop = false
let timer: number | undefined
api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout'))
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) }
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<Catalog>(new Map())
const refetching = useRef(false)
const stock = useSensorStream<{ v: Values }>(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)
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(() => {
const v = stock.data?.v
if (!v || catalog.current.size === 0) return
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) {
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 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])
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