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
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
+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/{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.
+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("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")
+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
}
// 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 {
+32 -23
View File
@@ -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
}
+18 -12
View File
@@ -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")
}
}
+39 -7
View File
@@ -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.
+45 -39
View File
@@ -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<Series | null>(null)
const saveTimer = useRef<number | undefined>(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<Catalog>(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
+17 -10
View File
@@ -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<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 [zfs, setZfs] = useState<ZFSInfo | null>(null)
const [hasSmart, setHasSmart] = useState(true)
const [live, setLive] = useState<Sample | null>(null)
const [err, setErr] = useState('')
const [task, setTask] = useState<string | null>(null)
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') }
}, [])
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<string, Sensor> }>({ d: 'disk.*' }, { everyMs: rate })
const live = useMemo(() => {
const by: Record<string, { name: string; read_bps: number; write_bps: number; util_pct: number }> = {}
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 <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Scanning disks…" />}</div>
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 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 (
<div className={'embed' + (p.get('bare') === '1' ? ' bare' : '')}>
{!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}`),
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
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),
logout: () => req<void>('POST', '/api/logout', 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
}
// 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<T = unknown>(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<T = unknown>(shape: Shape | null, opts: StreamOpts = {}): { data: T | null; error: string; resumed: number } {
const [data, setData] = useState<T | null>(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<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)
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 ----