From b05a0539205f0fda50e1396fcb75cdc30c2271bd Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sat, 19 Sep 2026 07:37:10 -0700 Subject: [PATCH] Sensors: read-only tokens, live stream, Prometheus, status sensors, widgets, embeds - sensors:read tokens open the sensor endpoints (here and on managed machines through the hub) and nothing else; full tokens are never accepted from a URL - /api/sensors/stream: a shaped query that keeps answering, over SSE - /metrics: Prometheus text format - services, updates, ZFS and SMART as background status sensors - widget library over the sensor API; custom widgets on the dashboard - /embed/widget: one live widget in its own bundle, the only frameable path - Dashboard tab is now Monitors Co-Authored-By: Claude Fable 5.1 --- PLAN.md | 24 ++-- SENSORS.md | 102 +++++++++++++- internal/api/server.go | 231 +++++++++++++++++++++++++++---- internal/api/status_sensors.go | 137 ++++++++++++++++++ internal/config/config.go | 5 + internal/metrics/metrics.go | 25 ++++ internal/sensors/prom.go | 76 ++++++++++ internal/sensors/sensors.go | 16 +++ internal/sensors/sensors_test.go | 24 ++++ web/src/Dashboard.tsx | 32 ++++- web/src/Embed.tsx | 31 +++++ web/src/Settings.tsx | 21 ++- web/src/Shell.tsx | 4 +- web/src/TerminalPane.tsx | 1 + web/src/WidgetPicker.tsx | 55 ++++++++ web/src/api.ts | 5 +- web/src/main.tsx | 12 +- web/src/sensors.ts | 97 +++++++++++++ web/src/styles.css | 21 +++ web/src/widgets.tsx | 87 ++++++++++++ 20 files changed, 950 insertions(+), 56 deletions(-) create mode 100644 internal/api/status_sensors.go create mode 100644 internal/sensors/prom.go create mode 100644 web/src/Embed.tsx create mode 100644 web/src/WidgetPicker.tsx create mode 100644 web/src/sensors.ts create mode 100644 web/src/widgets.tsx diff --git a/PLAN.md b/PLAN.md index ecce4af..a9f18d4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -176,18 +176,18 @@ something long in one, switch away and back, refresh — it's all still there. the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms and 10 min at 2 s. Still to do: persistent history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges. -- **Sensors** — **step 1 shipped 2026-09-19** (`internal/sensors`, - `internal/metrics/sensors.go`, reference in `SENSORS.md`): everything measured is a - sensor with a stable dotted ID; catalog, single value (`?format=text`), per-sensor - history, and a **shaped query** where the client sends the object it wants and gets it - back filled in (GraphQL's idea without GraphQL: sensors differ per machine, so a static - schema would collapse to `sensor(id:)` anyway). `/api/metrics` and the Dashboard are - untouched so far. **Remaining, in order:** (2) read-only scoped tokens + Prometheus - `/metrics`; (3) push stream + widget library, Dashboard rebuilt on its own sensors; - (4) embeddable widget page + Glance config (Glance's `custom-api` only refetches on page - load, an `iframe` of our embed is what's live); (5) non-metric sensors — services, - SMART, ZFS, updates, containers. One sampler rate, set in one place, is part of (3): - today the open dashboard still drives it. +- **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`, + `internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`; + reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID; + catalog, single value, history, a **shaped query** (the client sends the object it wants + and gets it back filled in — GraphQL's idea without GraphQL, since sensors differ per + machine and a static schema would collapse to `sensor(id:)`), the same query as a live + SSE stream, Prometheus `/metrics`, read-only `sensors:read` tokens (never full tokens in + a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets + on the Monitors tab (was "Dashboard"; the view id and URL are still `dashboard`), and a + frameable `/embed/widget` for Glance and friends. **Remaining:** port the stock Monitors + modules onto sensors; per-unit service and container sensors; pattern-restricted tokens; + one-request fleet query. - **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`, `MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose `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 d7782c9..74a62d5 100644 --- a/SENSORS.md +++ b/SENSORS.md @@ -76,9 +76,103 @@ POST /api/sensors/query - `?full=1` → every value is the whole sensor object instead of the bare value. (In the GET form `full` is therefore not usable as a key.) +## Live stream + +`GET /api/sensors/stream` is a shaped query that keeps answering: the same object, pushed +as a server-sent event after every sample. The query is `?q=` or +the flat form; `?full=1` as above; `?every=` spaces events out for a consumer that +doesn't want them at the sampling rate. A keepalive comment goes out every 25 s, and +`X-Accel-Buffering: no` keeps nginx from holding events back. + +```js +new EventSource('/api/sensors/stream?token=…&q=' + encodeURIComponent(JSON.stringify({ cpu: 'cpu.usage', fans: 'fan.*' }))) + .onmessage = (e) => render(JSON.parse(e.data)) +``` + +## Auth: read-only tokens + +Settings → Access tokens → **+ Read-only sensor token** (or `POST /api/tokens/read +{"name"}` with a session). Scope `sensors:read`: it opens every endpoint in this file, +here and on each managed machine through the hub (`/api/nodes//sensors…`; the +hub talks to the node as itself, the caller's token stops at the hub), and nothing else. + +- Send it as `Authorization: Bearer `, or as `?token=` where headers are + impossible (an iframe, a browser `EventSource`). +- A **full-control token is never accepted from a URL**, and a read-only token is never + accepted anywhere outside this file's endpoints. + +## Prometheus + +`GET /metrics` (or `/api/sensors/prometheus`, which also works through the hub as +`/api/nodes//sensors/prometheus`). One gauge family per kind of measurement, +base units, the sensor ID and its real-world names as labels: + +``` +hamlab_temperature_celsius{id="temp.coretemp.core_0",chip="coretemp",label="Core 0"} 50 +hamlab_usage_percent{id="fs.root.used_pct",fstype="ext4",mount="/"} 10.1 +hamlab_state{id="ups.state",model="CPS CP1500PFCRM2U",value="Normal"} 1 +hamlab_updates_pending{id="updates.pending",manager="apt"} 3 +``` + +```yaml +scrape_configs: + - job_name: hamlab + authorization: { credentials: } + static_configs: [{ targets: ['10.0.0.234:8100'] }] +``` + +## Status sensors + +Not everything is a /proc number. These are read in the background on their own clocks +(each sensor's `t` is its real age) by `internal/api/status_sensors.go`: + +``` +services.failed|failed_names|running every 30 s +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 +``` + +## Widgets + +A widget is a spec — `{type, sensor, title?}` — over this API and nothing else +(`web/src/widgets.tsx`, fed by `useSensorStream` in `web/src/sensors.ts`). Types: `stat`, +`gauge`, `bar`, `spark`, and `list` for a pattern. Scale and colour come from the +sensor's own `min`/`max`/`warn`/`crit`. + +- **In Ham Lab:** Monitors → Customize → **+ Add widget**. Saved with the layout, + per machine; all of a page's widgets share one stream. +- **Anywhere else:** `/embed/widget` is one widget alone on a page, live, in its own small + bundle (the console never loads), and the only path Ham Lab allows to be framed. + +``` +/embed/widget?type=gauge&sensor=cpu.usage&token= + &machine=grid &title=… &theme=dark|light|cyberpunk|retro &every= &bare=1 +``` + +### Glance + +Glance's own `custom-api` widget refetches on page load / cache expiry — fine for slow +facts, never live. An `iframe` of the embed is live. + +```yaml +- type: iframe + title: core CPU + source: https://hamlab.bigham.dev/embed/widget?type=gauge&sensor=cpu.usage&bare=1&token= + height: 130 + +- type: custom-api + title: grid + cache: 1m + url: https://hamlab.bigham.dev/api/nodes/grid/sensors/query?pool=zfs.tank.used_pct&failed=services.failed&updates=updates.pending + headers: { Authorization: Bearer } + template: | +
tank {{ .JSON.Float "pool" }}% · {{ .JSON.Int "failed" }} failed · {{ .JSON.Int "updates" }} updates
+``` + ## Not yet -Read-only scoped tokens, Prometheus `/metrics`, a push stream, widgets built on this, -an embeddable widget page, and non-metric sensors (services, SMART, ZFS, updates). Until -scoped tokens exist, **do not hand a Ham Lab token to a dashboard** — today's tokens are -full control of the machine. +- The stock Monitors modules (CPU, Temperatures, Network…) still read `/api/metrics`; + only custom widgets run on sensors. Porting them is the remaining dogfooding. +- Per-unit service sensors, containers, token restriction to ID patterns, a fleet-wide + query in one request. diff --git a/internal/api/server.go b/internal/api/server.go index 7e0e80d..18c5922 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -84,6 +84,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me if s.user == "" { s.user = "?" } + s.sensors.Add(newStatusSensors(s)) // The saved dashboard layout carries the sampling interval; apply it on boot. var raw json.RawMessage st.View(func(x *store.State) { raw = x.Dashboard }) @@ -111,11 +112,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach)) m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest)) m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory)) - m.HandleFunc("GET /api/sensors", s.auth(s.sensorCatalog)) - m.HandleFunc("GET /api/sensors/query", s.auth(s.sensorQuery)) - m.HandleFunc("POST /api/sensors/query", s.auth(s.sensorQuery)) - m.HandleFunc("GET /api/sensors/{id}", s.auth(s.sensorOne)) - m.HandleFunc("GET /api/sensors/{id}/history", s.auth(s.sensorHistory)) + m.HandleFunc("GET /api/sensors", s.sensorAuth(s.sensorCatalog)) + m.HandleFunc("GET /api/sensors/query", s.sensorAuth(s.sensorQuery)) + m.HandleFunc("POST /api/sensors/query", s.sensorAuth(s.sensorQuery)) + m.HandleFunc("GET /api/sensors/stream", s.sensorAuth(s.sensorStream)) + m.HandleFunc("GET /api/sensors/prometheus", s.sensorAuth(s.sensorProm)) + m.HandleFunc("GET /metrics", s.sensorAuth(s.sensorProm)) + m.HandleFunc("GET /api/sensors/{id}", s.sensorAuth(s.sensorOne)) + m.HandleFunc("GET /api/sensors/{id}/history", s.sensorAuth(s.sensorHistory)) + m.HandleFunc("GET /api/nodes/{name}/sensors", s.sensorAuth(s.proxyNodeSensors)) + 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/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)) @@ -179,8 +187,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me func (s *Server) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() - h.Set("X-Frame-Options", "DENY") - h.Set("Content-Security-Policy", "frame-ancestors 'none'") + // The console must never be framed. An embedded widget exists to be: it is a page + // that can only read sensors, and only with a read-only token in its URL. + if !strings.HasPrefix(r.URL.Path, "/embed/") { + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", "frame-ancestors 'none'") + } h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") s.mux.ServeHTTP(w, r) @@ -221,7 +233,9 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { var hashes []string s.cfg.View(func(c *config.Config) { for _, t := range c.Tokens { - hashes = append(hashes, t.Hash) + if t.Scope == "" { // a scoped token is not a key to the machine + hashes = append(hashes, t.Hash) + } } }) if auth.TokenMatches(strings.TrimPrefix(h, "Bearer "), hashes) { @@ -233,6 +247,84 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { } } +// sensorAuth guards the read-only sensor endpoints: a session, any token in the +// 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. +func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + 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) + } + } + }) + if auth.TokenMatches(presented, hashes) { + next(w, r) + return + } + } + jsonErr(w, 401, "unauthenticated") + } +} + +// 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) { + if q := r.URL.Query(); q.Has("token") { + q.Del("token") + r.URL.RawQuery = q.Encode() + } + 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"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" { + jsonErr(w, 400, "name required") + return + } + name := strings.TrimSpace(body.Name) + tok, hash, err := auth.NewToken() + if err != nil { + jsonErr(w, 500, "token error") + return + } + clash := false + if err := s.cfg.Update(func(c *config.Config) { + for _, t := range c.Tokens { + if t.Name == name { + clash = true + return + } + } + c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now(), Scope: config.ScopeSensorsRead}) + }); err != nil { + jsonErr(w, 500, err.Error()) + return + } + if clash { + jsonErr(w, 409, "a token with that name exists; revoke it first") + return + } + log.Printf("read-only sensor token %q created (from %s)", name, clientIP(r)) + jsonOK(w, 200, map[string]string{"token": tok, "name": name, "scope": config.ScopeSensorsRead}) +} + // ---- API tokens: how a hub authenticates to this node ---- // issueToken trades the password for a long-lived token, named by the caller (a hub). @@ -283,7 +375,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}) + out = append(out, map[string]any{"name": t.Name, "created": t.Created, "scope": t.Scope}) } }) if out == nil { @@ -621,27 +713,114 @@ func (s *Server) sensorHistory(w http.ResponseWriter, r *http.Request) { jsonOK(w, 200, h) } +// sensorProm is every numeric sensor in the Prometheus text format. +func (s *Server) sensorProm(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + sensors.WriteProm(w, s.sensors.All()) +} + +// queryOf reads a shaped query from a request: the POST body, or on a GET either ?q= (the +// JSON shape, URL-encoded — a browser EventSource can only GET) or the flat form, where +// every other parameter is a key and its selector. +func queryOf(w http.ResponseWriter, r *http.Request) (q json.RawMessage, full bool, err error) { + params := r.URL.Query() + full = params.Get("full") == "1" + if r.Method == http.MethodPost { + b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10)) + if err != nil { + return nil, full, errors.New("query larger than 64 KiB") + } + return b, full, nil + } + if raw := params.Get("q"); raw != "" { + return json.RawMessage(raw), full, nil + } + flat := map[string]string{} + for k, v := range params { + if !reservedParam[k] && len(v) > 0 { + flat[k] = v[0] + } + } + q, _ = json.Marshal(flat) + return q, full, nil +} + +var reservedParam = map[string]bool{"full": true, "token": true, "q": true, "every": 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 +// that doesn't want them at the sampling rate. +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 + } + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + tick, cancel, ok := s.sensors.Subscribe() + fl, canFlush := w.(http.Flusher) + if !ok || !canFlush { + jsonErr(w, 501, "streaming isn't available here") + return + } + defer cancel() + every := time.Duration(0) + if ms, err := strconv.Atoi(r.URL.Query().Get("every")); err == nil && ms > 0 { + every = time.Duration(ms) * time.Millisecond + } + h := w.Header() + h.Set("Content-Type", "text/event-stream") + 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) + b, err := json.Marshal(out) + if err != nil { + return false + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil { + return false + } + fl.Flush() + return true + } + if !send() { + return + } + last := time.Now() + keepalive := time.NewTicker(25 * time.Second) + defer keepalive.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-keepalive.C: + if _, err := io.WriteString(w, ": keepalive\n\n"); err != nil { + return + } + fl.Flush() + case <-tick: + if every > 0 && time.Since(last) < every-5*time.Millisecond { + continue + } + last = time.Now() + if !send() { + return + } + } + } +} + // sensorQuery answers a shaped query. POST sends the shape as the body; GET builds a // flat one from the query string (?cpu=cpu.usage&fans=fan.*). ?full=1 returns whole // sensors instead of bare values. func (s *Server) sensorQuery(w http.ResponseWriter, r *http.Request) { - full := r.URL.Query().Get("full") == "1" - var q json.RawMessage - if r.Method == http.MethodPost { - b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10)) - if err != nil { - jsonErr(w, 413, "query larger than 64 KiB") - return - } - q = b - } else { - flat := map[string]string{} - for k, v := range r.URL.Query() { - if k != "full" && len(v) > 0 { - flat[k] = v[0] - } - } - q, _ = json.Marshal(flat) + q, full, err := queryOf(w, r) + if err != nil { + jsonErr(w, 413, err.Error()) + return } out, err := sensors.Shape(s.sensors.All(), q, full) if err != nil { diff --git a/internal/api/status_sensors.go b/internal/api/status_sensors.go new file mode 100644 index 0000000..b7e4f6f --- /dev/null +++ b/internal/api/status_sensors.go @@ -0,0 +1,137 @@ +package api + +import ( + "context" + "strings" + "sync" + "time" + + "hamlab/internal/sensors" + "hamlab/internal/storage" +) + +// statusSensors is the sensors.Provider for what isn't a /proc number: failed services, +// pending updates, pool health, drive health. Each source costs an exec or several, so +// they are read in the background on their own clocks and served from memory; every +// sensor's t says how old its reading really is. +type statusSensors struct { + s *Server + mu sync.RWMutex + by map[string][]sensors.Sensor // source → its sensors +} + +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 +) + +func newStatusSensors(s *Server) *statusSensors { + p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}} + go p.loop("services", servicesEvery, p.services) + go p.loop("updates", updatesEvery, p.updates) + go p.loop("zfs", zfsEvery, p.zfs) + go p.loop("smart", smartEvery, p.smart) + return p +} + +func (p *statusSensors) loop(name string, every time.Duration, read func(context.Context, int64) []sensors.Sensor) { + for { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + got := read(ctx, time.Now().UnixMilli()) + cancel() + p.mu.Lock() + p.by[name] = got + p.mu.Unlock() + time.Sleep(every) + } +} + +func (p *statusSensors) Sensors() []sensors.Sensor { + p.mu.RLock() + defer p.mu.RUnlock() + var out []sensors.Sensor + for _, v := range p.by { + out = append(out, v...) + } + return out +} + +func (p *statusSensors) services(ctx context.Context, t int64) []sensors.Sensor { + units, err := p.s.svc.List(ctx) + if err != nil { + return nil + } + var failed, running float64 + var names []string + for _, u := range units { + if u.Failed { + failed++ + names = append(names, u.Name) + } + if u.Sub == "running" { + running++ + } + } + return []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}, + } +} + +func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor { + st := p.s.updates.Status(ctx) + if st.Manager == "" { + return nil + } + return []sensors.Sensor{ + {ID: "updates.pending", Name: "Pending updates", Kind: "count", Value: float64(len(st.Packages)), T: t, Labels: map[string]string{"manager": st.Manager}}, + {ID: "updates.security", Name: "Pending security updates", Kind: "count", Value: float64(st.SecurityCount), T: t, Warn: sensors.F(1)}, + {ID: "updates.reboot_required", Name: "Reboot required", Kind: "state", Value: st.RebootNeeded, T: t}, + } +} + +func (p *statusSensors) zfs(ctx context.Context, t int64) []sensors.Sensor { + z := p.s.storage.ZFS(ctx) + var out []sensors.Sensor + for _, pool := range z.Pools { + id, l := "zfs."+sensors.Seg(pool.Name), map[string]string{"pool": pool.Name} + out = append(out, + sensors.Sensor{ID: id + ".health", Name: pool.Name + " health", Kind: "state", Value: pool.Health, T: t, Labels: l}, + sensors.Sensor{ID: id + ".healthy", Name: pool.Name + " healthy", Kind: "state", Value: pool.Health == "ONLINE", T: t, Labels: l}, + sensors.Sensor{ID: id + ".size", Name: pool.Name + " size", Kind: "bytes", Unit: "B", Value: float64(pool.Size), T: t, Labels: l}, + sensors.Sensor{ID: id + ".used", Name: pool.Name + " used", Kind: "bytes", Unit: "B", Value: float64(pool.Alloc), T: t, Min: sensors.F(0), Max: sensors.F(float64(pool.Size)), Labels: l}, + sensors.Sensor{ID: id + ".used_pct", Name: pool.Name + " used", Kind: "usage", Unit: "%", Value: float64(pool.Cap), T: t, Min: sensors.F(0), Max: sensors.F(100), Labels: l}, + sensors.Sensor{ID: id + ".frag", Name: pool.Name + " fragmentation", Kind: "fragmentation", Unit: "%", Value: float64(pool.Frag), T: t, Min: sensors.F(0), Max: sensors.F(100), Labels: l}, + ) + } + return out +} + +func (p *statusSensors) smart(ctx context.Context, t int64) []sensors.Sensor { + devs, err := storage.Devices(ctx) + if err != nil { + return nil + } + var out []sensors.Sensor + for _, d := range devs { + sm := p.s.storage.Smart(ctx, d.Name, false) + if sm.Passed == nil || sm.Standby || sm.Error != "" { + continue // no verdict to report: no smartctl, asleep, or not a SMART device + } + id, l := "smart."+sensors.Seg(d.Name), map[string]string{"device": d.Name, "model": d.Model} + out = append(out, + sensors.Sensor{ID: id + ".passed", Name: d.Name + " health", Kind: "state", Value: *sm.Passed, T: sm.FetchedAt, Labels: l}, + sensors.Sensor{ID: id + ".warnings", Name: d.Name + " warnings", Kind: "count", Value: float64(len(sm.Warnings)), T: sm.FetchedAt, Warn: sensors.F(1), Labels: l}, + ) + if sm.PowerOnHours > 0 { + out = append(out, sensors.Sensor{ID: id + ".power_on", Name: d.Name + " powered on", Kind: "duration", Unit: "s", Value: float64(sm.PowerOnHours) * 3600, T: sm.FetchedAt, Labels: l}) + } + if sm.NVMe != nil { + out = append(out, sensors.Sensor{ID: id + ".wear", Name: d.Name + " endurance used", Kind: "usage", Unit: "%", Value: float64(sm.NVMe.PercentageUsed), T: sm.FetchedAt, Min: sensors.F(0), Max: sensors.F(100), Labels: l}) + } + } + return out +} diff --git a/internal/config/config.go b/internal/config/config.go index 6bbbd96..e01cc5d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,8 +19,13 @@ type Token struct { Name string `json:"name"` Hash string `json:"hash"` Created time.Time `json:"created"` + // 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"` } +const ScopeSensorsRead = "sensors:read" + // Node is a remote Ham Lab this host (as hub) manages. The token is the one that // node issued to us; the fingerprint pins its self-signed certificate. type Node struct { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 7f3ab7c..f84b12a 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -165,6 +165,9 @@ type Collector struct { procAt time.Time lastTable *ProcTable upsProbe func() *UPS // nil when no UPS daemon is installed + + subMu sync.Mutex + subs map[chan struct{}]struct{} // told after every sample; see Subscribe } func NewCollector() *Collector { @@ -309,6 +312,28 @@ func (c *Collector) sample() { c.ring = c.ring[len(c.ring)-History:] } c.mu.Unlock() + + c.subMu.Lock() + for ch := range c.subs { + select { + case ch <- struct{}{}: + default: // a slow listener misses a tick; it reads the latest on the next one + } + } + c.subMu.Unlock() +} + +// Subscribe returns a channel that gets a nudge after every new sample, and the func +// that ends the subscription. +func (c *Collector) Subscribe() (<-chan struct{}, func()) { + ch := make(chan struct{}, 1) + c.subMu.Lock() + if c.subs == nil { + c.subs = map[chan struct{}]struct{}{} + } + c.subs[ch] = struct{}{} + c.subMu.Unlock() + return ch, func() { c.subMu.Lock(); delete(c.subs, ch); c.subMu.Unlock() } } type cpuTimes struct{ busy, total uint64 } diff --git a/internal/sensors/prom.go b/internal/sensors/prom.go new file mode 100644 index 0000000..58cf7e4 --- /dev/null +++ b/internal/sensors/prom.go @@ -0,0 +1,76 @@ +package sensors + +import ( + "fmt" + "io" + "sort" + "strings" +) + +// promFamily names the metric family for a sensor and scales its value to the base unit +// Prometheus convention wants (seconds, not minutes). +func promFamily(s Sensor) (name string, scale float64) { + scale = 1 + unit := map[string]string{"%": "percent", "°C": "celsius", "rpm": "rpm", "B/s": "bytes_per_second", "B": "bytes", + "s": "seconds", "V": "volts", "W": "watts", "MHz": "megahertz"}[s.Unit] + if s.Unit == "min" { + unit, scale = "seconds", 60 + } + if s.Kind == "count" { // hamlab_count{id=…} would say nothing; name it after the thing counted + return "hamlab_" + strings.ReplaceAll(Seg(strings.ReplaceAll(s.ID, ".", "_")), "-", "_"), 1 + } + name = "hamlab_" + s.Kind + if unit != "" { + name += "_" + unit + } + return +} + +var promEsc = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`) + +// WriteProm writes sensors in the Prometheus text exposition format: one family per kind +// of measurement, the sensor's ID and its real-world labels as labels. Booleans are 0/1; +// a text state is a 1 carrying the text as a label, the usual way to export an enum. +func WriteProm(w io.Writer, all []Sensor) { + fams := map[string][]string{} + for _, s := range all { + name, scale := promFamily(s) + labels := []string{`id="` + promEsc.Replace(s.ID) + `"`} + keys := make([]string, 0, len(s.Labels)) + for k := range s.Labels { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if s.Labels[k] != "" { + labels = append(labels, Seg(k)+`="`+promEsc.Replace(s.Labels[k])+`"`) + } + } + var v float64 + switch x := s.Value.(type) { + case float64: + v = x * scale + case bool: + if x { + v = 1 + } + case string: + v = 1 + labels = append(labels, `value="`+promEsc.Replace(x)+`"`) + default: + continue + } + fams[name] = append(fams[name], fmt.Sprintf("%s{%s} %v", name, strings.Join(labels, ","), v)) + } + names := make([]string, 0, len(fams)) + for n := range fams { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + fmt.Fprintf(w, "# TYPE %s gauge\n", n) + for _, line := range fams[n] { + fmt.Fprintln(w, line) + } + } +} diff --git a/internal/sensors/sensors.go b/internal/sensors/sensors.go index 58ee838..84f881c 100644 --- a/internal/sensors/sensors.go +++ b/internal/sensors/sensors.go @@ -51,6 +51,22 @@ type Historian interface { SensorHistory(id string) (s Series, ok bool) } +// Ticker is a Provider that can say when its values have changed. +type Ticker interface { + Subscribe() (tick <-chan struct{}, cancel func()) +} + +// Subscribe follows the first provider that ticks. ok is false when none does. +func (r *Registry) Subscribe() (tick <-chan struct{}, cancel func(), ok bool) { + for _, p := range r.providers { + if t, is := p.(Ticker); is { + tick, cancel = t.Subscribe() + return tick, cancel, true + } + } + return nil, nil, false +} + // F is a pointer to a float, for the optional bounds. func F(v float64) *float64 { return &v } diff --git a/internal/sensors/sensors_test.go b/internal/sensors/sensors_test.go index ca4e3e5..0edf028 100644 --- a/internal/sensors/sensors_test.go +++ b/internal/sensors/sensors_test.go @@ -3,6 +3,7 @@ package sensors import ( "encoding/json" "reflect" + "strings" "testing" ) @@ -51,3 +52,26 @@ func TestShape(t *testing.T) { } } } + +func TestWriteProm(t *testing.T) { + var b strings.Builder + WriteProm(&b, []Sensor{ + {ID: "cpu.usage", Kind: "usage", Unit: "%", Value: 12.5}, + {ID: "ups.runtime", Kind: "duration", Unit: "min", Value: 2.0}, + {ID: "net.eth0.up", Kind: "state", Value: true, Labels: map[string]string{"iface": "eth0"}}, + {ID: "ups.state", Kind: "state", Value: `Nor"mal`}, + {ID: "updates.pending", Kind: "count", Value: 3.0}, + }) + got := b.String() + for _, want := range []string{ + "# TYPE hamlab_usage_percent gauge\n" + `hamlab_usage_percent{id="cpu.usage"} 12.5`, + `hamlab_duration_seconds{id="ups.runtime"} 120`, + `hamlab_state{id="net.eth0.up",iface="eth0"} 1`, + `hamlab_state{id="ups.state",value="Nor\"mal"} 1`, + `hamlab_updates_pending{id="updates.pending"} 3`, + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index be8fa6f..6486a34 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -3,6 +3,9 @@ import { api, DashPrefs, Sample, Series, Temp } from './api' import Sparkline from './Sparkline' import { Loading, Spinner, toast } from './ui' import { fmtMs, useRefreshMs } from './prefs' +import { useSensorStream } from './sensors' +import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets' +import WidgetPicker from './WidgetPicker' const MAX = 300 const S1 = 'var(--s1)', S2 = 'var(--s2)' @@ -27,7 +30,7 @@ const byId = (id: string) => MODULES.find((m) => m.id === id) const DEFAULT_INTERVAL = 2000 const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms)) -interface Layout { order: string[]; hidden: Set; hiddenItems: Set; intervalMs: number } +interface Layout { order: string[]; hidden: Set; hiddenItems: Set; intervalMs: number; widgets: WidgetSpec[] } // Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules. function normalize(p: DashPrefs): Layout { @@ -35,9 +38,10 @@ function normalize(p: DashPrefs): Layout { const order = (p.order ?? []).filter((id) => known.has(id)) for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id) const iv = p.intervalMs && p.intervalMs >= 100 && p.intervalMs <= 2000 ? p.intervalMs : DEFAULT_INTERVAL - return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv } + return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv, + widgets: (p.widgets ?? []).filter((w) => w && typeof w.id === 'string' && typeof w.sensor === 'string') } } -const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs }) +const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs, widgets: l.widgets }) const fmtWindow = (ms: number) => { const s = Math.round((MAX * ms) / 1000); return s >= 60 ? `${Math.round(s / 60)} min` : `${s} s` } // ---- formatting ---- @@ -337,6 +341,10 @@ export default function Dashboard() { if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want })) }, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps + // Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses. + const [picking, setPicking] = useState(false) + const live = useSensorStream>(shapeOf(layout.widgets)) + if (!latest) return
{err ?
{err}
: }
const edit: Edit = { @@ -396,6 +404,24 @@ export default function Dashboard() { {visible('tile').map((id) =>
{tools(id, 'tile')}{body(id, ctx)}
)} )} + {(layout.widgets.length > 0 || editing) && ( +
+ {layout.widgets.map((w, i) => ( +
+ {editing && ( +
+ + + +
+ )} + +
+ ))} + {editing && } +
+ )} + {picking && setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />} {visible('card').length > 0 && (
{visible('card').map((id) =>
{tools(id, 'card')}{body(id, ctx)}
)} diff --git a/web/src/Embed.tsx b/web/src/Embed.tsx new file mode 100644 index 0000000..ad9714c --- /dev/null +++ b/web/src/Embed.tsx @@ -0,0 +1,31 @@ +// One widget, alone, for an iframe in someone else's dashboard (Glance, Home Assistant…): +// /embed/widget?type=gauge&sensor=cpu.usage&token= +// &machine=grid &title=… &theme=dark|light|cyberpunk|retro &every= &bare=1 +// It stays live over the sensor stream. The token must be a read-only sensor token; a +// full-control token is refused in a URL by design. +import { useSensorStream, Source } from './sensors' +import { Widget, WidgetData, WidgetSpec, WidgetType, WIDGET_TYPES } from './widgets' +import { applyTheme } from './themes' + +const p = new URLSearchParams(location.search) +if (p.get('theme')) applyTheme(p.get('theme')!) + +export default function Embed() { + const sensor = p.get('sensor') ?? '' + const asked = p.get('type') as WidgetType | null + const type: WidgetType = /[*?[]/.test(sensor) ? 'list' : asked && WIDGET_TYPES.some((t) => t.id === asked && !t.many) ? asked : 'stat' + const spec: WidgetSpec = { id: 'w', type, sensor, title: p.get('title') ?? undefined } + const src: Source = { machine: p.get('machine') || null, token: p.get('token') ?? undefined } + const every = Number(p.get('every')) || undefined + const live = useSensorStream<{ w: WidgetData }>(sensor ? { w: sensor } : null, src, every) + return ( +
+ {!sensor ?
embed needs ?sensor=
: ( +
+ + {live.error && !live.data &&
{src.token ? 'waiting for the stream — check the token' : 'no token given'}
} +
+ )} +
+ ) +} diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index 741a722..d825ae0 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -31,7 +31,8 @@ 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 }[] | null>(null) + const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string }[] | null>(null) + const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null) const [cur, setCur] = useState('') const [pw1, setPw1] = useState('') const [pw2, setPw2] = useState('') @@ -97,16 +98,32 @@ export default function Settings({ me }: { me: Me }) {

Access tokens

-
Tokens let another Ham Lab (a hub) manage this host. They're issued when a hub adds this machine and revoked when it removes it.
+
A full token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A read-only token can only read sensors — that's the one to give a dashboard.
{tokens === null && } {tokens && tokens.length === 0 &&
No tokens. No hub manages this host.
} {tokens && tokens.map((t) => (
{t.name} + {t.scope ? 'read-only' : 'full'} issued {new Date(t.created).toLocaleString()} revoke(t.name)}>Revoke
))} + {fresh && ( +
+
Token for {fresh.name} — shown once, copy it now:
+
{fresh.token}
+ { try { await navigator.clipboard.writeText(fresh.token); toast('copied') } catch { toast('select and copy it by hand', 'err') } }}>Copy{' '} + setFresh(null)}>Done +
+ )} +
+ { + 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') } + }}>+ Read-only sensor token +

About

diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 777053d..21d2771 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -27,7 +27,7 @@ const FolderIcon = ( ) const GROUPS: Group[] = [ - { id: 'dashboard', label: 'Dashboard', icon: '◔' }, + { id: 'dashboard', label: 'Monitors', icon: '◔' }, { id: 'procs', label: 'Processes', icon: '≡' }, { id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'services', label: 'Services', icon: '◈' }, @@ -207,7 +207,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} + {view === 'dashboard' ? 'Monitors' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}
) diff --git a/web/src/TerminalPane.tsx b/web/src/TerminalPane.tsx index ca51f8a..67c76c6 100644 --- a/web/src/TerminalPane.tsx +++ b/web/src/TerminalPane.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Terminal } from '@xterm/xterm' +import '@xterm/xterm/css/xterm.css' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' import { wsURL } from './api' diff --git a/web/src/WidgetPicker.tsx b/web/src/WidgetPicker.tsx new file mode 100644 index 0000000..3ab6c7d --- /dev/null +++ b/web/src/WidgetPicker.tsx @@ -0,0 +1,55 @@ +import { useEffect, useMemo, useState } from 'react' +import { Sensor, fetchCatalog, fmtValue } from './sensors' +import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets' +import { Btn, Loading } from './ui' + +// Pick a sensor from this machine's catalog (or type a pattern) and how to draw it. +export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec) => void; onClose: () => void }) { + const [cat, setCat] = useState(null) + const [err, setErr] = useState('') + const [filter, setFilter] = useState('') + const [sensor, setSensor] = useState('') + const [type, setType] = useState('stat') + const [title, setTitle] = useState('') + useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, []) + const shown = useMemo(() => { + const f = filter.trim().toLowerCase() + return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f)) + }, [cat, filter]) + const pattern = /[*?[]/.test(sensor) + const pick = (id: string) => { setSensor(id); if (/[*?[]/.test(id)) setType('list') } + return ( +
+
e.stopPropagation()}> +

Add a widget

+ setFilter(e.target.value)} placeholder="filter sensors — cpu, temp, fan, enp3s0…" /> + {err &&
{err}
} + {!cat && !err && } + {cat && ( +
+ {shown.map((s) => ( + + ))} + {shown.length === 0 &&
No sensor matches.
} +
+ )} + +
+ {WIDGET_TYPES.filter((t) => !!t.many === pattern).map((t) => )} +
+ +
+ onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })}>Add + + +
+
+
+ ) +} diff --git a/web/src/api.ts b/web/src/api.ts index ab1ba06..d9d05af 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -79,7 +79,7 @@ export interface Series { // Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched // off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label"). -export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number } +export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number; widgets?: { id: string; type: 'stat' | 'gauge' | 'bar' | 'spark' | 'list'; sensor: string; title?: string }[] } export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean } export interface ServiceDetail extends ServiceUnit { props: Record } @@ -179,7 +179,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 }[]>('GET', '/api/tokens', undefined, 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), 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/main.tsx b/web/src/main.tsx index e39804a..993b95b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,17 +1,19 @@ -import React from 'react' +import React, { Suspense, lazy } from 'react' import { createRoot } from 'react-dom/client' -import App from './App' import { applyTheme, currentThemeId } from './themes' applyTheme(currentThemeId()) -import '@xterm/xterm/css/xterm.css' import './styles.css' +// An embedded widget is its own small bundle: it never loads the console (or xterm). +const embedded = location.pathname.startsWith('/embed') +const Root = embedded ? lazy(() => import('./Embed')) : lazy(() => import('./App')) + createRoot(document.getElementById('root')!).render( - + , ) -if ('serviceWorker' in navigator && location.protocol === 'https:') { +if (!embedded && 'serviceWorker' in navigator && location.protocol === 'https:') { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}) }) } diff --git a/web/src/sensors.ts b/web/src/sensors.ts new file mode 100644 index 0000000..242d444 --- /dev/null +++ b/web/src/sensors.ts @@ -0,0 +1,97 @@ +// Client for the sensor API (SENSORS.md): what Ham Lab's own widgets are built on, and +// the same thing any outside consumer uses. +import { useEffect, useRef, useState } from 'react' +import { apiPath } from './api' + +export interface Sensor { + id: string; name: string; kind: string; unit?: string; value: number | boolean | string; t: number + min?: number; max?: number; warn?: number; crit?: number; labels?: Record +} +export type Shape = string | Shape[] | { [k: string]: Shape } + +// Where to read from. Inside the console that's the current machine with the session +// cookie; an embedded widget names a machine and carries a read-only token. +export interface Source { machine?: string | null; token?: string } + +function url(src: Source | undefined, path: string, params: Record): string { + const base = src?.machine !== undefined ? (src.machine ? `/api/nodes/${src.machine}${path.slice(4)}` : path) : apiPath(path) + const q = new URLSearchParams(params) + if (src?.token) q.set('token', src.token) + const s = q.toString() + return s ? `${base}?${s}` : base +} + +export async function fetchCatalog(src?: Source): Promise { + const r = await fetch(url(src, '/api/sensors', {}), { credentials: 'same-origin' }) + if (!r.ok) throw new Error(r.status === 401 ? 'not authorised' : r.statusText) + return (await r.json()).sensors +} + +export async function fetchHistory(id: string, src?: Source): Promise<{ t: number[]; v: number[] } | null> { + const r = await fetch(url(src, `/api/sensors/${encodeURIComponent(id)}/history`, {}), { credentials: 'same-origin' }) + return r.ok ? r.json() : null +} + +// useSensorStream keeps a shaped query answered: the object comes back filled with whole +// sensors and is replaced on every sample. One stream serves any number of widgets. +export function useSensorStream(shape: Shape | null, src?: Source, everyMs?: number): { data: T | null; error: string } { + const [data, setData] = useState(null) + const [error, setError] = useState('') + const key = shape === null ? '' : JSON.stringify(shape) + const srcKey = `${src?.machine ?? '·'}|${src?.token ?? ''}|${everyMs ?? ''}` + const srcRef = useRef(src); srcRef.current = src + useEffect(() => { + if (!key) { setData(null); return } + const params: Record = { q: key, full: '1' } + if (everyMs) params.every = String(everyMs) + const es = new EventSource(url(srcRef.current, '/api/sensors/stream', params)) + es.onmessage = (e) => { try { setData(JSON.parse(e.data)); setError('') } catch { /* skip a bad frame */ } } + es.onerror = () => setError('reconnecting…') // EventSource retries by itself + return () => es.close() + }, [key, srcKey]) // eslint-disable-line react-hooks/exhaustive-deps + return { data, error } +} + +// ---- formatting, by unit ---- +const BYTES = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'] +function scaled(v: number): string { + let i = 0 + while (v >= 1024 && i < BYTES.length - 1) { v /= 1024; i++ } + return `${v >= 100 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${BYTES[i]}` +} +function duration(s: number): string { + const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) + return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m` : `${Math.round(s)}s` +} +export function fmtValue(v: Sensor['value'] | null | undefined, unit?: string): string { + if (v === null || v === undefined) return '—' + if (typeof v === 'boolean') return v ? 'yes' : 'no' + if (typeof v === 'string') return v + switch (unit) { + case 'B': return scaled(v) + case 'B/s': return scaled(v) + '/s' + case 's': return duration(v) + case 'min': return duration(v * 60) + case '%': return `${v.toFixed(v < 10 ? 1 : 0)}%` + case '°C': return `${v.toFixed(0)}°C` + case 'rpm': return `${Math.round(v)} rpm` + case undefined: case '': return String(Math.round(v * 100) / 100) + default: return `${Math.round(v * 10) / 10} ${unit}` + } +} + +// How full the scale is, 0–1, when the sensor has one. Temperatures use the hardware's +// own critical threshold as the top of the scale. +export function fraction(s: Sensor): number | null { + if (typeof s.value !== 'number') return null + const max = s.max ?? s.crit ?? s.warn, min = s.min ?? 0 + if (max === undefined || max <= min) return null + return Math.min(1, Math.max(0, (s.value - min) / (max - min))) +} +export function severity(s: Sensor): '' | 'warn' | 'bad' { + if (typeof s.value !== 'number') return '' + if (s.crit !== undefined && s.value >= s.crit) return 'bad' + if (s.warn !== undefined && s.value >= s.warn) return 'warn' + if (s.kind === 'usage' && s.id !== 'ups.battery') return s.value >= 95 ? 'bad' : s.value >= 85 ? 'warn' : '' + return '' +} diff --git a/web/src/styles.css b/web/src/styles.css index a539c90..72f6f6c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -393,3 +393,24 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .header .link, .header .icon-btn { min-height: 36px; display: inline-flex; align-items: center; } .mod-tools { margin-top: 0; } } + +/* sensor widgets */ +.gauge-svg { width: 100%; max-width: 180px; display: block; margin: 0 auto; } +.w-gauge-v { fill: var(--fg); font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; } +.tile .value.warn, .w-row .num.warn { color: var(--s2); } .tile .value.bad, .w-row .num.bad { color: var(--bad); } +.tile .meter { margin-top: 8px; } +.tile.w-list { grid-column: span 2; } +.w-list { display: flex; flex-direction: column; gap: 6px; } +.w-row { display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; font-size: 13px; align-items: baseline; } +.w-row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .w-row .meter { grid-column: 1 / 3; margin-top: 0; } +.tile.add-widget { border-style: dashed; color: var(--accent2); min-height: 72px; display: flex; align-items: center; justify-content: center; cursor: pointer; } +.modal.picker { width: min(520px, 100%); max-height: 90vh; } +.picker-list { overflow-y: auto; max-height: 34vh; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); } +.picker-row { display: flex; justify-content: space-between; gap: 10px; width: 100%; padding: 7px 10px; text-align: left; font-size: 12px; border-bottom: 1px solid var(--border); } +.picker-row:hover, .picker-row.active { background: var(--bg3); } .picker-row.active { box-shadow: inset 3px 0 0 var(--accent); } +@media (max-width: 800px) { .tile.w-list { grid-column: 1 / -1; } } + +/* a widget alone in an iframe */ +.embed { min-height: 100vh; padding: 10px; background: var(--bg2); box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; } +.embed.bare { background: transparent; } +.embed .tile { border: 0; padding: 0; background: none; } diff --git a/web/src/widgets.tsx b/web/src/widgets.tsx new file mode 100644 index 0000000..3b6ce20 --- /dev/null +++ b/web/src/widgets.tsx @@ -0,0 +1,87 @@ +// Prebuilt widgets over the sensor API. A widget is a spec — a type and a selector — +// and nothing else; the same spec renders inside the dashboard or alone in an iframe. +import { useEffect, useRef, useState } from 'react' +import Sparkline from './Sparkline' +import { Sensor, Source, fetchHistory, fmtValue, fraction, severity } from './sensors' + +export type WidgetType = 'stat' | 'gauge' | 'bar' | 'spark' | 'list' +export interface WidgetSpec { id: string; type: WidgetType; sensor: string; title?: string } +export const WIDGET_TYPES: { id: WidgetType; label: string; many?: boolean }[] = [ + { id: 'stat', label: 'Number' }, { id: 'gauge', label: 'Gauge' }, { id: 'bar', label: 'Bar' }, + { id: 'spark', label: 'Sparkline' }, { id: 'list', label: 'List (pattern, e.g. fan.*)', many: true }, +] + +// What the stream hands a widget: one sensor, a map of them (a glob), or null (no such sensor here). +export type WidgetData = Sensor | Record | null | undefined +const isSensor = (d: WidgetData): d is Sensor => !!d && typeof (d as Sensor).id === 'string' + +const MAX_POINTS = 300 + +function Gauge({ s }: { s: Sensor }) { + const f = fraction(s) ?? 0, sev = severity(s) + const R = 40, C = Math.PI * R // half circle + return ( + + + + {fmtValue(s.value, s.unit)} + + ) +} + +function Spark({ s, src }: { s: Sensor; src?: Source }) { + const pts = useRef([]) + const lastT = useRef(0) + const [, bump] = useState(0) + useEffect(() => { + let stop = false + pts.current = []; lastT.current = 0 + fetchHistory(s.id, src).then((h) => { if (!stop && h) { pts.current = h.v.slice(-MAX_POINTS); lastT.current = h.t[h.t.length - 1] ?? 0; bump((n) => n + 1) } }) + return () => { stop = true } + }, [s.id, src?.machine, src?.token]) // eslint-disable-line react-hooks/exhaustive-deps + if (typeof s.value === 'number' && s.t > lastT.current) { + lastT.current = s.t + pts.current.push(s.value) + if (pts.current.length > MAX_POINTS) pts.current.shift() + } + return fmtValue(v, s.unit)} height={40} /> +} + +export function Widget({ spec, data, src }: { spec: WidgetSpec; data: WidgetData; src?: Source }) { + const title = spec.title || (isSensor(data) ? data.name : spec.sensor) + let body + if (data === undefined) body =
…
+ else if (data === null) body =
no sensor {spec.sensor} on this machine
+ else if (!isSensor(data)) { + const rows = Object.values(data).sort((a, b) => a.id.localeCompare(b.id)) + body = rows.length === 0 ?
nothing matches {spec.sensor}
: ( +
+ {rows.map((s) => { + const f = fraction(s) + return ( +
+ {s.name} + {fmtValue(s.value, s.unit)} + {f !== null &&
} +
+ ) + })} +
+ ) + } else if (spec.type === 'gauge' && fraction(data) !== null) body = + else { + const f = fraction(data) + body = ( + <> +
{fmtValue(data.value, data.unit)}
+ {spec.type === 'bar' && f !== null &&
} + {spec.type === 'spark' && } + + ) + } + return <>
{title}
{body} +} + +// The one query that feeds a set of widgets: each spec's id → its selector. +export const shapeOf = (specs: WidgetSpec[]) => (specs.length ? Object.fromEntries(specs.map((w) => [w.id, w.sensor])) : null)