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:
co-authored by
Claude Fable 5.1
parent
f01175ceca
commit
4ddf10be51
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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> : (
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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 ----
|
||||||
|
|||||||
Reference in New Issue
Block a user