From 1751afc974950573cd332ff8f344dc20fd5af6e0 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Thu, 17 Sep 2026 13:54:48 -0700 Subject: [PATCH] =?UTF-8?q?Configurable=20update=20interval=20(100=20ms=20?= =?UTF-8?q?=E2=80=93=202=20s):=20drives=20daemon=20sampling=20and=20UI=20p?= =?UTF-8?q?olling,=20saved=20with=20the=20dashboard=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- PLAN.md | 5 +- internal/api/server.go | 21 ++++++++ internal/metrics/metrics.go | 96 +++++++++++++++++++++++++------------ web/src/Dashboard.tsx | 27 ++++++++--- web/src/api.ts | 4 +- web/src/styles.css | 3 +- 6 files changed, 115 insertions(+), 41 deletions(-) diff --git a/PLAN.md b/PLAN.md index 04dd39d..848aec0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -137,7 +137,10 @@ something long in one, switch away and back, refresh — it's all still there. Customize mode reorders and hides modules (CPU, Memory, Load, Uptime, Temperatures, Fans, Network, Disks, Filesystems, UPS) and hides individual rows (an interface, a mount, a disk, a sensor); the layout is stored server-side in `state.json` via - `GET/PUT /api/prefs/dashboard` so every device sees the same dashboard. Still to do: persistent + `GET/PUT /api/prefs/dashboard` so every device sees the same dashboard. The update + interval (100 ms – 2 s) is part of the layout and drives both the browser's polling and + the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms + and 10 min at 2 s. Still to do: persistent history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges. - **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini / `wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a diff --git a/internal/api/server.go b/internal/api/server.go index 580da65..fac3a03 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -50,6 +50,11 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me if s.user == "" { s.user = "?" } + // The saved dashboard layout carries the sampling interval; apply it on boot. + var raw json.RawMessage + st.View(func(x *store.State) { raw = x.Dashboard }) + s.applyPrefs(raw) + m := http.NewServeMux() m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) m.HandleFunc("POST /api/login", s.login) @@ -212,9 +217,25 @@ func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) { jsonErr(w, 500, err.Error()) return } + s.applyPrefs(b) w.WriteHeader(204) } +// applyPrefs picks the few layout fields the server acts on (just the interval). +func (s *Server) applyPrefs(raw []byte) { + var p struct { + IntervalMs int `json:"intervalMs"` + } + if len(raw) == 0 || json.Unmarshal(raw, &p) != nil { + return + } + if p.IntervalMs > 0 { + s.metrics.SetInterval(time.Duration(p.IntervalMs) * time.Millisecond) + } else { + s.metrics.SetInterval(metrics.DefaultInterval) + } +} + // ---- terminals ---- func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index a317c98..c2b72b4 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -25,11 +25,13 @@ import ( ) const ( - Interval = 2 * time.Second - History = 300 // samples kept (10 min at 2 s) - fsEvery = 15 // sample fs usage every N ticks - upsEvery = 5 // sample UPS every N ticks - sectorSize = 512 // /proc/diskstats always counts 512-byte sectors + DefaultInterval = 2 * time.Second + MinInterval = 100 * time.Millisecond + MaxInterval = 2 * time.Second + History = 300 // samples kept; the window is History × interval (30 s at 100 ms, 10 min at 2 s) + fsEvery = 30 * time.Second // filesystem usage changes slowly and statfs on network mounts isn't free + upsEvery = 10 * time.Second // UPS daemons are an exec each; don't hammer them at fast intervals + sectorSize = 512 // /proc/diskstats always counts 512-byte sectors ) type Mem struct { @@ -94,19 +96,20 @@ type UPS struct { } type Sample struct { - T int64 `json:"t"` // unix ms - CPU float64 `json:"cpu"` - Cores []float64 `json:"cores"` - Load [3]float64 `json:"load"` - Mem Mem `json:"mem"` - Swap Mem `json:"swap"` - Temps []Temp `json:"temps"` - Fans []Fan `json:"fans"` - Net []Net `json:"net"` - Disks []Disk `json:"disks"` - FS []FS `json:"fs"` - UPS *UPS `json:"ups"` - Uptime float64 `json:"uptime"` + T int64 `json:"t"` // unix ms + Interval int64 `json:"interval_ms"` // sampling interval in effect + CPU float64 `json:"cpu"` + Cores []float64 `json:"cores"` + Load [3]float64 `json:"load"` + Mem Mem `json:"mem"` + Swap Mem `json:"swap"` + Temps []Temp `json:"temps"` + Fans []Fan `json:"fans"` + Net []Net `json:"net"` + Disks []Disk `json:"disks"` + FS []FS `json:"fs"` + UPS *UPS `json:"ups"` + Uptime float64 `json:"uptime"` } // Series is the compact history the UI seeds its sparklines from. @@ -120,21 +123,25 @@ type Series struct { } type Collector struct { - mu sync.RWMutex - ring []Sample - tick int + mu sync.RWMutex + ring []Sample + interval time.Duration + wake chan struct{} // nudges Run when the interval changes prevCPU []cpuTimes prevNet map[string][2]uint64 prevDisk map[string][3]uint64 // sectors read, sectors written, io ticks ms prevT time.Time lastFS []FS + fsAt time.Time lastUPS *UPS + upsAt time.Time upsProbe func() *UPS // nil when no UPS daemon is installed } func NewCollector() *Collector { - c := &Collector{prevNet: map[string][2]uint64{}, prevDisk: map[string][3]uint64{}} + c := &Collector{prevNet: map[string][2]uint64{}, prevDisk: map[string][3]uint64{}, + interval: DefaultInterval, wake: make(chan struct{}, 1)} switch { case lookPath("pwrstat"): c.upsProbe = upsPwrstat @@ -146,21 +153,49 @@ func NewCollector() *Collector { return c } -// Run samples until ctx is cancelled. +// Run samples until ctx is cancelled, at whatever interval is current. func (c *Collector) Run(ctx context.Context) { c.sample() // prime deltas - t := time.NewTicker(Interval) - defer t.Stop() for { + t := time.NewTimer(c.Interval()) select { case <-ctx.Done(): + t.Stop() return + case <-c.wake: // interval changed: restart the wait + t.Stop() case <-t.C: c.sample() } } } +// SetInterval changes the sampling period, clamped to [MinInterval, MaxInterval]. +func (c *Collector) SetInterval(d time.Duration) { + if d < MinInterval { + d = MinInterval + } + if d > MaxInterval { + d = MaxInterval + } + c.mu.Lock() + changed := d != c.interval + c.interval = d + c.mu.Unlock() + if changed { + select { + case c.wake <- struct{}{}: + default: + } + } +} + +func (c *Collector) Interval() time.Duration { + c.mu.RLock() + defer c.mu.RUnlock() + return c.interval +} + func (c *Collector) Latest() (Sample, bool) { c.mu.RLock() defer c.mu.RUnlock() @@ -216,7 +251,7 @@ func (c *Collector) sample() { first := c.prevT.IsZero() c.prevT = now - s := Sample{T: now.UnixMilli(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}} + s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}} s.CPU, s.Cores = c.cpu() s.Load = loadavg() s.Mem, s.Swap = meminfo() @@ -228,15 +263,14 @@ func (c *Collector) sample() { s.Net = c.net(dt) s.Disks = c.disks(dt) - if c.tick%fsEvery == 0 || c.lastFS == nil { - c.lastFS = filesystems() + if c.lastFS == nil || now.Sub(c.fsAt) >= fsEvery { + c.lastFS, c.fsAt = filesystems(), now } s.FS = c.lastFS - if c.upsProbe != nil && (c.tick%upsEvery == 0 || c.lastUPS == nil) { - c.lastUPS = c.upsProbe() + if c.upsProbe != nil && (c.upsAt.IsZero() || now.Sub(c.upsAt) >= upsEvery) { + c.lastUPS, c.upsAt = c.upsProbe(), now } s.UPS = c.lastUPS - c.tick++ if first { return // deltas are meaningless on the priming pass diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index 6e945f3..9179348 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -22,16 +22,22 @@ const MODULES: Module[] = [ ] const byId = (id: string) => MODULES.find((m) => m.id === id) -interface Layout { order: string[]; hidden: Set; hiddenItems: Set } +const INTERVALS = [100, 250, 500, 1000, 2000] as const +const DEFAULT_INTERVAL = 2000 + +interface Layout { order: string[]; hidden: Set; hiddenItems: Set; intervalMs: number } // Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules. function normalize(p: DashPrefs): Layout { 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) - return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []) } + 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 } } -const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems] }) +const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs }) +const fmtMs = (ms: number) => (ms >= 1000 ? `${ms / 1000} s` : `${ms} ms`) +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() } @@ -256,6 +262,8 @@ export default function Dashboard() { const [editing, setEditing] = useState(false) const hist = useRef(null) const saveTimer = useRef(undefined) + const intervalRef = useRef(DEFAULT_INTERVAL) + intervalRef.current = layout.intervalMs // Persist layout changes server-side (debounced) so every device sees the same dashboard. const update = useCallback((fn: (l: Layout) => Layout) => { @@ -300,9 +308,9 @@ export default function Dashboard() { setLatest(s); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } - timer = window.setTimeout(tick, 2000) + timer = window.setTimeout(tick, intervalRef.current) } - seed().then(() => { timer = window.setTimeout(tick, 2000) }) + 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) } @@ -351,7 +359,14 @@ export default function Dashboard() {
{err && {err}} - {editing && Reorder with ▲▼, hide modules or single rows with ✕. Layout is saved as you go.} + {editing ? ( + + ) : last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}} {editing && } diff --git a/web/src/api.ts b/web/src/api.ts index 1d1edb0..96f3144 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -27,7 +27,7 @@ export interface Disk { name: string; model?: string; size: number; read_bps: nu export interface FS { mount: string; type: string; size: number; used: number } export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string } export interface Sample { - t: number; cpu: number; cores: number[]; load: [number, number, number] + t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number] mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number } temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; uptime: number } @@ -40,7 +40,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 DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[] } +export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number } export const api = { dashPrefs: () => req('GET', '/api/prefs/dashboard'), diff --git a/web/src/styles.css b/web/src/styles.css index e10ba2c..5a6d54f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -109,7 +109,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi /* dashboard: customize mode */ .dash-bar { display: flex; align-items: center; gap: 8px; min-height: 28px; } -.dash-bar .hint { color: var(--muted); font-size: 12px; } +.dash-bar .hint { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.dash-bar select.btn { padding: 3px 6px; } .btn { padding: 5px 10px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-size: 12px; color: var(--fg); } .btn.on { background: var(--accent); color: #111; border-color: var(--accent); } .mod-tools { display: flex; gap: 4px; margin: -4px -4px 6px auto; justify-content: flex-end; }