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:
co-authored by
Claude Fable 5.1
parent
4ddf10be51
commit
4c2eb57ea6
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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.*'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user