Settings: one window for how far back every graph reaches, 1 minute to 24 hours

Each machine keeps 300 points per curve whatever the window, so memory doesn't follow it;
a wider window means wider points, each the average of its bucket rather than one instant
out of it. The hub passes the setting to every machine it manages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-20 19:07:55 -07:00
co-authored by Claude Opus 5
parent 885b050d02
commit 04dd98e2cd
13 changed files with 533 additions and 33 deletions
+24 -7
View File
@@ -2,7 +2,7 @@ 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, useRefreshMs } from './prefs'
import { fmtMs, fmtWindowSec, 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'
@@ -46,10 +46,17 @@ function normalize(raw: DashPrefs): Layout {
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 })
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() }
// 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 {
@@ -276,6 +283,7 @@ export default function Dashboard() {
const hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(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) => {
@@ -305,6 +313,7 @@ export default function Dashboard() {
// 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 binN = useRef(1) // values averaged into the point currently being built
const stock = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
const seed = useCallback(async () => {
try {
@@ -324,8 +333,16 @@ export default function Dashboard() {
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)
// the point being built keeps the time it started at, so the next one is a whole stride later
const last = h.t[h.t.length - 1] ?? 0
const fresh = h.t.length === 0 || s.t - last >= strideMs(historySec, refreshMs)
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)
@@ -340,7 +357,7 @@ export default function Dashboard() {
}
}
setLatest(s)
}, [stock.data])
}, [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).
@@ -400,8 +417,8 @@ export default function Dashboard() {
<div className={'dash' + (editing ? ' editing' : '')}>
<div className="dash-bar">
{err && <span className="error">{err}</span>}
{editing ? <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
: <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
{editing ? <span className="hint">last {fmtWindowSec(historySec)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
: <span className="hint">last {fmtWindowSec(historySec)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
<span className="spacer" />
{saving === 'saving' && <span className="saved"><Spinner /> saving</span>}
{saving === 'saved' && <span className="saved">saved ✓</span>}