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:
co-authored by
Claude Opus 5
parent
885b050d02
commit
04dd98e2cd
+24
-7
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user