diff --git a/PLAN.md b/PLAN.md index 2f1ee00..96f9285 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. diff --git a/SENSORS.md b/SENSORS.md index f1a1e79..377babc 100644 --- a/SENSORS.md +++ b/SENSORS.md @@ -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 `, or as `?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..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..running|state|health|mem|cpu mem and cpu read from the container's cgroup updates.pending|security|reboot_required every 5 min zfs..health|healthy|size|used|used_pct|frag every 1 min smart..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.
tank {{ .JSON.Float "pool" }}% · {{ .JSON.Int "failed" }} failed · {{ .JSON.Int "updates" }} updates
``` -## 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 diff --git a/internal/api/server.go b/internal/api/server.go index 14451d1..2b5aa0a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 - } - 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 - 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) + 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 != "" { + h := []byte(auth.HashToken(presented)) + s.cfg.View(func(c *config.Config) { + for _, t := range c.Tokens { + 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) - return + }) } } - jsonErr(w, 401, "unauthenticated") + if !ok { + jsonErr(w, 401, "unauthenticated") + return + } + if f := r.Header.Get(filterHeader); trusted && f != "" { + patterns = strings.Split(f, ",") + } + 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,18 +336,33 @@ 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) } // issueReadToken makes a sensors:read token for a dashboard. Shown once, stored hashed. func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) { var body struct { - Name string `json:"name"` + 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= 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": }, 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": }`) + 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) { diff --git a/internal/api/status_sensors.go b/internal/api/status_sensors.go index b7e4f6f..b34b113 100644 --- a/internal/api/status_sensors.go +++ b/internal/api/status_sensors.go @@ -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,18 +23,22 @@ 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 - updatesEvery = 5 * time.Minute - zfsEvery = time.Minute - smartEvery = 10 * time.Minute // storage.Manager caches SMART that long anyway, and never wakes a sleeping disk + 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 ) 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 + } + 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 []sensors.Sensor{ + 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 { diff --git a/internal/config/config.go b/internal/config/config.go index e01cc5d..89a3135 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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" diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 54d001a..04e4f71 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -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")) { diff --git a/internal/nodes/nodes.go b/internal/nodes/nodes.go index 3f9d5d5..bb5056b 100644 --- a/internal/nodes/nodes.go +++ b/internal/nodes/nodes.go @@ -381,6 +381,40 @@ func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string, force bo // Proxy returns the handler for /api/nodes//: it rewrites to /api/, // 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 { diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index 80193f5..a7797c1 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -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(new Map()) const refetching = useRef(false) - const stock = useSensorStream<{ v: Values }>(MONITOR_SHAPE, { full: false, everyMs: refreshMs }) + const stock = useSensorStream(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)) diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index d825ae0..40f8824 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -31,7 +31,7 @@ function Preview({ vars }: { vars: Record }) { 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) => (
{t.name} - {t.scope ? 'read-only' : 'full'} + {t.scope ? (t.sensors?.length ? `read-only · ${t.sensors.join(' ')}` : 'read-only') : 'full'} issued {new Date(t.created).toLocaleString()} revoke(t.name)}>Revoke
@@ -121,7 +121,9 @@ export default function Settings({ me }: { me: Me }) { { 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 diff --git a/web/src/api.ts b/web/src/api.ts index a442686..1d545da 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -177,8 +177,8 @@ export const api = { updateMachine: (name: string, force = false) => req('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true), updateAllMachines: (force = false) => req('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true), changePassword: (current: string, password: string) => req('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('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('PUT', '/api/prefs/ui', p, true), diff --git a/web/src/sampleAdapter.ts b/web/src/sampleAdapter.ts index a2aff27..6a3a8f1 100644 --- a/web/src/sampleAdapter.ts +++ b/web/src/sampleAdapter.ts @@ -8,7 +8,16 @@ export type Values = Record export type Catalog = Map // 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.*'