From 4ddf10be51682a7e844fc5e6d79d6e0884cdb445 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sat, 19 Sep 2026 07:52:14 -0700 Subject: [PATCH] The full switch: Monitors and Disks read only the sensor API; /api/metrics retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PLAN.md | 9 ++-- SENSORS.md | 12 ++++- internal/api/server.go | 33 ++++++------- internal/metrics/metrics.go | 50 +------------------ internal/metrics/sensors.go | 55 ++++++++++++--------- internal/metrics/sensors_test.go | 30 +++++++----- internal/sensors/sensors.go | 46 ++++++++++++++--- web/src/Dashboard.tsx | 84 +++++++++++++++++--------------- web/src/Disks.tsx | 27 ++++++---- web/src/Embed.tsx | 2 +- web/src/api.ts | 2 - web/src/sampleAdapter.ts | 71 +++++++++++++++++++++++++++ web/src/sensors.ts | 35 ++++++++++--- 13 files changed, 283 insertions(+), 173 deletions(-) create mode 100644 web/src/sampleAdapter.ts diff --git a/PLAN.md b/PLAN.md index a9f18d4..2f1ee00 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 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 - every 2 s into an in-memory ring of 10 min; the UI seeds from - `GET /api/metrics/history` then polls `GET /api/metrics`. **Customizable**: a + every 2 s into an in-memory ring of 10 min; the UI (the **Monitors** tab + 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, 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 @@ -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 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 - frameable `/embed/widget` for Glance and friends. **Remaining:** port the stock Monitors - modules onto sensors; per-unit service and container sensors; pattern-restricted tokens; + frameable `/embed/widget` for Glance and friends. The stock Monitors modules and the Disks + 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. - **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`, `MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose diff --git a/SENSORS.md b/SENSORS.md index 74a62d5..f1a1e79 100644 --- a/SENSORS.md +++ b/SENSORS.md @@ -50,6 +50,7 @@ On a hub, every one is also at `/api/nodes//sensors…` for each manage | `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}/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. | | `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 -- 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 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. diff --git a/internal/api/server.go b/internal/api/server.go index 18c5922..14451d1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal)) 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/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/history", s.sensorAuth(s.sensorHistories)) m.HandleFunc("GET /api/sensors/prometheus", s.sensorAuth(s.sensorProm)) m.HandleFunc("GET /metrics", s.sensorAuth(s.sensorProm)) 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) ---- // 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) } +// 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. func (s *Server) sensorProm(w http.ResponseWriter, _ *http.Request) { 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 { 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.Set("Content-Type", "text/event-stream") h.Set("Cache-Control", "no-store") diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index f84b12a..54d001a 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -136,16 +136,6 @@ type Sample struct { 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 { mu sync.RWMutex ring []Sample @@ -236,44 +226,6 @@ func (c *Collector) Latest() (Sample, bool) { 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 ---- func (c *Collector) sample() { @@ -853,7 +805,7 @@ func upsNUT() *UPS { u.InV = leadingNum(kv["input.voltage"]) u.OutV = leadingNum(kv["output.voltage"]) 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.LoadW = leadingNum(kv["ups.realpower"]) if u.LoadW == 0 { diff --git a/internal/metrics/sensors.go b/internal/metrics/sensors.go index 803322e..ffa7525 100644 --- a/internal/metrics/sensors.go +++ b/internal/metrics/sensors.go @@ -11,10 +11,10 @@ import ( // IDs are built from names the hardware reports (chip, label, interface, device, mount), // 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 -// walking the whole ring for a history costs string compares, not allocations. +// flattener turns one Sample into sensors. With want set it builds only those, so walking +// the whole ring for a history costs string compares, not allocations. type flattener struct { - only string + want func(id string) bool seen map[string]int 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 id = fmt.Sprintf("%s_%d", id, n) } - if f.only != "" && id != f.only { + if f.want != nil && !f.want(id) { 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} @@ -50,8 +50,8 @@ func ratio(part, whole uint64) float64 { return round1(float64(part) / float64(whole) * 100) } -func flatten(s Sample, only string) []sensors.Sensor { - f := &flattener{only: only, seen: map[string]int{}} +func flatten(s Sample, want func(id string) bool) []sensors.Sensor { + f := &flattener{want: want, seen: map[string]int{}} t, none := s.T, bounds{} 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("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 { 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.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.last_event", "UPS last event", "state", "", u.LastEvent, ut, none, "model", u.Model, "driver", u.Driver) } return f.out } @@ -142,28 +144,35 @@ func (c *Collector) Sensors() []sensors.Sensor { if !ok { return nil } - return flatten(s, "") + return flatten(s, nil) } -// SensorHistory implements sensors.Historian: the ring, seen through one sensor. Samples -// where the sensor didn't exist (a disk plugged in since) are left out, not zero-filled. -func (c *Collector) SensorHistory(id string) (sensors.Series, bool) { +// SensorHistories implements sensors.Historian: the ring, seen through the sensors that +// match. A sensor that didn't exist in a sample (a disk plugged in since) simply has a +// 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() defer c.mu.RUnlock() - out := sensors.Series{ID: id, T: []int64{}, V: []float64{}} - found := false + by := map[string]*sensors.Series{} + var order []string for _, smp := range c.ring { - got := flatten(smp, id) - if len(got) == 0 { - continue + for _, x := range flatten(smp, match) { + v, ok := x.Value.(float64) + if !ok { + continue + } + s := by[x.ID] + if s == nil { + s = &sensors.Series{ID: x.ID, Unit: x.Unit, T: []int64{}, V: []float64{}} + by[x.ID] = s + order = append(order, x.ID) + } + s.T, s.V = append(s.T, smp.T), append(s.V, v) } - found = true - v, ok := got[0].Value.(float64) - if !ok { - return sensors.Series{}, false // states have no curve - } - out.Unit = got[0].Unit - 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 } diff --git a/internal/metrics/sensors_test.go b/internal/metrics/sensors_test.go index 48fc68c..986b44b 100644 --- a/internal/metrics/sensors_test.go +++ b/internal/metrics/sensors_test.go @@ -8,7 +8,7 @@ func TestFlatten(t *testing.T) { Fans: []Fan{{Chip: "nct6775", Label: "fan2", RPM: 1100}}, FS: []FS{{Mount: "/", Size: 100, Used: 25}}, fsT: 900} got := map[string]any{} - for _, x := range flatten(s, "") { + for _, x := range flatten(s, nil) { if _, dup := got[x.ID]; dup { 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) } } - 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 { 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 { 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.ring = []Sample{{T: 1, CPU: 10}, {T: 2, CPU: 20, Net: []Net{{Iface: "eth0", Up: true, RxBps: 5}}}} - h, ok := c.SensorHistory("cpu.usage") - if !ok || len(h.V) != 2 || h.V[1] != 20 || h.Unit != "%" { - t.Fatalf("cpu: %+v %v", h, ok) + got := map[string][]float64{} + for _, h := range c.SensorHistories(func(id string) bool { return id == "cpu.usage" || id == "net.eth0.rx" || id == "net.eth0.up" }) { + if len(h.T) != len(h.V) { + t.Errorf("%s: t and v differ in length", h.ID) + } + got[h.ID] = h.V } - if h, ok := c.SensorHistory("net.eth0.rx"); !ok || len(h.V) != 1 || h.T[0] != 2 { - t.Fatalf("net: a sensor that appeared later has a shorter history, got %+v %v", h, ok) + if len(got["cpu.usage"]) != 2 || got["cpu.usage"][1] != 20 { + t.Errorf("cpu: %v", got["cpu.usage"]) } - if _, ok := c.SensorHistory("net.eth0.up"); ok { + 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") } - if _, ok := c.SensorHistory("nope"); ok { - t.Error("unknown id") + if len(c.SensorHistories(func(string) bool { return false })) != 0 { + t.Error("nothing matched, nothing returned") } } diff --git a/internal/sensors/sensors.go b/internal/sensors/sensors.go index 84f881c..97b5db3 100644 --- a/internal/sensors/sensors.go +++ b/internal/sensors/sensors.go @@ -46,9 +46,10 @@ type Provider interface { 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 { - 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. @@ -125,16 +126,47 @@ func (r *Registry) All() []Sensor { 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) { + 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 { if h, ok := p.(Historian); ok { - if s, ok := h.SensorHistory(id); ok { - return s, true - } + out = append(out, h.SensorHistories(match)...) } } - return Series{}, false + sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// 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. diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index 6486a34..80193f5 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -3,7 +3,8 @@ import { api, DashPrefs, Sample, Series, Temp } from './api' import Sparkline from './Sparkline' import { Loading, Spinner, toast } from './ui' 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 WidgetPicker from './WidgetPicker' @@ -272,8 +273,6 @@ export default function Dashboard() { const hist = useRef(null) const saveTimer = useRef(undefined) const refreshMs = useRefreshMs() - const intervalRef = useRef(DEFAULT_INTERVAL) - intervalRef.current = refreshMs // Persist layout changes server-side (debounced) so every device sees the same dashboard. const update = useCallback((fn: (l: Layout) => Layout) => { @@ -295,45 +294,52 @@ export default function Dashboard() { useEffect(() => { let stop = false - let timer: number | undefined api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout')) - const seed = async () => { - try { hist.current = await api.metricsHistory(); setLatest(await api.metrics()); setErr('') } - catch (e) { setErr(e instanceof Error ? e.message : 'failed') } - } - const tick = async () => { - if (stop) return - if (document.visibilityState === 'visible') { - try { - const s = await api.metrics() - const h = hist.current - if (h) { - push(h.t, s.t); push(h.cpu, s.cpu); push(h.mem, s.mem.used) - const n = h.t.length - for (const x of s.net) { - const v = (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) - } - for (const d of s.disks) { - const v = (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) - } - for (const t of s.temps) { - const v = (h.temp[`${t.chip}/${t.label}`] ??= []) - pad(v, n - 1); push(v, t.c) - } - } - setLatest(s); setErr('') - } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } - } - 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) } + return () => { stop = true } }, []) + // Everything on this page comes from the sensor API: the catalog for names, labels and + // thresholds; one batch of histories to seed the curves; one stream for the values. + const catalog = useRef(new Map()) + const refetching = useRef(false) + const stock = useSensorStream<{ v: Values }>(MONITOR_SHAPE, { full: false, everyMs: refreshMs }) + const seed = useCallback(async () => { + try { + 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 + if (h) { + push(h.t, s.t); push(h.cpu, s.cpu); push(h.mem, s.mem.used) + const n = h.t.length + 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) + } + for (const d of s.disks) { + const c = (h.disk[d.name] ??= { r: [], w: [], u: [] }) + 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) { + const c = (h.temp[`${t.chip}/${t.label}`] ??= []) + pad(c, n - 1); push(c, t.c) + } + } + setLatest(s) + }, [stock.data]) + 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). useEffect(() => { if (!loaded) return diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx index 1c88b56..a3dbe67 100644 --- a/web/src/Disks.tsx +++ b/web/src/Disks.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from 'react' -import { api, Sample, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' import { fmtBytes, fmtRate } from './Dashboard' import TaskOutput from './TaskOutput' import { Btn, Loading, Spinner, toast } from './ui' import { useRefreshMs } from './prefs' +import { Sensor, useSensorStream } from './sensors' const pct = (v: number) => `${Math.round(v)}%` const KIND: Record = { 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(null) const [zfs, setZfs] = useState(null) const [hasSmart, setHasSmart] = useState(true) - const [live, setLive] = useState(null) const [err, setErr] = useState('') const [task, setTask] = useState(null) const [allSmart, setAllSmart] = useState(() => localStorage.getItem('hamlab.disks.smart') === '1') @@ -196,13 +196,20 @@ export default function Disks() { } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) useEffect(() => { load() }, [load]) - useEffect(() => { - let stop = false - const tick = async () => { if (!stop && document.visibilityState === 'visible') { try { setLive(await api.metrics()) } catch { /* ignore */ } } } - tick() - const t = setInterval(tick, rate) - return () => { stop = true; clearInterval(t) } - }, [rate]) + // Live per-device rates come from the sensor stream, grouped by the kernel name in each sensor's labels. + const stream = useSensorStream<{ d: Record }>({ d: 'disk.*' }, { everyMs: rate }) + const live = useMemo(() => { + const by: Record = {} + for (const x of Object.values(stream.data?.d ?? {})) { + const name = x.labels?.device + 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
{err ?
{err}
: }
const liveFor = (dev: StorageDevice) => { diff --git a/web/src/Embed.tsx b/web/src/Embed.tsx index c3206c1..66ac294 100644 --- a/web/src/Embed.tsx +++ b/web/src/Embed.tsx @@ -18,7 +18,7 @@ export default function Embed() { 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 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 (
{!sensor ?
embed needs ?sensor=
: ( diff --git a/web/src/api.ts b/web/src/api.ts index d9d05af..a442686 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -168,8 +168,6 @@ export const api = { serviceAction: (unit: string, action: string) => req('POST', `/api/services/${encodeURIComponent(unit)}/${action}`), dashPrefs: () => req('GET', '/api/prefs/dashboard'), saveDashPrefs: (p: DashPrefs) => req('PUT', '/api/prefs/dashboard', p), - metrics: () => req('GET', '/api/metrics'), - metricsHistory: () => req('GET', '/api/metrics/history'), login: (password: string) => req('POST', '/api/login', { password }, true), logout: () => req('POST', '/api/logout', undefined, true), meHere: () => req('GET', '/api/me', undefined, true), diff --git a/web/src/sampleAdapter.ts b/web/src/sampleAdapter.ts new file mode 100644 index 0000000..a2aff27 --- /dev/null +++ b/web/src/sampleAdapter.ts @@ -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 +export type Catalog = Map + +// 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..rx by the real name in the catalog's labels, keeping catalog order. +function groups(v: Values, cat: Catalog, prefix: string, label: string): Map { + const out = new Map() // 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 +} diff --git a/web/src/sensors.ts b/web/src/sensors.ts index 242d444..01981b9 100644 --- a/web/src/sensors.ts +++ b/web/src/sensors.ts @@ -32,24 +32,45 @@ export async function fetchHistory(id: string, src?: Source): Promise<{ t: numbe return r.ok ? r.json() : null } -// useSensorStream keeps a shaped query answered: the object comes back filled with whole -// sensors and is replaced on every sample. One stream serves any number of widgets. -export function useSensorStream(shape: Shape | null, src?: Source, everyMs?: number): { data: T | null; error: string } { +export async function fetchHistories(ids: string, src?: Source): Promise<{ id: string; unit?: string; t: number[]; v: number[] }[]> { + const r = await fetch(url(src, '/api/sensors/history', { ids }), { credentials: 'same-origin' }) + 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(shape: Shape | null, opts: StreamOpts = {}): { data: T | null; error: string; resumed: number } { const [data, setData] = useState(null) const [error, setError] = useState('') + const [resumed, setResumed] = useState(0) + const [visible, setVisible] = useState(() => document.visibilityState === 'visible') 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 + 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(() => { if (!key) { setData(null); return } - const params: Record = { q: key, full: '1' } + if (!visible) return + const params: Record = { q: key } + if (full) params.full = '1' if (everyMs) params.every = String(everyMs) 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.onerror = () => setError('reconnecting…') // EventSource retries by itself return () => es.close() - }, [key, srcKey]) // eslint-disable-line react-hooks/exhaustive-deps - return { data, error } + }, [key, srcKey, visible]) // eslint-disable-line react-hooks/exhaustive-deps + return { data, error, resumed } } // ---- formatting, by unit ----