Configurable update interval (100 ms – 2 s): drives daemon sampling and UI polling, saved with the dashboard layout

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 13:54:48 -07:00
co-authored by Claude Fable 5.1
parent eb70daf129
commit 1751afc974
6 changed files with 115 additions and 41 deletions
+4 -1
View File
@@ -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, Customize mode reorders and hides modules (CPU, Memory, Load, Uptime, Temperatures,
Fans, Network, Disks, Filesystems, UPS) and hides individual rows (an interface, a 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 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. 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 / - **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 `wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a
+21
View File
@@ -50,6 +50,11 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
if s.user == "" { if s.user == "" {
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 := http.NewServeMux()
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
m.HandleFunc("POST /api/login", s.login) 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()) jsonErr(w, 500, err.Error())
return return
} }
s.applyPrefs(b)
w.WriteHeader(204) 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 ---- // ---- terminals ----
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
+65 -31
View File
@@ -25,11 +25,13 @@ import (
) )
const ( const (
Interval = 2 * time.Second DefaultInterval = 2 * time.Second
History = 300 // samples kept (10 min at 2 s) MinInterval = 100 * time.Millisecond
fsEvery = 15 // sample fs usage every N ticks MaxInterval = 2 * time.Second
upsEvery = 5 // sample UPS every N ticks History = 300 // samples kept; the window is History × interval (30 s at 100 ms, 10 min at 2 s)
sectorSize = 512 // /proc/diskstats always counts 512-byte sectors 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 { type Mem struct {
@@ -94,19 +96,20 @@ type UPS struct {
} }
type Sample struct { type Sample struct {
T int64 `json:"t"` // unix ms T int64 `json:"t"` // unix ms
CPU float64 `json:"cpu"` Interval int64 `json:"interval_ms"` // sampling interval in effect
Cores []float64 `json:"cores"` CPU float64 `json:"cpu"`
Load [3]float64 `json:"load"` Cores []float64 `json:"cores"`
Mem Mem `json:"mem"` Load [3]float64 `json:"load"`
Swap Mem `json:"swap"` Mem Mem `json:"mem"`
Temps []Temp `json:"temps"` Swap Mem `json:"swap"`
Fans []Fan `json:"fans"` Temps []Temp `json:"temps"`
Net []Net `json:"net"` Fans []Fan `json:"fans"`
Disks []Disk `json:"disks"` Net []Net `json:"net"`
FS []FS `json:"fs"` Disks []Disk `json:"disks"`
UPS *UPS `json:"ups"` FS []FS `json:"fs"`
Uptime float64 `json:"uptime"` UPS *UPS `json:"ups"`
Uptime float64 `json:"uptime"`
} }
// Series is the compact history the UI seeds its sparklines from. // Series is the compact history the UI seeds its sparklines from.
@@ -120,21 +123,25 @@ type Series struct {
} }
type Collector struct { type Collector struct {
mu sync.RWMutex mu sync.RWMutex
ring []Sample ring []Sample
tick int interval time.Duration
wake chan struct{} // nudges Run when the interval changes
prevCPU []cpuTimes prevCPU []cpuTimes
prevNet map[string][2]uint64 prevNet map[string][2]uint64
prevDisk map[string][3]uint64 // sectors read, sectors written, io ticks ms prevDisk map[string][3]uint64 // sectors read, sectors written, io ticks ms
prevT time.Time prevT time.Time
lastFS []FS lastFS []FS
fsAt time.Time
lastUPS *UPS lastUPS *UPS
upsAt time.Time
upsProbe func() *UPS // nil when no UPS daemon is installed upsProbe func() *UPS // nil when no UPS daemon is installed
} }
func NewCollector() *Collector { 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 { switch {
case lookPath("pwrstat"): case lookPath("pwrstat"):
c.upsProbe = upsPwrstat c.upsProbe = upsPwrstat
@@ -146,21 +153,49 @@ func NewCollector() *Collector {
return c 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) { func (c *Collector) Run(ctx context.Context) {
c.sample() // prime deltas c.sample() // prime deltas
t := time.NewTicker(Interval)
defer t.Stop()
for { for {
t := time.NewTimer(c.Interval())
select { select {
case <-ctx.Done(): case <-ctx.Done():
t.Stop()
return return
case <-c.wake: // interval changed: restart the wait
t.Stop()
case <-t.C: case <-t.C:
c.sample() 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) { func (c *Collector) Latest() (Sample, bool) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -216,7 +251,7 @@ func (c *Collector) sample() {
first := c.prevT.IsZero() first := c.prevT.IsZero()
c.prevT = now 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.CPU, s.Cores = c.cpu()
s.Load = loadavg() s.Load = loadavg()
s.Mem, s.Swap = meminfo() s.Mem, s.Swap = meminfo()
@@ -228,15 +263,14 @@ func (c *Collector) sample() {
s.Net = c.net(dt) s.Net = c.net(dt)
s.Disks = c.disks(dt) s.Disks = c.disks(dt)
if c.tick%fsEvery == 0 || c.lastFS == nil { if c.lastFS == nil || now.Sub(c.fsAt) >= fsEvery {
c.lastFS = filesystems() c.lastFS, c.fsAt = filesystems(), now
} }
s.FS = c.lastFS s.FS = c.lastFS
if c.upsProbe != nil && (c.tick%upsEvery == 0 || c.lastUPS == nil) { if c.upsProbe != nil && (c.upsAt.IsZero() || now.Sub(c.upsAt) >= upsEvery) {
c.lastUPS = c.upsProbe() c.lastUPS, c.upsAt = c.upsProbe(), now
} }
s.UPS = c.lastUPS s.UPS = c.lastUPS
c.tick++
if first { if first {
return // deltas are meaningless on the priming pass return // deltas are meaningless on the priming pass
+21 -6
View File
@@ -22,16 +22,22 @@ const MODULES: Module[] = [
] ]
const byId = (id: string) => MODULES.find((m) => m.id === id) const byId = (id: string) => MODULES.find((m) => m.id === id)
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string> } const INTERVALS = [100, 250, 500, 1000, 2000] as const
const DEFAULT_INTERVAL = 2000
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number }
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules. // Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
function normalize(p: DashPrefs): Layout { function normalize(p: DashPrefs): Layout {
const known = new Set(MODULES.map((m) => m.id)) const known = new Set(MODULES.map((m) => m.id))
const order = (p.order ?? []).filter((id) => known.has(id)) const order = (p.order ?? []).filter((id) => known.has(id))
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.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 ---- // ---- formatting ----
function push(arr: number[], v: number) { arr.push(v); if (arr.length > MAX) arr.shift() } 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 [editing, setEditing] = useState(false)
const hist = useRef<Series | null>(null) const hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(undefined) const saveTimer = useRef<number | undefined>(undefined)
const intervalRef = useRef(DEFAULT_INTERVAL)
intervalRef.current = layout.intervalMs
// Persist layout changes server-side (debounced) so every device sees the same dashboard. // Persist layout changes server-side (debounced) so every device sees the same dashboard.
const update = useCallback((fn: (l: Layout) => Layout) => { const update = useCallback((fn: (l: Layout) => Layout) => {
@@ -300,9 +308,9 @@ export default function Dashboard() {
setLatest(s); setErr('') setLatest(s); setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } 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() } const onVis = () => { if (document.visibilityState === 'visible' && !hist.current) seed() }
document.addEventListener('visibilitychange', onVis) document.addEventListener('visibilitychange', onVis)
return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) } return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) }
@@ -351,7 +359,14 @@ export default function Dashboard() {
<div className={'dash' + (editing ? ' editing' : '')}> <div className={'dash' + (editing ? ' editing' : '')}>
<div className="dash-bar"> <div className="dash-bar">
{err && <span className="error">{err}</span>} {err && <span className="error">{err}</span>}
{editing && <span className="hint">Reorder with ▲▼, hide modules or single rows with ✕. Layout is saved as you go.</span>} {editing ? (
<label className="hint">Update every
<select className="btn" value={layout.intervalMs} onChange={(e) => update((l) => ({ ...l, intervalMs: Number(e.target.value) }))}>
{INTERVALS.map((ms) => <option key={ms} value={ms}>{fmtMs(ms)}</option>)}
</select>
· charts show the last {fmtWindow(layout.intervalMs)} · ▲▼ reorder, ✕ hides a module or row
</label>
) : <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
<span className="spacer" /> <span className="spacer" />
{editing && <button className="btn" onClick={reset}>Reset</button>} {editing && <button className="btn" onClick={reset}>Reset</button>}
<button className={'btn' + (editing ? ' on' : '')} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button> <button className={'btn' + (editing ? ' on' : '')} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
+2 -2
View File
@@ -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 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 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 { 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 } 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 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 // 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"). // 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 = { export const api = {
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'), dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
+2 -1
View File
@@ -109,7 +109,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
/* dashboard: customize mode */ /* dashboard: customize mode */
.dash-bar { display: flex; align-items: center; gap: 8px; min-height: 28px; } .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 { 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); } .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; } .mod-tools { display: flex; gap: 4px; margin: -4px -4px 6px auto; justify-content: flex-end; }