Sensors: per-service and container sensors, pattern-limited tokens, fleet query, steady sampler

- service.<unit>.active|state for services that are enabled, up or failed
- containers.* and container.<name>.running|state|health|mem|cpu (cgroup reads, no docker stats)
- sensors:read tokens can be limited to ID patterns, enforced on every endpoint and,
  through the hub's filter header, on managed machines; the header can only narrow
- /api/fleet/sensors/query: one shaped query, every machine, one request
- the sampler waits what's left of the interval, so 250 ms means 4 a second
- Monitors asks for the families it draws instead of *
- unpowered hwmon sentinels (-93 °C) are not temperatures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 08:08:44 -07:00
co-authored by Claude Fable 5.1
parent 4ddf10be51
commit 4c2eb57ea6
11 changed files with 383 additions and 51 deletions
+2 -2
View File
@@ -187,8 +187,8 @@ something long in one, switch away and back, refresh — it's all still there.
on the Monitors tab (was "Dashboard"; the view id and URL are still `dashboard`), and a
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.
Also per-service and per-container sensors (memory and CPU from cgroups), tokens limited
to ID patterns, and `/api/fleet/sensors/query` for the whole fleet in one request.
- **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`,
`MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose
`config.json` lists `nodes` is a hub; any other is a standalone node with its full UI.
+18 -3
View File
@@ -99,6 +99,10 @@ hub talks to the node as itself, the caller's token stops at the hub), and nothi
- Send it as `Authorization: Bearer <token>`, or as `?token=<token>` where headers are
impossible (an iframe, a browser `EventSource`).
- A read-only token can be **limited to ID patterns** (`{"name": "glance", "sensors":
["cpu.*", "zfs.tank.*"]}`, or the second prompt in Settings). Everything outside them
does not exist for that token — catalog, query, history, stream, Prometheus, and on
managed machines through the hub, which passes the restriction along.
- A **full-control token is never accepted from a URL**, and a read-only token is never
accepted anywhere outside this file's endpoints.
@@ -129,6 +133,9 @@ Not everything is a /proc number. These are read in the background on their own
```
services.failed|failed_names|running every 30 s
service.<unit>.active|state one pair per service that is enabled, up or failed
containers.total|running|unhealthy every 15 s (Docker or Podman, if present)
container.<name>.running|state|health|mem|cpu mem and cpu read from the container's cgroup
updates.pending|security|reboot_required every 5 min
zfs.<pool>.health|healthy|size|used|used_pct|frag every 1 min
smart.<dev>.passed|warnings|power_on|wear every 10 min, never waking a sleeping disk
@@ -171,10 +178,18 @@ facts, never live. An `iframe` of the embed is live.
<div>tank {{ .JSON.Float "pool" }}% · {{ .JSON.Int "failed" }} failed · {{ .JSON.Int "updates" }} updates</div>
```
## Not yet
## The whole fleet in one request
- Per-unit service sensors, containers, token restriction to ID patterns, a fleet-wide
query in one request.
On a hub, `/api/fleet/sensors/query` asks every machine (or `machines`) the same shaped
query and answers `{machine: result}`; this machine answers under its own hostname, and
one that can't be reached answers `{"error": …}` while the rest still do.
```
POST /api/fleet/sensors/query {"machines": "*", "query": {"cpu": "cpu.usage", "up": "uptime"}}
GET /api/fleet/sensors/query?machines=grid,wyse&cpu=cpu.usage&failed=services.failed
→ {"core": {"cpu": 4.8, "up": 50211}, "grid": {"cpu": 0.4, "up": 437177}, "mac": {"error": "timeout"}}
```
## What Ham Lab itself runs on
+167 -19
View File
@@ -4,6 +4,7 @@ package api
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@@ -22,6 +23,7 @@ import (
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
@@ -123,6 +125,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/nodes/{name}/sensors/{rest...}", s.sensorAuth(s.proxyNodeSensors))
m.HandleFunc("POST /api/nodes/{name}/sensors/query", s.sensorAuth(s.proxyNodeSensors))
m.HandleFunc("POST /api/tokens/read", s.auth(s.issueReadToken))
m.HandleFunc("GET /api/fleet/sensors/query", s.sensorAuth(s.fleetQuery))
m.HandleFunc("POST /api/fleet/sensors/query", s.sensorAuth(s.fleetQuery))
m.HandleFunc("GET /api/services", s.auth(s.listServices))
m.HandleFunc("GET /api/services/{unit}", s.auth(s.showService))
m.HandleFunc("GET /api/services/{unit}/file", s.auth(s.serviceFile))
@@ -250,34 +254,81 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
// Authorization header, or a sensors:read token as ?token= — an iframe or a browser
// EventSource can't send headers, and a token that can only read fan speeds is one we
// can afford to see in a URL. Full-control tokens are never accepted from the URL.
//
// A sensors:read token may be narrowed to ID patterns; they ride the request context
// and every handler reads sensors through s.visible. A hub forwarding such a caller to
// a node states the patterns in filterHeader, which a node believes only from a caller
// holding full control (the hub) — so the header can narrow a request, never widen one.
func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ok, trusted := false, false
var patterns []string
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) {
next(w, r)
return
}
ok, trusted = true, true
} else {
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
if presented == "" {
presented, fromURL = r.URL.Query().Get("token"), true
}
if presented != "" {
var hashes []string
h := []byte(auth.HashToken(presented))
s.cfg.View(func(c *config.Config) {
for _, t := range c.Tokens {
if t.Scope == config.ScopeSensorsRead || (t.Scope == "" && !fromURL) {
hashes = append(hashes, t.Hash)
if subtle.ConstantTimeCompare(h, []byte(t.Hash)) != 1 {
continue
}
if t.Scope == config.ScopeSensorsRead {
ok, patterns = true, t.Sensors
} else if t.Scope == "" && !fromURL {
ok, trusted = true, true
}
}
})
if auth.TokenMatches(presented, hashes) {
next(w, r)
}
}
if !ok {
jsonErr(w, 401, "unauthenticated")
return
}
if f := r.Header.Get(filterHeader); trusted && f != "" {
patterns = strings.Split(f, ",")
}
jsonErr(w, 401, "unauthenticated")
next(w, r.WithContext(context.WithValue(r.Context(), filterKey{}, patterns)))
}
}
const filterHeader = "X-Hamlab-Sensor-Filter"
type filterKey struct{}
// allowed is the caller's ID filter: nil when it may read everything.
func allowed(r *http.Request) func(id string) bool {
pats, _ := r.Context().Value(filterKey{}).([]string)
if len(pats) == 0 {
return nil
}
match, err := sensors.Matcher(strings.Join(pats, ","))
if err != nil {
return func(string) bool { return false } // a broken restriction opens nothing
}
return match
}
// visible is every sensor this caller may read.
func (s *Server) visible(r *http.Request) []sensors.Sensor {
all, ok := s.sensors.All(), allowed(r)
if ok == nil {
return all
}
out := all[:0:0]
for _, x := range all {
if ok(x.ID) {
out = append(out, x)
}
}
return out
}
// proxyNodeSensors lets a sensors:read token reach a managed machine's sensors through
// this hub. The hub authenticates to the node as itself; the caller's token stops here.
func (s *Server) proxyNodeSensors(w http.ResponseWriter, r *http.Request) {
@@ -285,6 +336,10 @@ func (s *Server) proxyNodeSensors(w http.ResponseWriter, r *http.Request) {
q.Del("token")
r.URL.RawQuery = q.Encode()
}
r.Header.Del(filterHeader)
if pats, _ := r.Context().Value(filterKey{}).([]string); len(pats) > 0 {
r.Header.Set(filterHeader, strings.Join(pats, ","))
}
s.proxyNode(w, r)
}
@@ -292,11 +347,22 @@ func (s *Server) proxyNodeSensors(w http.ResponseWriter, r *http.Request) {
func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Sensors []string `json:"sensors"` // optional ID patterns the token is limited to
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
jsonErr(w, 400, "name required")
return
}
var pats []string
for _, p := range body.Sensors {
if p = strings.TrimSpace(p); p != "" && !strings.Contains(p, ",") {
pats = append(pats, p)
}
}
if _, err := sensors.Matcher(strings.Join(pats, ",")); err != nil {
jsonErr(w, 400, err.Error())
return
}
name := strings.TrimSpace(body.Name)
tok, hash, err := auth.NewToken()
if err != nil {
@@ -311,7 +377,7 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
return
}
}
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now(), Scope: config.ScopeSensorsRead})
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now(), Scope: config.ScopeSensorsRead, Sensors: pats})
}); err != nil {
jsonErr(w, 500, err.Error())
return
@@ -374,7 +440,7 @@ func (s *Server) listTokens(w http.ResponseWriter, _ *http.Request) {
var out []map[string]any
s.cfg.View(func(c *config.Config) {
for _, t := range c.Tokens {
out = append(out, map[string]any{"name": t.Name, "created": t.Created, "scope": t.Scope})
out = append(out, map[string]any{"name": t.Name, "created": t.Created, "scope": t.Scope, "sensors": t.Sensors})
}
})
if out == nil {
@@ -656,7 +722,7 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
// sensorCatalog lists every sensor with its current value; ?ids= narrows it with
// comma-separated IDs or globs.
func (s *Server) sensorCatalog(w http.ResponseWriter, r *http.Request) {
all := s.sensors.All()
all := s.visible(r)
if ids := r.URL.Query().Get("ids"); ids != "" {
picked := []sensors.Sensor{}
for _, sel := range strings.Split(ids, ",") {
@@ -675,7 +741,7 @@ func (s *Server) sensorCatalog(w http.ResponseWriter, r *http.Request) {
// sensorOne is a single sensor; ?format=text is the bare value, for shell scripts and
// anything else that can only fetch a URL and read a number.
func (s *Server) sensorOne(w http.ResponseWriter, r *http.Request) {
got, _ := sensors.Select(s.sensors.All(), r.PathValue("id"))
got, _ := sensors.Select(s.visible(r), r.PathValue("id"))
if len(got) != 1 {
jsonErr(w, 404, "no such sensor")
return
@@ -690,6 +756,9 @@ func (s *Server) sensorOne(w http.ResponseWriter, r *http.Request) {
func (s *Server) sensorHistory(w http.ResponseWriter, r *http.Request) {
h, ok := s.sensors.History(r.PathValue("id"))
if may := allowed(r); may != nil && !may(r.PathValue("id")) {
ok = false
}
if !ok {
jsonErr(w, 404, "no history for that sensor")
return
@@ -704,13 +773,17 @@ func (s *Server) sensorHistories(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, err.Error())
return
}
if may := allowed(r); may != nil {
asked := match
match = func(id string) bool { return asked(id) && may(id) }
}
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) {
func (s *Server) sensorProm(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
sensors.WriteProm(w, s.sensors.All())
sensors.WriteProm(w, s.visible(r))
}
// queryOf reads a shaped query from a request: the POST body, or on a GET either ?q= (the
@@ -739,7 +812,7 @@ func queryOf(w http.ResponseWriter, r *http.Request) (q json.RawMessage, full bo
return q, full, nil
}
var reservedParam = map[string]bool{"full": true, "token": true, "q": true, "every": true}
var reservedParam = map[string]bool{"full": true, "token": true, "q": true, "every": true, "machines": true}
// sensorStream is a shaped query that keeps answering: the same object, pushed as a
// server-sent event after every sample. ?every=<ms> spaces the events out for a consumer
@@ -747,7 +820,7 @@ var reservedParam = map[string]bool{"full": true, "token": true, "q": true, "eve
func (s *Server) sensorStream(w http.ResponseWriter, r *http.Request) {
q, full, err := queryOf(w, r)
if err == nil {
_, err = sensors.Shape(s.sensors.All(), q, full) // refuse a bad query before the stream starts
_, err = sensors.Shape(s.visible(r), q, full) // refuse a bad query before the stream starts
}
if err != nil {
jsonErr(w, 400, err.Error())
@@ -774,7 +847,7 @@ func (s *Server) sensorStream(w http.ResponseWriter, r *http.Request) {
h.Set("Cache-Control", "no-store")
h.Set("X-Accel-Buffering", "no") // nginx (NPM) would otherwise hold events back
send := func() bool {
out, _ := sensors.Shape(s.sensors.All(), q, full)
out, _ := sensors.Shape(s.visible(r), q, full)
b, err := json.Marshal(out)
if err != nil {
return false
@@ -821,7 +894,7 @@ func (s *Server) sensorQuery(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 413, err.Error())
return
}
out, err := sensors.Shape(s.sensors.All(), q, full)
out, err := sensors.Shape(s.visible(r), q, full)
if err != nil {
jsonErr(w, 400, err.Error())
return
@@ -829,6 +902,81 @@ func (s *Server) sensorQuery(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, out)
}
// fleetQuery asks several machines one shaped query in one request and answers
// {machine: result}. POST {"machines": "*" | ["grid", …], "query": <shape>}, or GET with
// the flat form plus ?machines=grid,wyse (default: all). This machine answers under its
// own hostname. One that can't be reached answers {"error": …}; the rest still do.
func (s *Server) fleetQuery(w http.ResponseWriter, r *http.Request) {
var q json.RawMessage
var want []string
full := r.URL.Query().Get("full") == "1"
if r.Method == http.MethodPost {
var body struct {
Machines json.RawMessage `json:"machines"`
Query json.RawMessage `json:"query"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil || len(body.Query) == 0 {
jsonErr(w, 400, `expected {"machines": "*" or [names], "query": <shape>}`)
return
}
q = body.Query
_ = json.Unmarshal(body.Machines, &want) // "*" or absent leaves it empty: everyone
} else {
q, full, _ = queryOf(w, r)
if m := r.URL.Query().Get("machines"); m != "" && m != "*" {
want = strings.Split(m, ",")
}
}
local, err := sensors.Shape(s.visible(r), q, full)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
names := append([]string{s.host}, s.nodes.Names()...)
if len(want) > 0 {
names = names[:0]
for _, n := range want {
names = append(names, strings.TrimSpace(n))
}
}
hdr := http.Header{"Content-Type": {"application/json"}}
if pats, _ := r.Context().Value(filterKey{}).([]string); len(pats) > 0 {
hdr.Set(filterHeader, strings.Join(pats, ","))
}
path := "/api/sensors/query"
if full {
path += "?full=1"
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
out := make(map[string]any, len(names))
var mu sync.Mutex
var wg sync.WaitGroup
for _, name := range names {
if name == s.host {
out[name] = local
continue
}
wg.Add(1)
go func(name string) {
defer wg.Done()
var res any
code, b, err := s.nodes.Do(ctx, name, "POST", path, q, hdr)
switch {
case err != nil:
res = map[string]string{"error": err.Error()}
case code != 200 || json.Unmarshal(b, &res) != nil:
res = map[string]string{"error": fmt.Sprintf("HTTP %d", code)}
}
mu.Lock()
out[name] = res
mu.Unlock()
}(name)
}
wg.Wait()
jsonOK(w, 200, out)
}
// ---- services (systemd) ----
func (s *Server) listServices(w http.ResponseWriter, r *http.Request) {
+114 -1
View File
@@ -2,10 +2,15 @@ package api
import (
"context"
"encoding/json"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"hamlab/internal/priv"
"hamlab/internal/sensors"
"hamlab/internal/storage"
)
@@ -18,10 +23,13 @@ type statusSensors struct {
s *Server
mu sync.RWMutex
by map[string][]sensors.Sensor // source → its sensors
cpuPrev map[string]cpuMark // container ID → CPU time at the previous read
}
const (
servicesEvery = 30 * time.Second
containersEvery = 15 * time.Second
updatesEvery = 5 * time.Minute
zfsEvery = time.Minute
smartEvery = 10 * time.Minute // storage.Manager caches SMART that long anyway, and never wakes a sleeping disk
@@ -30,6 +38,7 @@ const (
func newStatusSensors(s *Server) *statusSensors {
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}}
go p.loop("services", servicesEvery, p.services)
go p.loop("containers", containersEvery, p.containers)
go p.loop("updates", updatesEvery, p.updates)
go p.loop("zfs", zfsEvery, p.zfs)
go p.loop("smart", smartEvery, p.smart)
@@ -65,6 +74,7 @@ func (p *statusSensors) services(ctx context.Context, t int64) []sensors.Sensor
}
var failed, running float64
var names []string
var per []sensors.Sensor
for _, u := range units {
if u.Failed {
failed++
@@ -73,12 +83,115 @@ func (p *statusSensors) services(ctx context.Context, t int64) []sensors.Sensor
if u.Sub == "running" {
running++
}
// One pair per service that is meant to be doing something: enabled, up, or broken.
// (Every static and one-shot unit on the box would triple the catalog for nothing.)
short, isService := strings.CutSuffix(u.Name, ".service")
if !isService || !(u.Enabled == "enabled" || u.Active == "active" || u.Failed) {
continue
}
return []sensors.Sensor{
id, l := "service."+sensors.Seg(short), map[string]string{"unit": u.Name, "description": u.Description}
per = append(per,
sensors.Sensor{ID: id + ".active", Name: short, Kind: "state", Value: u.Active == "active", T: t, Labels: l},
sensors.Sensor{ID: id + ".state", Name: short + " state", Kind: "state", Value: u.Sub, T: t, Labels: l})
}
return append(per, []sensors.Sensor{
{ID: "services.failed", Name: "Failed services", Kind: "count", Value: failed, T: t, Warn: sensors.F(1)},
{ID: "services.failed_names", Name: "Failed services", Kind: "state", Value: strings.Join(names, ", "), T: t},
{ID: "services.running", Name: "Running services", Kind: "count", Value: running, T: t},
}...)
}
// containers reads Docker (or Podman) with one `ps`, then each running container's memory
// and CPU straight from its cgroup — `docker stats` takes seconds and a daemon round-trip
// per call; two small files per container take microseconds.
func (p *statusSensors) containers(ctx context.Context, t int64) []sensors.Sensor {
var out []byte
var err error
for _, tool := range []string{"docker", "podman"} {
if _, e := exec.LookPath(tool); e != nil {
continue
}
args := []string{tool, "ps", "-a", "--no-trunc", "--format", "{{json .}}"}
if out, err = exec.CommandContext(ctx, args[0], args[1:]...).Output(); err != nil && priv.Available() {
full := priv.Wrap(args...) // not in the docker group: ask as root
out, err = exec.CommandContext(ctx, full[0], full[1:]...).Output()
}
break
}
if err != nil || len(out) == 0 {
return nil
}
var res []sensors.Sensor
var running, unhealthy, total float64
seenCPU := map[string]cpuMark{}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
var c struct{ ID, Names, Image, State, Status string }
if json.Unmarshal([]byte(line), &c) != nil || c.Names == "" {
continue
}
total++
name := strings.Split(c.Names, ",")[0]
id, l := "container."+sensors.Seg(name), map[string]string{"container": name, "image": c.Image}
up := c.State == "running"
health := "none"
for _, h := range []string{"unhealthy", "healthy", "starting"} { // "unhealthy" first: it contains "healthy"
if strings.Contains(c.Status, "("+h+")") || strings.Contains(c.Status, "(health: "+h+")") {
health = h
break
}
}
if up {
running++
}
if health == "unhealthy" {
unhealthy++
}
res = append(res,
sensors.Sensor{ID: id + ".running", Name: name, Kind: "state", Value: up, T: t, Labels: l},
sensors.Sensor{ID: id + ".state", Name: name + " state", Kind: "state", Value: c.State, T: t, Labels: l},
sensors.Sensor{ID: id + ".health", Name: name + " health", Kind: "state", Value: health, T: t, Labels: l})
if !up {
continue
}
cg := ""
for _, dir := range []string{"/sys/fs/cgroup/system.slice/docker-" + c.ID + ".scope", "/sys/fs/cgroup/docker/" + c.ID, "/sys/fs/cgroup/machine.slice/libpod-" + c.ID + ".scope"} {
if _, e := os.Stat(dir + "/memory.current"); e == nil {
cg = dir
break
}
}
if cg == "" {
continue
}
if b, e := os.ReadFile(cg + "/memory.current"); e == nil {
if v, e := strconv.ParseFloat(strings.TrimSpace(string(b)), 64); e == nil {
res = append(res, sensors.Sensor{ID: id + ".mem", Name: name + " memory", Kind: "bytes", Unit: "B", Value: v, T: t, Labels: l})
}
}
if b, e := os.ReadFile(cg + "/cpu.stat"); e == nil {
for _, ln := range strings.Split(string(b), "\n") {
if f := strings.Fields(ln); len(f) == 2 && f[0] == "usage_usec" {
usec, _ := strconv.ParseFloat(f[1], 64)
seenCPU[c.ID] = cpuMark{usec, t}
if prev, ok := p.cpuPrev[c.ID]; ok && t > prev.t && usec >= prev.usec {
// percent of one core, like the Processes tab
pctv := (usec - prev.usec) / 1000 / float64(t-prev.t) * 100
res = append(res, sensors.Sensor{ID: id + ".cpu", Name: name + " CPU", Kind: "usage", Unit: "%", Value: float64(int(pctv*10+0.5)) / 10, T: t, Min: sensors.F(0), Labels: l})
}
}
}
}
}
p.cpuPrev = seenCPU // only this loop touches it
return append(res,
sensors.Sensor{ID: "containers.total", Name: "Containers", Kind: "count", Value: total, T: t},
sensors.Sensor{ID: "containers.running", Name: "Running containers", Kind: "count", Value: running, T: t},
sensors.Sensor{ID: "containers.unhealthy", Name: "Unhealthy containers", Kind: "count", Value: unhealthy, T: t, Warn: sensors.F(1)})
}
type cpuMark struct {
usec float64
t int64
}
func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
+2
View File
@@ -22,6 +22,8 @@ type Token struct {
// Scope is what the token may do. "" is everything (a hub's token: full control of
// this machine). ScopeSensorsRead only reads sensors — what a dashboard gets.
Scope string `json:"scope,omitempty"`
// Sensors narrows a sensors:read token to the IDs these patterns match. Empty is all.
Sensors []string `json:"sensors,omitempty"`
}
const ScopeSensorsRead = "sensors:read"
+10 -1
View File
@@ -178,7 +178,13 @@ func NewCollector() *Collector {
func (c *Collector) Run(ctx context.Context) {
c.sample() // prime deltas
for {
t := time.NewTimer(c.Interval())
// The wait is what's left of the interval after the work, so a machine whose
// sample takes 30 ms still delivers 4 a second at 250 ms, not 3.6.
wait := c.Interval() - time.Since(c.prevT)
if wait < 0 {
wait = 0
}
t := time.NewTimer(wait)
select {
case <-ctx.Done():
t.Stop()
@@ -420,6 +426,9 @@ func hwmonDir(d string) (chip string, temps []Temp, fans []Fan) {
label = base
}
if m[1] == "temp" {
if raw < -60000 { // an unpowered sensor's sentinel (an idle audio amp reads -93 °C), not weather
continue
}
temps = append(temps, Temp{Chip: chip, Label: label, C: float64(raw) / 1000,
Max: tempLimit(filepath.Join(d, base+"_max")), Crit: tempLimit(filepath.Join(d, base+"_crit"))})
} else if raw > 0 || fileExists(filepath.Join(d, base+"_enable")) {
+34
View File
@@ -381,6 +381,40 @@ func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string, force bo
// Proxy returns the handler for /api/nodes/<name>/<rest>: it rewrites to <node>/api/<rest>,
// swaps the hub's cookie for the node's bearer token, and drops Origin so the node's
// WebSocket origin check (meant for browsers) doesn't reject the hub.
// Do makes one request to a managed machine as this hub and returns the whole response.
func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte, hdr http.Header) (int, []byte, error) {
n, ok := m.node(name)
if !ok {
return 0, nil, errors.New("no such machine")
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(n.URL, "/")+path, bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
for k, v := range hdr {
req.Header[k] = v
}
req.Header.Set("Authorization", "Bearer "+n.Token)
resp, err := m.client(n).Do(req)
if err != nil {
return 0, nil, errors.New(shortErr(err))
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
return resp.StatusCode, b, err
}
// Names lists the managed machines.
func (m *Manager) Names() []string {
var out []string
m.cfg.View(func(c *config.Config) {
for _, n := range c.Nodes {
out = append(out, n.Name)
}
})
return out
}
func (m *Manager) Proxy(name string) (http.Handler, bool) {
n, ok := m.node(name)
if !ok {
+4 -4
View File
@@ -4,7 +4,7 @@ import Sparkline from './Sparkline'
import { Loading, Spinner, toast } from './ui'
import { fmtMs, useRefreshMs } from './prefs'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, sampleOf, seriesOf, stale } from './sampleAdapter'
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
import WidgetPicker from './WidgetPicker'
@@ -302,7 +302,7 @@ export default function Dashboard() {
// 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 stock = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
const seed = useCallback(async () => {
try {
catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x]))
@@ -312,8 +312,8 @@ export default function Dashboard() {
}, [])
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 (!stock.data || catalog.current.size === 0) return
const v: Values = mergeValues(stock.data)
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))
+5 -3
View File
@@ -31,7 +31,7 @@ function Preview({ vars }: { vars: Record<string, string> }) {
export default function Settings({ me }: { me: Me }) {
const [theme, setTheme] = useState(currentThemeId())
const [refresh, setRefresh] = useState(getRefreshMs())
const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string }[] | null>(null)
const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null)
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
const [cur, setCur] = useState('')
const [pw1, setPw1] = useState('')
@@ -104,7 +104,7 @@ export default function Settings({ me }: { me: Me }) {
{tokens && tokens.map((t) => (
<div className="row" key={t.name}>
<span className="k mono">{t.name}</span>
<span className="badge" style={{ marginLeft: 0 }}>{t.scope ? 'read-only' : 'full'}</span>
<span className="badge" style={{ marginLeft: 0 }} title={t.sensors?.length ? t.sensors.join(', ') : undefined}>{t.scope ? (t.sensors?.length ? `read-only · ${t.sensors.join(' ')}` : 'read-only') : 'full'}</span>
<span className="muted small">issued {new Date(t.created).toLocaleString()}</span>
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
</div>
@@ -121,7 +121,9 @@ export default function Settings({ me }: { me: Me }) {
<Btn className="btn" onClick={async () => {
const name = window.prompt('Name for the read-only token (what will use it)', 'glance')?.trim()
if (!name) return
try { setFresh(await api.createReadToken(name)); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
const pats = window.prompt('Limit it to sensor patterns? Comma-separated, e.g. cpu.*, temp.*, zfs.tank.* — leave empty for every sensor', '')
if (pats === null) return
try { setFresh(await api.createReadToken(name, pats.split(',').map((x) => x.trim()).filter(Boolean))); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
}}>+ Read-only sensor token</Btn>
</div>
</section>
+2 -2
View File
@@ -177,8 +177,8 @@ export const api = {
updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
tokens: () => req<{ name: string; created: string; scope?: string }[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name }, true),
tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true),
saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req<void>('PUT', '/api/prefs/ui', p, true),
+10 -1
View File
@@ -8,7 +8,16 @@ 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: '*' }
export const MONITOR_SHAPE = ['cpu.*', 'load.*', 'uptime', 'hamlab.*', 'mem.*', 'swap.*', 'temp.*', 'fan.*', 'net.*', 'disk.*', 'fs.*', 'ups.*']
// The stream answers a list with a list; the modules want one flat map.
export function mergeValues(parts: unknown[]): Values {
const out: Values = {}
parts.forEach((part, i) => {
if (part && typeof part === 'object') Object.assign(out, part)
else if (part !== null && part !== undefined) out[MONITOR_SHAPE[i]] = part as Values[string]
})
return out
}
// 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.*'