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 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 07:37:10 -07:00
co-authored by Claude Fable 5.1
parent 468f45251d
commit b05a053920
20 changed files with 950 additions and 56 deletions
+12 -12
View File
@@ -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.
+98 -4
View File
@@ -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=<URL-encoded JSON shape>` or
the flat form; `?full=1` as above; `?every=<ms>` 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/<machine>/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 <token>`, or as `?token=<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/<machine>/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: <read-only token> }
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.<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
```
## 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=<read-only token>
&machine=grid &title=… &theme=dark|light|cyberpunk|retro &every=<ms> &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=<read-only 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 <read-only token> }
template: |
<div>tank {{ .JSON.Float "pool" }}% · {{ .JSON.Int "failed" }} failed · {{ .JSON.Int "updates" }} updates</div>
```
## 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.
+197 -18
View File
@@ -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()
// 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,8 +233,10 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
var hashes []string
s.cfg.View(func(c *config.Config) {
for _, t := range c.Tokens {
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) {
next(w, r)
@@ -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)
}
// 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
// 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 {
jsonErr(w, 413, "query larger than 64 KiB")
return
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
}
q = b
} else {
flat := map[string]string{}
for k, v := range r.URL.Query() {
if k != "full" && len(v) > 0 {
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=<ms> 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) {
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 {
+137
View File
@@ -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
}
+5
View File
@@ -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 {
+25
View File
@@ -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 }
+76
View File
@@ -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)
}
}
}
+16
View File
@@ -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 }
+24
View File
@@ -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)
}
}
}
+29 -3
View File
@@ -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<string>; hiddenItems: Set<string>; intervalMs: number }
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; 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<Record<string, WidgetData>>(shapeOf(layout.widgets))
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
const edit: Edit = {
@@ -396,6 +404,24 @@ export default function Dashboard() {
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
</div>
)}
{(layout.widgets.length > 0 || editing) && (
<div className="kpis widgets">
{layout.widgets.map((w, i) => (
<div className={'tile w-' + w.type} key={w.id}>
{editing && (
<div className="mod-tools">
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
</div>
)}
<Widget spec={w} data={live.data ? live.data[w.id] : undefined} />
</div>
))}
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
</div>
)}
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
{visible('card').length > 0 && (
<div className="cards">
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
+31
View File
@@ -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=<read-only token>
// &machine=grid &title=… &theme=dark|light|cyberpunk|retro &every=<ms> &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 (
<div className={'embed' + (p.get('bare') === '1' ? ' bare' : '')}>
{!sensor ? <div className="error">embed needs ?sensor=</div> : (
<div className={'tile w-' + type}>
<Widget spec={spec} data={live.data ? live.data.w : undefined} src={src} />
{live.error && !live.data && <div className="sub">{src.token ? 'waiting for the stream — check the token' : 'no token given'}</div>}
</div>
)}
</div>
)
}
+19 -2
View File
@@ -31,7 +31,8 @@ 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 }[] | 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 }) {
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
<section className="card">
<div className="muted small" style={{ marginBottom: 8 }}>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.</div>
<div className="muted small" style={{ marginBottom: 8 }}>A <b>full</b> token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A <b>read-only</b> token can only read sensors — that's the one to give a dashboard.</div>
{tokens === null && <Loading label="Loading…" />}
{tokens && tokens.length === 0 && <div className="empty">No tokens. No hub manages this host.</div>}
{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="muted small">issued {new Date(t.created).toLocaleString()}</span>
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
</div>
))}
{fresh && (
<div className="notice attn" style={{ marginTop: 8 }}>
<div>Token for <b>{fresh.name}</b> — shown once, copy it now:</div>
<div className="mono small" style={{ wordBreak: 'break-all', userSelect: 'all', margin: '6px 0' }}>{fresh.token}</div>
<Btn className="btn" onClick={async () => { try { await navigator.clipboard.writeText(fresh.token); toast('copied') } catch { toast('select and copy it by hand', 'err') } }}>Copy</Btn>{' '}
<Btn className="btn" onClick={() => setFresh(null)}>Done</Btn>
</div>
)}
<div className="svc-actions" style={{ marginTop: 8 }}>
<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') }
}}>+ Read-only sensor token</Btn>
</div>
</section>
<div className="dash-bar"><h2 className="section">About</h2></div>
+2 -2
View File
@@ -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:
<span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{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 : ''}</span>
<span className="muted">{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 : ''}</span>
</footer>
</div>
)
+1
View File
@@ -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'
+55
View File
@@ -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<Sensor[] | null>(null)
const [err, setErr] = useState('')
const [filter, setFilter] = useState('')
const [sensor, setSensor] = useState('')
const [type, setType] = useState<WidgetType>('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 (
<div className="modal-back" onClick={onClose}>
<div className="modal picker" onClick={(e) => e.stopPropagation()}>
<h3>Add a widget</h3>
<input autoFocus value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter sensors — cpu, temp, fan, enp3s0…" />
{err && <div className="error">{err}</div>}
{!cat && !err && <Loading label="Reading the catalog…" />}
{cat && (
<div className="picker-list">
{shown.map((s) => (
<button key={s.id} className={'picker-row' + (s.id === sensor ? ' active' : '')} onClick={() => pick(s.id)}>
<span className="mono">{s.id}</span><span className="muted num">{fmtValue(s.value, s.unit)}</span>
</button>
))}
{shown.length === 0 && <div className="muted small" style={{ padding: 8 }}>No sensor matches.</div>}
</div>
)}
<label className="muted">Sensor ID, or a pattern for a list (<span className="mono">fan.*</span>, <span className="mono">cpu.core.*.usage</span>)
<input className="mono" value={sensor} onChange={(e) => pick(e.target.value.trim())} placeholder="cpu.usage" />
</label>
<div className="chips">
{WIDGET_TYPES.filter((t) => !!t.many === pattern).map((t) => <button key={t.id} className={'chip' + (type === t.id ? ' on' : '')} onClick={() => setType(t.id)}>{t.label}</button>)}
</div>
<label className="muted">Title (optional)
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" />
</label>
<div className="svc-actions">
<Btn className="btn on" disabled={!sensor} onClick={() => onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })}>Add</Btn>
<span className="spacer" />
<button className="btn" onClick={onClose}>Cancel</button>
</div>
</div>
</div>
)
}
+3 -2
View File
@@ -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<string, string> }
@@ -179,7 +179,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 }[]>('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<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),
+7 -5
View File
@@ -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(
<React.StrictMode>
<App />
<Suspense fallback={null}><Root /></Suspense>
</React.StrictMode>,
)
if ('serviceWorker' in navigator && location.protocol === 'https:') {
if (!embedded && 'serviceWorker' in navigator && location.protocol === 'https:') {
window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}) })
}
+97
View File
@@ -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<string, string>
}
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, string>): 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<Sensor[]> {
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<T = unknown>(shape: Shape | null, src?: Source, everyMs?: number): { data: T | null; error: string } {
const [data, setData] = useState<T | null>(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<string, string> = { 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 ''
}
+21
View File
@@ -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; }
+87
View File
@@ -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<string, Sensor> | 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 (
<svg viewBox="0 0 100 56" className="gauge-svg" role="img" aria-label={`${s.name} ${fmtValue(s.value, s.unit)}`}>
<path d="M10 50 A40 40 0 0 1 90 50" fill="none" stroke="var(--track)" strokeWidth="9" strokeLinecap="round" />
<path d="M10 50 A40 40 0 0 1 90 50" fill="none" stroke={sev === 'bad' ? 'var(--bad)' : sev === 'warn' ? 'var(--s2)' : 'var(--s1)'} strokeWidth="9" strokeLinecap="round"
strokeDasharray={`${f * C} ${C}`} style={{ transition: 'stroke-dasharray .3s' }} />
<text x="50" y="47" textAnchor="middle" className="w-gauge-v">{fmtValue(s.value, s.unit)}</text>
</svg>
)
}
function Spark({ s, src }: { s: Sensor; src?: Source }) {
const pts = useRef<number[]>([])
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 <Sparkline series={[{ name: s.name, values: pts.current, color: 'var(--s1)' }]} max={s.unit === '%' ? 100 : s.max} format={(v) => 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 = <div className="value muted">…</div>
else if (data === null) body = <div className="sub">no sensor <span className="mono">{spec.sensor}</span> on this machine</div>
else if (!isSensor(data)) {
const rows = Object.values(data).sort((a, b) => a.id.localeCompare(b.id))
body = rows.length === 0 ? <div className="sub">nothing matches <span className="mono">{spec.sensor}</span></div> : (
<div className="w-list">
{rows.map((s) => {
const f = fraction(s)
return (
<div className="w-row" key={s.id}>
<span className="k" title={s.id}>{s.name}</span>
<span className={'num ' + severity(s)}>{fmtValue(s.value, s.unit)}</span>
{f !== null && <div className="meter"><div className={'fill ' + severity(s)} style={{ width: `${f * 100}%` }} /></div>}
</div>
)
})}
</div>
)
} else if (spec.type === 'gauge' && fraction(data) !== null) body = <Gauge s={data} />
else {
const f = fraction(data)
body = (
<>
<div className={'value ' + severity(data)}>{fmtValue(data.value, data.unit)}</div>
{spec.type === 'bar' && f !== null && <div className="meter"><div className={'fill ' + severity(data)} style={{ width: `${f * 100}%` }} /></div>}
{spec.type === 'spark' && <Spark s={data} src={src} />}
</>
)
}
return <><div className="label" title={spec.sensor}>{title}</div>{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)