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
+4 -1
View File
@@ -4,7 +4,7 @@ import Login from './Login'
import Shell from './Shell'
import { Dialogs } from './ui'
import { applyTheme, currentThemeId } from './themes'
import { getRefreshMs, setRefreshMs } from './prefs'
import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs'
import { setAccents } from './accents'
export default function App() {
@@ -18,6 +18,9 @@ export default function App() {
if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs)
if (p.accents && typeof p.accents === 'object') setAccents(p.accents, false)
}).catch(() => { /* keep local */ })
// The graph window is the machines' own setting; the copy here only labels the curves
// and spaces the live points, so take the hub's word for it at every login.
api.historyWindow().then((h) => { if (h.seconds && h.seconds !== getHistorySec()) setHistorySec(h.seconds) }).catch(() => { /* keep local */ })
} catch (e) { if (e instanceof Unauthenticated) setMe(null) }
}, [])
+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>}
+28 -2
View File
@@ -4,7 +4,7 @@ import { api, Me } from './api'
import { savePrefs } from './accents'
import { applyTheme, currentThemeId, THEMES } from './themes'
import { Btn, Loading, askYes, toast, useEscape } from './ui'
import { fmtMs, getRefreshMs, REFRESH_OPTIONS, setRefreshMs } from './prefs'
import { fmtMs, fmtWindowSec, getHistorySec, getRefreshMs, HISTORY_OPTIONS, HISTORY_POINTS, REFRESH_OPTIONS, setHistorySec, setRefreshMs, strideMs } from './prefs'
const fmtUptime = (s: number) => { 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` }
@@ -33,6 +33,8 @@ function Preview({ vars }: { vars: Record<string, string> }) {
export default function Settings({ me }: { me: Me }) {
const [theme, setTheme] = useState(currentThemeId())
const [refresh, setRefresh] = useState(getRefreshMs())
const [history, setHistory] = useState(getHistorySec())
const [histErr, setHistErr] = useState('')
const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null)
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
const [making, setMaking] = useState(false)
@@ -52,6 +54,14 @@ export default function Settings({ me }: { me: Me }) {
setRefreshMs(ms); setRefresh(ms)
await savePrefs()
}
// The window is the machine's own setting, not a look: the hub takes it and passes it on.
useEffect(() => { api.historyWindow().then((h) => { setHistory(h.seconds); setHistorySec(h.seconds) }).catch(() => {}) }, [])
const pickHistory = async (sec: number) => {
const was = history
setHistory(sec); setHistorySec(sec); setHistErr('')
try { const h = await api.setHistoryWindow(sec); setHistory(h.seconds); setHistorySec(h.seconds) }
catch (e) { setHistory(was); setHistorySec(was); setHistErr(e instanceof Error ? e.message : 'failed') }
}
async function changePassword(e: FormEvent) {
e.preventDefault()
@@ -85,11 +95,27 @@ export default function Settings({ me }: { me: Me }) {
{REFRESH_OPTIONS.map((ms) => <button key={ms} className={'chip' + (refresh === ms ? ' on' : '')} onClick={() => pickRefresh(ms)}>{fmtMs(ms)}</button>)}
</div>
<div className="muted small" style={{ marginTop: 8 }}>
Every page polls at exactly this rate: dashboard, processes, disks, services, cron, updates, machines. The sampler on each machine follows it too, so charts keep 300 points and cover 300 × rate.
Every page polls at exactly this rate: dashboard, processes, disks, services, cron, updates, machines. The sampler on each machine follows it too, so it is also the finest a graph can be — how far back the graphs reach is the next setting down.
Fast rates cost CPU on the host, especially on the list pages, which run commands to refresh. Nothing is throttled behind your back.
</div>
</section>
<div className="dash-bar"><h2 className="section">Graph history</h2><span className="spacer" /><span className="muted small">every graph, on every machine</span></div>
<section className="card">
<div className="chips">
{HISTORY_OPTIONS.map((sec) => <button key={sec} className={'chip' + (history === sec ? ' on' : '')} onClick={() => pickHistory(sec)}>{fmtWindowSec(sec)}</button>)}
</div>
{histErr && <div className="error" style={{ marginTop: 8 }}>{histErr}</div>}
<div className="muted small" style={{ marginTop: 8 }}>
How far back every sparkline reaches — the dashboard, the processes page, the disks
page and any sparkline widget. Each machine keeps {HISTORY_POINTS} points per curve whatever you
pick, so a wider window is a coarser one: <b>{fmtWindowSec(history)}</b> puts a point every{' '}
{fmtWindowSec(strideMs(history, refresh) / 1000)}, each the average of what happened
in between rather than one instant out of it. Memory on the machine doesn't change.
The history is kept in memory, so a machine that restarts starts its graphs again.
</div>
</section>
<div className="dash-bar"><h2 className="section">Account · {me.host}</h2></div>
<form className="card addform" onSubmit={changePassword}>
<label>Current password <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" required /></label>
+3
View File
@@ -82,6 +82,7 @@ export interface Series {
// Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number }
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number; widgets?: { id: string; type: 'stat' | 'gauge' | 'bar' | 'spark' | 'list'; sensor: string; title?: string }[] }
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
@@ -179,6 +180,8 @@ export const api = {
serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`),
serviceLogs: (unit: string, n = 200) => req<LogEntry[]>('GET', `/api/services/${encodeURIComponent(unit)}/logs?n=${n}`),
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true),
setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
login: (password: string) => req<void>('POST', '/api/login', { password }, true),
+44
View File
@@ -21,6 +21,50 @@ export function setRefreshMs(ms: number) {
export function fmtMs(ms: number): string { return ms >= 1000 ? `${ms / 1000} s` : `${ms} ms` }
// ---- how far back the graphs reach ----
// Unlike the refresh rate this is the machine's own setting, not a look: the hub keeps the
// window and pushes it to every machine, so the graphs of any of them reach equally far back.
// The value is cached here only so a page can draw before the hub has answered.
export const HISTORY_OPTIONS = [60, 300, 600, 1800, 3600, 3 * 3600, 6 * 3600, 12 * 3600, 24 * 3600] as const
export const DEFAULT_HISTORY_SEC = 600
export const HISTORY_POINTS = 300 // what a machine keeps per curve, whatever the window
const HKEY = 'hamlab.historySec'
export function getHistorySec(): number {
const v = Number(localStorage.getItem(HKEY))
return v >= 60 && v <= 86400 ? v : DEFAULT_HISTORY_SEC
}
export function setHistorySec(sec: number) {
if (!(sec >= 60 && sec <= 86400)) return
localStorage.setItem(HKEY, String(sec))
window.dispatchEvent(new CustomEvent('hamlab:history', { detail: sec }))
}
// useHistorySec re-renders the caller when the window changes.
export function useHistorySec(): number {
const [sec, setSec] = useState(getHistorySec())
useEffect(() => {
const h = (e: Event) => setSec((e as CustomEvent<number>).detail)
window.addEventListener('hamlab:history', h)
return () => window.removeEventListener('hamlab:history', h)
}, [])
return sec
}
// How far apart the kept points are: the refresh rate until the window is wider than
// HISTORY_POINTS of it, then the window split that many ways. Mirrors the server.
export function strideMs(windowSec: number, refreshMs: number): number {
return Math.max(refreshMs, Math.round((windowSec * 1000) / HISTORY_POINTS))
}
export function fmtWindowSec(sec: number): string {
if (sec >= 3600) { const h = sec / 3600; return `${Number.isInteger(h) ? h : h.toFixed(1)} hour${h === 1 ? '' : 's'}` }
if (sec >= 60) { const m = sec / 60; return `${Number.isInteger(m) ? m : m.toFixed(1)} min` }
return `${Math.round(sec)} s`
}
// useRefreshMs re-renders the caller when the global rate changes.
export function useRefreshMs(floor = 0): number {
const [ms, setMs] = useState(getRefreshMs())
+19 -5
View File
@@ -41,17 +41,31 @@ function Gauge({ s }: { s: Sensor }) {
function Spark({ s, src }: { s: Sensor; src?: Source }) {
const pts = useRef<number[]>([])
const lastT = useRef(0)
const stride = useRef(0) // how far apart the machine's own points are, as they arrive
const [, bump] = useState(0)
useEffect(() => {
let stop = false
pts.current = []; lastT.current = 0
fetchHistory(s.id, src).then((h) => { if (!stop && h) { pts.current = h.v.slice(-MAX_POINTS); lastT.current = h.t[h.t.length - 1] ?? 0; bump((n) => n + 1) } })
pts.current = []; lastT.current = 0; stride.current = 0
fetchHistory(s.id, src).then((h) => {
if (stop || !h) return
pts.current = h.v.slice(-MAX_POINTS); lastT.current = h.t[h.t.length - 1] ?? 0
const t = h.t.slice(-MAX_POINTS)
const gaps = t.slice(1).map((x, i) => x - t[i]).sort((a, b) => a - b)
stride.current = gaps.length ? gaps[gaps.length >> 1] : 0 // the median: one long gap is a restart, not the spacing
bump((n) => n + 1)
})
return () => { stop = true }
}, [s.id, src?.machine, src?.token]) // eslint-disable-line react-hooks/exhaustive-deps
if (typeof s.value === 'number' && s.t > lastT.current) {
lastT.current = s.t
pts.current.push(s.value)
if (pts.current.length > MAX_POINTS) pts.current.shift()
// a new point once a stride has passed; until then the newest point keeps being refined,
// so a wide window's curve isn't squeezed by a fast stream of live values
if (pts.current.length && s.t - lastT.current < stride.current) {
pts.current[pts.current.length - 1] = s.value
} else {
lastT.current = s.t
pts.current.push(s.value)
if (pts.current.length > MAX_POINTS) pts.current.shift()
}
}
return <Sparkline series={[{ name: s.name, values: pts.current, color: 'var(--s1)' }]} max={s.unit === '%' ? 100 : s.max} format={(v) => fmtValue(v, s.unit)} height={40} />
}