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
+21
View File
@@ -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) {
+65 -31
View File
@@ -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