The full switch: Monitors and Disks read only the sensor API; /api/metrics retired

- stock Monitors modules fed by one sensor stream, the catalog and one batch of
  histories (GET /api/sensors/history?ids=…), through sampleAdapter.ts
- the stream closes while the page is hidden and refills its curves on return
- Disks tab live rates from the stream
- new sensors: hamlab.sample_interval, ups.last_event
- stream: asking for about the sampling rate means every sample
- NUT runtime rounded at the source

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 07:52:14 -07:00
co-authored by Claude Fable 5.1
parent f01175ceca
commit 4ddf10be51
13 changed files with 283 additions and 173 deletions
+5 -4
View File
@@ -166,8 +166,8 @@ something long in one, switch away and back, refresh — it's all still there.
CPU (total + per core), memory/swap, load, uptime, hwmon temps and fans read straight CPU (total + per core), memory/swap, load, uptime, hwmon temps and fans read straight
from `/sys` (no lm-sensors), per-interface network rates, per-disk read/write/busy from `/sys` (no lm-sensors), per-interface network rates, per-disk read/write/busy
from `/proc/diskstats`, filesystem usage, UPS via `sudo -n pwrstat -status`. Sampled from `/proc/diskstats`, filesystem usage, UPS via `sudo -n pwrstat -status`. Sampled
every 2 s into an in-memory ring of 10 min; the UI seeds from every 2 s into an in-memory ring of 10 min; the UI (the **Monitors** tab
`GET /api/metrics/history` then polls `GET /api/metrics`. **Customizable**: a since 2026-09-19) reads it all through the sensor API — see Sensors below. **Customizable**: a
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
@@ -185,8 +185,9 @@ something long in one, switch away and back, refresh — it's all still there.
SSE stream, Prometheus `/metrics`, read-only `sensors:read` tokens (never full tokens in SSE stream, Prometheus `/metrics`, read-only `sensors:read` tokens (never full tokens in
a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets
on the Monitors tab (was "Dashboard"; the view id and URL are still `dashboard`), and a on the Monitors tab (was "Dashboard"; the view id and URL are still `dashboard`), and a
frameable `/embed/widget` for Glance and friends. **Remaining:** port the stock Monitors frameable `/embed/widget` for Glance and friends. The stock Monitors modules and the Disks
modules onto sensors; per-unit service and container sensors; pattern-restricted tokens; tab read the sensor API too (`sampleAdapter.ts`); `/api/metrics` is retired.
**Remaining:** per-unit service and container sensors; pattern-restricted tokens;
one-request fleet query. one-request fleet query.
- **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`, - **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`,
`MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose `MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose
+10 -2
View File
@@ -50,6 +50,7 @@ On a hub, every one is also at `/api/nodes/<machine>/sensors…` for each manage
| `GET /api/sensors` | Catalog, with current values. `?ids=fan.*,cpu.usage` narrows it. | | `GET /api/sensors` | Catalog, with current values. `?ids=fan.*,cpu.usage` narrows it. |
| `GET /api/sensors/{id}` | One sensor. `?format=text` → the bare value as text/plain. | | `GET /api/sensors/{id}` | One sensor. `?format=text` → the bare value as text/plain. |
| `GET /api/sensors/{id}/history` | `{id, unit, t[], v[]}` — what the ring holds (300 samples at the sampling interval). Numbers only. | | `GET /api/sensors/{id}/history` | `{id, unit, t[], v[]}` — what the ring holds (300 samples at the sampling interval). Numbers only. |
| `GET /api/sensors/history?ids=cpu.usage,net.*.rx` | Many histories in one request: `{series: [{id, unit, t[], v[]}]}`. A sensor younger than the ring has a shorter series. |
| `POST /api/sensors/query` | Shaped query, below. | | `POST /api/sensors/query` | Shaped query, below. |
| `GET /api/sensors/query?k=selector&…` | The same, flat, for consumers that can only fetch a URL. | | `GET /api/sensors/query?k=selector&…` | The same, flat, for consumers that can only fetch a URL. |
@@ -172,7 +173,14 @@ facts, never live. An `iframe` of the embed is live.
## Not yet ## Not yet
- The stock Monitors modules (CPU, Temperatures, Network…) still read `/api/metrics`;
only custom widgets run on sensors. Porting them is the remaining dogfooding.
- Per-unit service sensors, containers, token restriction to ID patterns, a fleet-wide - Per-unit service sensors, containers, token restriction to ID patterns, a fleet-wide
query in one request. query in one request.
## What Ham Lab itself runs on
Since 2026-09-19 the Monitors tab (stock modules and custom widgets) and the Disks tab's
live rates read only this API: the catalog once, one batch of histories, one stream —
closed while the page is hidden, refilled when it returns. `web/src/sampleAdapter.ts` is
the seam that hands the stock modules the object they were written against. The old
`/api/metrics` and `/api/metrics/history` are gone. `hamlab.sample_interval` is the
sampler's own rate, as a sensor.
+16 -17
View File
@@ -110,12 +110,11 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal)) m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal))
m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal)) m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal))
m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach)) m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest))
m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory))
m.HandleFunc("GET /api/sensors", s.sensorAuth(s.sensorCatalog)) m.HandleFunc("GET /api/sensors", s.sensorAuth(s.sensorCatalog))
m.HandleFunc("GET /api/sensors/query", s.sensorAuth(s.sensorQuery)) m.HandleFunc("GET /api/sensors/query", s.sensorAuth(s.sensorQuery))
m.HandleFunc("POST /api/sensors/query", s.sensorAuth(s.sensorQuery)) m.HandleFunc("POST /api/sensors/query", s.sensorAuth(s.sensorQuery))
m.HandleFunc("GET /api/sensors/stream", s.sensorAuth(s.sensorStream)) m.HandleFunc("GET /api/sensors/stream", s.sensorAuth(s.sensorStream))
m.HandleFunc("GET /api/sensors/history", s.sensorAuth(s.sensorHistories))
m.HandleFunc("GET /api/sensors/prometheus", s.sensorAuth(s.sensorProm)) m.HandleFunc("GET /api/sensors/prometheus", s.sensorAuth(s.sensorProm))
m.HandleFunc("GET /metrics", s.sensorAuth(s.sensorProm)) m.HandleFunc("GET /metrics", s.sensorAuth(s.sensorProm))
m.HandleFunc("GET /api/sensors/{id}", s.sensorAuth(s.sensorOne)) m.HandleFunc("GET /api/sensors/{id}", s.sensorAuth(s.sensorOne))
@@ -652,21 +651,6 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
}) })
} }
// ---- metrics (Dashboard) ----
func (s *Server) metricsLatest(w http.ResponseWriter, _ *http.Request) {
smp, ok := s.metrics.Latest()
if !ok {
jsonErr(w, 503, "no sample yet")
return
}
jsonOK(w, 200, smp)
}
func (s *Server) metricsHistory(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, s.metrics.History())
}
// ---- sensors: the standard read interface (SENSORS.md) ---- // ---- sensors: the standard read interface (SENSORS.md) ----
// sensorCatalog lists every sensor with its current value; ?ids= narrows it with // sensorCatalog lists every sensor with its current value; ?ids= narrows it with
@@ -713,6 +697,16 @@ func (s *Server) sensorHistory(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, h) jsonOK(w, 200, h)
} }
// sensorHistories is many histories in one request: ?ids= takes IDs and globs.
func (s *Server) sensorHistories(w http.ResponseWriter, r *http.Request) {
match, err := sensors.Matcher(r.URL.Query().Get("ids"))
if err != nil {
jsonErr(w, 400, err.Error())
return
}
jsonOK(w, 200, map[string]any{"series": s.sensors.Histories(match)})
}
// sensorProm is every numeric sensor in the Prometheus text format. // sensorProm is every numeric sensor in the Prometheus text format.
func (s *Server) sensorProm(w http.ResponseWriter, _ *http.Request) { func (s *Server) sensorProm(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
@@ -770,6 +764,11 @@ func (s *Server) sensorStream(w http.ResponseWriter, r *http.Request) {
if ms, err := strconv.Atoi(r.URL.Query().Get("every")); err == nil && ms > 0 { if ms, err := strconv.Atoi(r.URL.Query().Get("every")); err == nil && ms > 0 {
every = time.Duration(ms) * time.Millisecond every = time.Duration(ms) * time.Millisecond
} }
// Asking for roughly the sampling rate means "every sample": don't let timer jitter
// drop alternate ticks.
if iv := s.metrics.Interval(); every <= iv+iv/2 {
every = 0
}
h := w.Header() h := w.Header()
h.Set("Content-Type", "text/event-stream") h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-store") h.Set("Cache-Control", "no-store")
+1 -49
View File
@@ -136,16 +136,6 @@ type Sample struct {
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
} }
// Series is the compact history the UI seeds its sparklines from.
type Series struct {
T []int64 `json:"t"`
CPU []float64 `json:"cpu"`
Mem []uint64 `json:"mem"`
Net map[string]map[string][]float64 `json:"net"` // iface -> {rx,tx}
Disk map[string]map[string][]float64 `json:"disk"` // name -> {r,w,u}
Temp map[string][]float64 `json:"temp"` // "chip/label" -> °C
}
type Collector struct { type Collector struct {
mu sync.RWMutex mu sync.RWMutex
ring []Sample ring []Sample
@@ -236,44 +226,6 @@ func (c *Collector) Latest() (Sample, bool) {
return c.ring[len(c.ring)-1], true return c.ring[len(c.ring)-1], true
} }
func (c *Collector) History() Series {
c.mu.RLock()
defer c.mu.RUnlock()
n := len(c.ring)
s := Series{T: make([]int64, n), CPU: make([]float64, n), Mem: make([]uint64, n),
Net: map[string]map[string][]float64{}, Disk: map[string]map[string][]float64{}, Temp: map[string][]float64{}}
series := func(m map[string]map[string][]float64, key string, keys ...string) map[string][]float64 {
v, ok := m[key]
if !ok {
v = map[string][]float64{}
for _, k := range keys {
v[k] = make([]float64, n)
}
m[key] = v
}
return v
}
for i, smp := range c.ring {
s.T[i], s.CPU[i], s.Mem[i] = smp.T, smp.CPU, smp.Mem.Used
for _, x := range smp.Net {
v := series(s.Net, x.Iface, "rx", "tx")
v["rx"][i], v["tx"][i] = x.RxBps, x.TxBps
}
for _, d := range smp.Disks {
v := series(s.Disk, d.Name, "r", "w", "u")
v["r"][i], v["w"][i], v["u"][i] = d.ReadBps, d.WriteBps, d.UtilPct
}
for _, t := range smp.Temps {
k := t.Chip + "/" + t.Label
if s.Temp[k] == nil {
s.Temp[k] = make([]float64, n)
}
s.Temp[k][i] = t.C
}
}
return s
}
// ---- sampling ---- // ---- sampling ----
func (c *Collector) sample() { func (c *Collector) sample() {
@@ -853,7 +805,7 @@ func upsNUT() *UPS {
u.InV = leadingNum(kv["input.voltage"]) u.InV = leadingNum(kv["input.voltage"])
u.OutV = leadingNum(kv["output.voltage"]) u.OutV = leadingNum(kv["output.voltage"])
u.BatteryPct = leadingNum(kv["battery.charge"]) u.BatteryPct = leadingNum(kv["battery.charge"])
u.RuntimeMin = leadingNum(kv["battery.runtime"]) / 60 u.RuntimeMin = round1(leadingNum(kv["battery.runtime"]) / 60)
u.LoadPct = leadingNum(kv["ups.load"]) u.LoadPct = leadingNum(kv["ups.load"])
u.LoadW = leadingNum(kv["ups.realpower"]) u.LoadW = leadingNum(kv["ups.realpower"])
if u.LoadW == 0 { if u.LoadW == 0 {
+30 -21
View File
@@ -11,10 +11,10 @@ import (
// IDs are built from names the hardware reports (chip, label, interface, device, mount), // IDs are built from names the hardware reports (chip, label, interface, device, mount),
// never from enumeration order like hwmon3, which moves between boots. // never from enumeration order like hwmon3, which moves between boots.
// flattener turns one Sample into sensors. With only set it builds just that one, so // flattener turns one Sample into sensors. With want set it builds only those, so walking
// walking the whole ring for a history costs string compares, not allocations. // the whole ring for a history costs string compares, not allocations.
type flattener struct { type flattener struct {
only string want func(id string) bool
seen map[string]int seen map[string]int
out []sensors.Sensor out []sensors.Sensor
} }
@@ -30,7 +30,7 @@ func (f *flattener) add(id, name, kind, unit string, v any, t int64, b bounds, l
if n := f.seen[id]; n > 1 { // two identical chips: the second is coretemp_2 if n := f.seen[id]; n > 1 { // two identical chips: the second is coretemp_2
id = fmt.Sprintf("%s_%d", id, n) id = fmt.Sprintf("%s_%d", id, n)
} }
if f.only != "" && id != f.only { if f.want != nil && !f.want(id) {
return return
} }
s := sensors.Sensor{ID: id, Name: name, Kind: kind, Unit: unit, Value: v, T: t, Min: b.min, Max: b.max, Warn: b.warn, Crit: b.crit} s := sensors.Sensor{ID: id, Name: name, Kind: kind, Unit: unit, Value: v, T: t, Min: b.min, Max: b.max, Warn: b.warn, Crit: b.crit}
@@ -50,8 +50,8 @@ func ratio(part, whole uint64) float64 {
return round1(float64(part) / float64(whole) * 100) return round1(float64(part) / float64(whole) * 100)
} }
func flatten(s Sample, only string) []sensors.Sensor { func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
f := &flattener{only: only, seen: map[string]int{}} f := &flattener{want: want, seen: map[string]int{}}
t, none := s.T, bounds{} t, none := s.T, bounds{}
f.add("cpu.usage", "CPU", "usage", "%", s.CPU, t, pct) f.add("cpu.usage", "CPU", "usage", "%", s.CPU, t, pct)
@@ -66,6 +66,7 @@ func flatten(s Sample, only string) []sensors.Sensor {
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none) f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
} }
f.add("uptime", "Uptime", "duration", "s", s.Uptime, t, none) f.add("uptime", "Uptime", "duration", "s", s.Uptime, t, none)
f.add("hamlab.sample_interval", "Sampling interval", "duration", "ms", float64(s.Interval), t, none)
for _, m := range []struct { for _, m := range []struct {
id, name string id, name string
@@ -132,6 +133,7 @@ func flatten(s Sample, only string) []sensors.Sensor {
f.add("ups.on_battery", "UPS on battery", "state", "", u.OnBattery, ut, none, "model", u.Model) f.add("ups.on_battery", "UPS on battery", "state", "", u.OnBattery, ut, none, "model", u.Model)
f.add("ups.state", "UPS state", "state", "", u.State, ut, none, "model", u.Model) f.add("ups.state", "UPS state", "state", "", u.State, ut, none, "model", u.Model)
f.add("ups.source", "UPS power source", "state", "", u.Source, ut, none, "model", u.Model) f.add("ups.source", "UPS power source", "state", "", u.Source, ut, none, "model", u.Model)
f.add("ups.last_event", "UPS last event", "state", "", u.LastEvent, ut, none, "model", u.Model, "driver", u.Driver)
} }
return f.out return f.out
} }
@@ -142,28 +144,35 @@ func (c *Collector) Sensors() []sensors.Sensor {
if !ok { if !ok {
return nil return nil
} }
return flatten(s, "") return flatten(s, nil)
} }
// SensorHistory implements sensors.Historian: the ring, seen through one sensor. Samples // SensorHistories implements sensors.Historian: the ring, seen through the sensors that
// where the sensor didn't exist (a disk plugged in since) are left out, not zero-filled. // match. A sensor that didn't exist in a sample (a disk plugged in since) simply has a
func (c *Collector) SensorHistory(id string) (sensors.Series, bool) { // shorter series; nothing is zero-filled. States have no curve and are left out.
func (c *Collector) SensorHistories(match func(id string) bool) []sensors.Series {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
out := sensors.Series{ID: id, T: []int64{}, V: []float64{}} by := map[string]*sensors.Series{}
found := false var order []string
for _, smp := range c.ring { for _, smp := range c.ring {
got := flatten(smp, id) for _, x := range flatten(smp, match) {
if len(got) == 0 { v, ok := x.Value.(float64)
if !ok {
continue continue
} }
found = true s := by[x.ID]
v, ok := got[0].Value.(float64) if s == nil {
if !ok { s = &sensors.Series{ID: x.ID, Unit: x.Unit, T: []int64{}, V: []float64{}}
return sensors.Series{}, false // states have no curve by[x.ID] = s
order = append(order, x.ID)
} }
out.Unit = got[0].Unit s.T, s.V = append(s.T, smp.T), append(s.V, v)
out.T, out.V = append(out.T, smp.T), append(out.V, v)
} }
return out, found }
out := make([]sensors.Series, 0, len(order))
for _, id := range order {
out = append(out, *by[id])
}
return out
} }
+18 -12
View File
@@ -8,7 +8,7 @@ func TestFlatten(t *testing.T) {
Fans: []Fan{{Chip: "nct6775", Label: "fan2", RPM: 1100}}, Fans: []Fan{{Chip: "nct6775", Label: "fan2", RPM: 1100}},
FS: []FS{{Mount: "/", Size: 100, Used: 25}}, fsT: 900} FS: []FS{{Mount: "/", Size: 100, Used: 25}}, fsT: 900}
got := map[string]any{} got := map[string]any{}
for _, x := range flatten(s, "") { for _, x := range flatten(s, nil) {
if _, dup := got[x.ID]; dup { if _, dup := got[x.ID]; dup {
t.Errorf("duplicate id %s", x.ID) t.Errorf("duplicate id %s", x.ID)
} }
@@ -20,31 +20,37 @@ func TestFlatten(t *testing.T) {
t.Errorf("%s = %v, want %v", id, got[id], want) t.Errorf("%s = %v, want %v", id, got[id], want)
} }
} }
one := flatten(s, "temp.nvme.composite_2") one := flatten(s, func(id string) bool { return id == "temp.nvme.composite_2" })
if len(one) != 1 || one[0].Value != 45.0 { if len(one) != 1 || one[0].Value != 45.0 {
t.Errorf("only: got %+v", one) t.Errorf("only: got %+v", one)
} }
for _, x := range flatten(s, "fs.root.used") { for _, x := range flatten(s, func(id string) bool { return id == "fs.root.used" }) {
if x.T != 900 { if x.T != 900 {
t.Errorf("fs sensor should carry the fs read time, got %d", x.T) t.Errorf("fs sensor should carry the fs read time, got %d", x.T)
} }
} }
} }
func TestSensorHistory(t *testing.T) { func TestSensorHistories(t *testing.T) {
c := NewCollector() c := NewCollector()
c.ring = []Sample{{T: 1, CPU: 10}, {T: 2, CPU: 20, Net: []Net{{Iface: "eth0", Up: true, RxBps: 5}}}} c.ring = []Sample{{T: 1, CPU: 10}, {T: 2, CPU: 20, Net: []Net{{Iface: "eth0", Up: true, RxBps: 5}}}}
h, ok := c.SensorHistory("cpu.usage") got := map[string][]float64{}
if !ok || len(h.V) != 2 || h.V[1] != 20 || h.Unit != "%" { for _, h := range c.SensorHistories(func(id string) bool { return id == "cpu.usage" || id == "net.eth0.rx" || id == "net.eth0.up" }) {
t.Fatalf("cpu: %+v %v", h, ok) if len(h.T) != len(h.V) {
t.Errorf("%s: t and v differ in length", h.ID)
} }
if h, ok := c.SensorHistory("net.eth0.rx"); !ok || len(h.V) != 1 || h.T[0] != 2 { got[h.ID] = h.V
t.Fatalf("net: a sensor that appeared later has a shorter history, got %+v %v", h, ok)
} }
if _, ok := c.SensorHistory("net.eth0.up"); ok { if len(got["cpu.usage"]) != 2 || got["cpu.usage"][1] != 20 {
t.Errorf("cpu: %v", got["cpu.usage"])
}
if len(got["net.eth0.rx"]) != 1 {
t.Errorf("a sensor that appeared later has a shorter history, got %v", got["net.eth0.rx"])
}
if _, ok := got["net.eth0.up"]; ok {
t.Error("a state has no history") t.Error("a state has no history")
} }
if _, ok := c.SensorHistory("nope"); ok { if len(c.SensorHistories(func(string) bool { return false })) != 0 {
t.Error("unknown id") t.Error("nothing matched, nothing returned")
} }
} }
+38 -6
View File
@@ -46,9 +46,10 @@ type Provider interface {
Sensors() []Sensor Sensors() []Sensor
} }
// Historian is a Provider that also remembers. ok is false for an ID it doesn't hold. // Historian is a Provider that also remembers: the recent past of every numeric sensor
// of its own that match accepts.
type Historian interface { type Historian interface {
SensorHistory(id string) (s Series, ok bool) SensorHistories(match func(id string) bool) []Series
} }
// Ticker is a Provider that can say when its values have changed. // Ticker is a Provider that can say when its values have changed.
@@ -125,16 +126,47 @@ func (r *Registry) All() []Sensor {
return out return out
} }
// History finds the provider that remembers id. // History is one sensor's recent past. ok is false when nothing remembers it.
func (r *Registry) History(id string) (Series, bool) { func (r *Registry) History(id string) (Series, bool) {
got := r.Histories(func(x string) bool { return x == id })
if len(got) == 0 {
return Series{}, false
}
return got[0], true
}
// Histories is the recent past of every numeric sensor match accepts, sorted by ID.
func (r *Registry) Histories(match func(id string) bool) []Series {
out := []Series{}
for _, p := range r.providers { for _, p := range r.providers {
if h, ok := p.(Historian); ok { if h, ok := p.(Historian); ok {
if s, ok := h.SensorHistory(id); ok { out = append(out, h.SensorHistories(match)...)
return s, true
} }
} }
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
} }
return Series{}, false
// Matcher turns comma-separated selectors (IDs and globs) into a match function.
func Matcher(selectors string) (func(id string) bool, error) {
var sels []string
for _, sel := range strings.Split(selectors, ",") {
if sel = strings.TrimSpace(sel); sel == "" {
continue
}
if _, err := path.Match(sel, ""); err != nil {
return nil, fmt.Errorf("bad pattern %q", sel)
}
sels = append(sels, sel)
}
return func(id string) bool {
for _, sel := range sels {
if ok, _ := path.Match(sel, id); ok {
return true
}
}
return false
}, nil
} }
// IsGlob reports whether a selector names many sensors rather than one. // IsGlob reports whether a selector names many sensors rather than one.
+34 -28
View File
@@ -3,7 +3,8 @@ import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline' import Sparkline from './Sparkline'
import { Loading, Spinner, toast } from './ui' import { Loading, Spinner, toast } from './ui'
import { fmtMs, useRefreshMs } from './prefs' import { fmtMs, useRefreshMs } from './prefs'
import { useSensorStream } from './sensors' import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, sampleOf, seriesOf, stale } from './sampleAdapter'
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets' import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
import WidgetPicker from './WidgetPicker' import WidgetPicker from './WidgetPicker'
@@ -272,8 +273,6 @@ export default function Dashboard() {
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 refreshMs = useRefreshMs() const refreshMs = useRefreshMs()
const intervalRef = useRef(DEFAULT_INTERVAL)
intervalRef.current = refreshMs
// 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) => {
@@ -295,44 +294,51 @@ export default function Dashboard() {
useEffect(() => { useEffect(() => {
let stop = false let stop = false
let timer: number | undefined
api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout')) api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout'))
const seed = async () => { return () => { stop = true }
try { hist.current = await api.metricsHistory(); setLatest(await api.metrics()); setErr('') } }, [])
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
} // Everything on this page comes from the sensor API: the catalog for names, labels and
const tick = async () => { // thresholds; one batch of histories to seed the curves; one stream for the values.
if (stop) return const catalog = useRef<Catalog>(new Map())
if (document.visibilityState === 'visible') { const refetching = useRef(false)
const stock = useSensorStream<{ v: Values }>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
const seed = useCallback(async () => {
try { try {
const s = await api.metrics() catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x]))
hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current)
setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { seed() }, [seed, stock.resumed]) // on load, and again after the page was hidden: the curves have a gap to refill
useEffect(() => {
const v = stock.data?.v
if (!v || catalog.current.size === 0) return
if (stale(v, catalog.current) && !refetching.current) { // something appeared (a disk, an interface): learn its labels
refetching.current = true
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
}
const s = sampleOf(v, catalog.current)
const h = hist.current const h = hist.current
if (h) { if (h) {
push(h.t, s.t); push(h.cpu, s.cpu); push(h.mem, s.mem.used) push(h.t, s.t); push(h.cpu, s.cpu); push(h.mem, s.mem.used)
const n = h.t.length const n = h.t.length
for (const x of s.net) { for (const x of s.net) {
const v = (h.net[x.iface] ??= { rx: [], tx: [] }) const c = (h.net[x.iface] ??= { rx: [], tx: [] })
pad(v.rx, n - 1); pad(v.tx, n - 1); push(v.rx, x.rx_bps); push(v.tx, x.tx_bps) pad(c.rx, n - 1); pad(c.tx, n - 1); push(c.rx, x.rx_bps); push(c.tx, x.tx_bps)
} }
for (const d of s.disks) { for (const d of s.disks) {
const v = (h.disk[d.name] ??= { r: [], w: [], u: [] }) const c = (h.disk[d.name] ??= { r: [], w: [], u: [] })
pad(v.r, n - 1); pad(v.w, n - 1); pad(v.u, n - 1); push(v.r, d.read_bps); push(v.w, d.write_bps); push(v.u, d.util_pct) pad(c.r, n - 1); pad(c.w, n - 1); pad(c.u, n - 1); push(c.r, d.read_bps); push(c.w, d.write_bps); push(c.u, d.util_pct)
} }
for (const t of s.temps) { for (const t of s.temps) {
const v = (h.temp[`${t.chip}/${t.label}`] ??= []) const c = (h.temp[`${t.chip}/${t.label}`] ??= [])
pad(v, n - 1); push(v, t.c) pad(c, n - 1); push(c, t.c)
} }
} }
setLatest(s); setErr('') setLatest(s)
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [stock.data])
} useEffect(() => { if (stock.error && !latest) setErr(stock.error) }, [stock.error, latest])
timer = window.setTimeout(tick, intervalRef.current)
}
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) }
}, [])
// The sampler on this machine follows the global rate (clamped to what it supports). // The sampler on this machine follows the global rate (clamped to what it supports).
useEffect(() => { useEffect(() => {
+17 -10
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, Sample, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api'
import { fmtBytes, fmtRate } from './Dashboard' import { fmtBytes, fmtRate } from './Dashboard'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import { Btn, Loading, Spinner, toast } from './ui' import { Btn, Loading, Spinner, toast } from './ui'
import { useRefreshMs } from './prefs' import { useRefreshMs } from './prefs'
import { Sensor, useSensorStream } from './sensors'
const pct = (v: number) => `${Math.round(v)}%` const pct = (v: number) => `${Math.round(v)}%`
const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', emmc: 'eMMC', sd: 'SD', other: 'disk' } const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', emmc: 'eMMC', sd: 'SD', other: 'disk' }
@@ -183,7 +184,6 @@ export default function Disks() {
const [devs, setDevs] = useState<StorageDevice[] | null>(null) const [devs, setDevs] = useState<StorageDevice[] | null>(null)
const [zfs, setZfs] = useState<ZFSInfo | null>(null) const [zfs, setZfs] = useState<ZFSInfo | null>(null)
const [hasSmart, setHasSmart] = useState(true) const [hasSmart, setHasSmart] = useState(true)
const [live, setLive] = useState<Sample | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [task, setTask] = useState<string | null>(null) const [task, setTask] = useState<string | null>(null)
const [allSmart, setAllSmart] = useState<boolean>(() => localStorage.getItem('hamlab.disks.smart') === '1') const [allSmart, setAllSmart] = useState<boolean>(() => localStorage.getItem('hamlab.disks.smart') === '1')
@@ -196,13 +196,20 @@ export default function Disks() {
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, []) }, [])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => { // Live per-device rates come from the sensor stream, grouped by the kernel name in each sensor's labels.
let stop = false const stream = useSensorStream<{ d: Record<string, Sensor> }>({ d: 'disk.*' }, { everyMs: rate })
const tick = async () => { if (!stop && document.visibilityState === 'visible') { try { setLive(await api.metrics()) } catch { /* ignore */ } } } const live = useMemo(() => {
tick() const by: Record<string, { name: string; read_bps: number; write_bps: number; util_pct: number }> = {}
const t = setInterval(tick, rate) for (const x of Object.values(stream.data?.d ?? {})) {
return () => { stop = true; clearInterval(t) } const name = x.labels?.device
}, [rate]) if (!name || typeof x.value !== 'number') continue
const row = (by[name] ??= { name, read_bps: 0, write_bps: 0, util_pct: 0 })
if (x.id.endsWith('.read')) row.read_bps = x.value
else if (x.id.endsWith('.write')) row.write_bps = x.value
else if (x.id.endsWith('.util')) row.util_pct = x.value
}
return { disks: Object.values(by) }
}, [stream.data])
if (!devs) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Scanning disks…" />}</div> if (!devs) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Scanning disks…" />}</div>
const liveFor = (dev: StorageDevice) => { const liveFor = (dev: StorageDevice) => {
+1 -1
View File
@@ -18,7 +18,7 @@ export default function Embed() {
const spec: WidgetSpec = { id: 'w', type, sensor, title: p.get('title') ?? undefined } const spec: WidgetSpec = { id: 'w', type, sensor, title: p.get('title') ?? undefined }
const src: Source = { machine: p.get('machine') || null, token: p.get('token') ?? undefined } const src: Source = { machine: p.get('machine') || null, token: p.get('token') ?? undefined }
const every = Number(p.get('every')) || undefined const every = Number(p.get('every')) || undefined
const live = useSensorStream<{ w: WidgetData }>(sensor ? { w: sensor } : null, src, every) const live = useSensorStream<{ w: WidgetData }>(sensor ? { w: sensor } : null, { src, everyMs: every })
return ( return (
<div className={'embed' + (p.get('bare') === '1' ? ' bare' : '')}> <div className={'embed' + (p.get('bare') === '1' ? ' bare' : '')}>
{!sensor ? <div className="error">embed needs ?sensor=</div> : ( {!sensor ? <div className="error">embed needs ?sensor=</div> : (
-2
View File
@@ -168,8 +168,6 @@ export const api = {
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`), serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'), dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p), saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
metrics: () => req<Sample>('GET', '/api/metrics'),
metricsHistory: () => req<Series>('GET', '/api/metrics/history'),
login: (password: string) => req<void>('POST', '/api/login', { password }, true), login: (password: string) => req<void>('POST', '/api/login', { password }, true),
logout: () => req<void>('POST', '/api/logout', undefined, true), logout: () => req<void>('POST', '/api/logout', undefined, true),
meHere: () => req<Me>('GET', '/api/me', undefined, true), meHere: () => req<Me>('GET', '/api/me', undefined, true),
+71
View File
@@ -0,0 +1,71 @@
// The stock Monitors modules were written against one Sample object. They now read the
// sensor API like everything else; this file is the seam — it rebuilds that object from
// sensor values (the live stream) and sensor metadata (the catalog: labels, thresholds).
import { Sample, Series } from './api'
import { Sensor } from './sensors'
export type Values = Record<string, number | boolean | string>
export type Catalog = Map<string, Sensor>
// Everything the modules draw, as one stream query.
export const MONITOR_SHAPE = { v: '*' }
// The curves they keep: CPU, memory, per-NIC and per-disk rates, every temperature.
export const MONITOR_HISTORY = 'cpu.usage,mem.used,net.*.rx,net.*.tx,disk.*.read,disk.*.write,disk.*.util,temp.*'
const num = (v: Values, id: string) => (typeof v[id] === 'number' ? (v[id] as number) : 0)
const str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '')
// Groups ids like net.<x>.rx by the real name in the catalog's labels, keeping catalog order.
function groups(v: Values, cat: Catalog, prefix: string, label: string): Map<string, string> {
const out = new Map<string, string>() // real name → id stem ("net.enp3s0")
for (const id of Object.keys(v)) {
if (!id.startsWith(prefix)) continue
const real = cat.get(id)?.labels?.[label]
if (real && !out.has(real)) out.set(real, id.slice(0, id.lastIndexOf('.')))
}
return new Map([...out].sort((a, b) => a[0].localeCompare(b[0]))) // by real name: "/" leads the filesystems
}
// Every id in the values must be known to the catalog, or the catalog is stale (a disk
// or interface appeared) and wants refetching.
export const stale = (v: Values, cat: Catalog) => Object.keys(v).some((id) => !cat.has(id))
export function sampleOf(v: Values, cat: Catalog): Sample {
const cores = Object.keys(v).map((id) => /^cpu\.core\.(\d+)\.usage$/.exec(id)).filter(Boolean).map((m) => [Number(m![1]), v[m![0]] as number] as const).sort((a, b) => a[0] - b[0]).map((x) => x[1])
const mem = (p: string) => ({ total: num(v, p + '.total'), used: num(v, p + '.used'), avail: num(v, p + '.avail'), free: num(v, p + '.free'), cached: num(v, p + '.cached'), buffers: num(v, p + '.buffers') })
const hw = (prefix: string) => Object.keys(v).filter((id) => id.startsWith(prefix) && cat.get(id)?.labels?.chip !== undefined).map((id) => ({ id, s: cat.get(id)! }))
.sort((a, b) => a.s.labels!.chip.localeCompare(b.s.labels!.chip) || a.s.labels!.label.localeCompare(b.s.labels!.label))
return {
t: Date.now(), interval_ms: num(v, 'hamlab.sample_interval'),
cpu: num(v, 'cpu.usage'), cores, load: [num(v, 'load.1'), num(v, 'load.5'), num(v, 'load.15')],
mem: mem('mem'), swap: mem('swap'), uptime: num(v, 'uptime'),
temps: hw('temp.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, c: num(v, id), max: s.warn, crit: s.crit })),
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used') })),
ups: v['ups.battery'] === undefined ? null : {
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
load_w: num(v, 'ups.load'), load_pct: num(v, 'ups.load_pct'), last_event: str(v, 'ups.last_event') || undefined,
},
}
}
// The modules' history: one shared time axis (the CPU's), every other curve right-aligned
// to it and zero-padded on the left where the sensor is younger than the ring.
export function seriesOf(hist: { id: string; t: number[]; v: number[] }[], cat: Catalog): Series {
const by = new Map(hist.map((h) => [h.id, h]))
const t = by.get('cpu.usage')?.t ?? []
const n = t.length
const fit = (id: string) => { const v = (by.get(id)?.v ?? []).slice(-n); return [...new Array(Math.max(0, n - v.length)).fill(0), ...v] as number[] }
const out: Series = { t: [...t], cpu: fit('cpu.usage'), mem: fit('mem.used'), net: {}, disk: {}, temp: {} }
for (const h of hist) {
const l = cat.get(h.id)?.labels
const stem = h.id.slice(0, h.id.lastIndexOf('.'))
if (h.id.startsWith('net.') && l?.iface) out.net[l.iface] ??= { rx: fit(stem + '.rx'), tx: fit(stem + '.tx') }
else if (h.id.startsWith('disk.') && l?.device) out.disk[l.device] ??= { r: fit(stem + '.read'), w: fit(stem + '.write'), u: fit(stem + '.util') }
else if (h.id.startsWith('temp.') && l?.chip !== undefined) out.temp[`${l.chip}/${l.label}`] = fit(h.id)
}
return out
}
+28 -7
View File
@@ -32,24 +32,45 @@ export async function fetchHistory(id: string, src?: Source): Promise<{ t: numbe
return r.ok ? r.json() : null return r.ok ? r.json() : null
} }
// useSensorStream keeps a shaped query answered: the object comes back filled with whole export async function fetchHistories(ids: string, src?: Source): Promise<{ id: string; unit?: string; t: number[]; v: number[] }[]> {
// sensors and is replaced on every sample. One stream serves any number of widgets. const r = await fetch(url(src, '/api/sensors/history', { ids }), { credentials: 'same-origin' })
export function useSensorStream<T = unknown>(shape: Shape | null, src?: Source, everyMs?: number): { data: T | null; error: string } { if (!r.ok) throw new Error(r.statusText)
return (await r.json()).series
}
export interface StreamOpts { src?: Source; everyMs?: number; full?: boolean }
// useSensorStream keeps a shaped query answered: the object comes back filled (with whole
// sensors unless full is false) and is replaced on every sample. One stream serves any
// number of widgets. It closes while the page is hidden — a phone in a pocket shouldn't
// hold a stream open — and `resumed` counts the times it came back, so a caller that
// keeps history knows there is a gap to refill.
export function useSensorStream<T = unknown>(shape: Shape | null, opts: StreamOpts = {}): { data: T | null; error: string; resumed: number } {
const [data, setData] = useState<T | null>(null) const [data, setData] = useState<T | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [resumed, setResumed] = useState(0)
const [visible, setVisible] = useState(() => document.visibilityState === 'visible')
const key = shape === null ? '' : JSON.stringify(shape) const key = shape === null ? '' : JSON.stringify(shape)
const srcKey = `${src?.machine ?? '·'}|${src?.token ?? ''}|${everyMs ?? ''}` const { src, everyMs, full = true } = opts
const srcKey = `${src?.machine ?? '·'}|${src?.token ?? ''}|${everyMs ?? ''}|${full}`
const srcRef = useRef(src); srcRef.current = src const srcRef = useRef(src); srcRef.current = src
useEffect(() => {
const onVis = () => { const v = document.visibilityState === 'visible'; setVisible(v); if (v) setResumed((n) => n + 1) }
document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
useEffect(() => { useEffect(() => {
if (!key) { setData(null); return } if (!key) { setData(null); return }
const params: Record<string, string> = { q: key, full: '1' } if (!visible) return
const params: Record<string, string> = { q: key }
if (full) params.full = '1'
if (everyMs) params.every = String(everyMs) if (everyMs) params.every = String(everyMs)
const es = new EventSource(url(srcRef.current, '/api/sensors/stream', params)) const es = new EventSource(url(srcRef.current, '/api/sensors/stream', params))
es.onmessage = (e) => { try { setData(JSON.parse(e.data)); setError('') } catch { /* skip a bad frame */ } } es.onmessage = (e) => { try { setData(JSON.parse(e.data)); setError('') } catch { /* skip a bad frame */ } }
es.onerror = () => setError('reconnecting…') // EventSource retries by itself es.onerror = () => setError('reconnecting…') // EventSource retries by itself
return () => es.close() return () => es.close()
}, [key, srcKey]) // eslint-disable-line react-hooks/exhaustive-deps }, [key, srcKey, visible]) // eslint-disable-line react-hooks/exhaustive-deps
return { data, error } return { data, error, resumed }
} }
// ---- formatting, by unit ---- // ---- formatting, by unit ----