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:
co-authored by
Claude Fable 5.1
parent
468f45251d
commit
b05a053920
@@ -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
|
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
|
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.
|
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
|
||||||
- **Sensors** — **step 1 shipped 2026-09-19** (`internal/sensors`,
|
- **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`,
|
||||||
`internal/metrics/sensors.go`, reference in `SENSORS.md`): everything measured is a
|
`internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`;
|
||||||
sensor with a stable dotted ID; catalog, single value (`?format=text`), per-sensor
|
reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID;
|
||||||
history, and a **shaped query** where the client sends the object it wants and gets it
|
catalog, single value, history, a **shaped query** (the client sends the object it wants
|
||||||
back filled in (GraphQL's idea without GraphQL: sensors differ per machine, so a static
|
and gets it back filled in — GraphQL's idea without GraphQL, since sensors differ per
|
||||||
schema would collapse to `sensor(id:)` anyway). `/api/metrics` and the Dashboard are
|
machine and a static schema would collapse to `sensor(id:)`), the same query as a live
|
||||||
untouched so far. **Remaining, in order:** (2) read-only scoped tokens + Prometheus
|
SSE stream, Prometheus `/metrics`, read-only `sensors:read` tokens (never full tokens in
|
||||||
`/metrics`; (3) push stream + widget library, Dashboard rebuilt on its own sensors;
|
a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets
|
||||||
(4) embeddable widget page + Glance config (Glance's `custom-api` only refetches on page
|
on the Monitors tab (was "Dashboard"; the view id and URL are still `dashboard`), and a
|
||||||
load, an `iframe` of our embed is what's live); (5) non-metric sensors — services,
|
frameable `/embed/widget` for Glance and friends. **Remaining:** port the stock Monitors
|
||||||
SMART, ZFS, updates, containers. One sampler rate, set in one place, is part of (3):
|
modules onto sensors; per-unit service and container sensors; pattern-restricted tokens;
|
||||||
today the open dashboard still drives it.
|
one-request fleet query.
|
||||||
- **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`,
|
- **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`,
|
||||||
`MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose
|
`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.
|
`config.json` lists `nodes` is a hub; any other is a standalone node with its full UI.
|
||||||
|
|||||||
+98
-4
@@ -76,9 +76,103 @@ POST /api/sensors/query
|
|||||||
- `?full=1` → every value is the whole sensor object instead of the bare value. (In the
|
- `?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.)
|
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
|
## Not yet
|
||||||
|
|
||||||
Read-only scoped tokens, Prometheus `/metrics`, a push stream, widgets built on this,
|
- The stock Monitors modules (CPU, Temperatures, Network…) still read `/api/metrics`;
|
||||||
an embeddable widget page, and non-metric sensors (services, SMART, ZFS, updates). Until
|
only custom widgets run on sensors. Porting them is the remaining dogfooding.
|
||||||
scoped tokens exist, **do not hand a Ham Lab token to a dashboard** — today's tokens are
|
- Per-unit service sensors, containers, token restriction to ID patterns, a fleet-wide
|
||||||
full control of the machine.
|
query in one request.
|
||||||
|
|||||||
+197
-18
@@ -84,6 +84,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
if s.user == "" {
|
if s.user == "" {
|
||||||
s.user = "?"
|
s.user = "?"
|
||||||
}
|
}
|
||||||
|
s.sensors.Add(newStatusSensors(s))
|
||||||
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
||||||
var raw json.RawMessage
|
var raw json.RawMessage
|
||||||
st.View(func(x *store.State) { raw = x.Dashboard })
|
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/terminals/{id}/attach", s.auth(s.attach))
|
||||||
m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest))
|
m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest))
|
||||||
m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory))
|
m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory))
|
||||||
m.HandleFunc("GET /api/sensors", s.auth(s.sensorCatalog))
|
m.HandleFunc("GET /api/sensors", s.sensorAuth(s.sensorCatalog))
|
||||||
m.HandleFunc("GET /api/sensors/query", s.auth(s.sensorQuery))
|
m.HandleFunc("GET /api/sensors/query", s.sensorAuth(s.sensorQuery))
|
||||||
m.HandleFunc("POST /api/sensors/query", s.auth(s.sensorQuery))
|
m.HandleFunc("POST /api/sensors/query", s.sensorAuth(s.sensorQuery))
|
||||||
m.HandleFunc("GET /api/sensors/{id}", s.auth(s.sensorOne))
|
m.HandleFunc("GET /api/sensors/stream", s.sensorAuth(s.sensorStream))
|
||||||
m.HandleFunc("GET /api/sensors/{id}/history", s.auth(s.sensorHistory))
|
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", s.auth(s.listServices))
|
||||||
m.HandleFunc("GET /api/services/{unit}", s.auth(s.showService))
|
m.HandleFunc("GET /api/services/{unit}", s.auth(s.showService))
|
||||||
m.HandleFunc("GET /api/services/{unit}/file", s.auth(s.serviceFile))
|
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 {
|
func (s *Server) Handler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := w.Header()
|
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("X-Frame-Options", "DENY")
|
||||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
|
}
|
||||||
h.Set("X-Content-Type-Options", "nosniff")
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
h.Set("Referrer-Policy", "no-referrer")
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
s.mux.ServeHTTP(w, r)
|
s.mux.ServeHTTP(w, r)
|
||||||
@@ -221,8 +233,10 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
var hashes []string
|
var hashes []string
|
||||||
s.cfg.View(func(c *config.Config) {
|
s.cfg.View(func(c *config.Config) {
|
||||||
for _, t := range c.Tokens {
|
for _, t := range c.Tokens {
|
||||||
|
if t.Scope == "" { // a scoped token is not a key to the machine
|
||||||
hashes = append(hashes, t.Hash)
|
hashes = append(hashes, t.Hash)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if auth.TokenMatches(strings.TrimPrefix(h, "Bearer "), hashes) {
|
if auth.TokenMatches(strings.TrimPrefix(h, "Bearer "), hashes) {
|
||||||
next(w, r)
|
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 ----
|
// ---- API tokens: how a hub authenticates to this node ----
|
||||||
|
|
||||||
// issueToken trades the password for a long-lived token, named by the caller (a hub).
|
// 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
|
var out []map[string]any
|
||||||
s.cfg.View(func(c *config.Config) {
|
s.cfg.View(func(c *config.Config) {
|
||||||
for _, t := range c.Tokens {
|
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 {
|
if out == nil {
|
||||||
@@ -621,27 +713,114 @@ func (s *Server) sensorHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, 200, h)
|
jsonOK(w, 200, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sensorQuery answers a shaped query. POST sends the shape as the body; GET builds a
|
// sensorProm is every numeric sensor in the Prometheus text format.
|
||||||
// flat one from the query string (?cpu=cpu.usage&fans=fan.*). ?full=1 returns whole
|
func (s *Server) sensorProm(w http.ResponseWriter, _ *http.Request) {
|
||||||
// sensors instead of bare values.
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
func (s *Server) sensorQuery(w http.ResponseWriter, r *http.Request) {
|
sensors.WriteProm(w, s.sensors.All())
|
||||||
full := r.URL.Query().Get("full") == "1"
|
}
|
||||||
var q json.RawMessage
|
|
||||||
|
// 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 {
|
if r.Method == http.MethodPost {
|
||||||
b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
|
b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 413, "query larger than 64 KiB")
|
return nil, full, errors.New("query larger than 64 KiB")
|
||||||
return
|
}
|
||||||
|
return b, full, nil
|
||||||
|
}
|
||||||
|
if raw := params.Get("q"); raw != "" {
|
||||||
|
return json.RawMessage(raw), full, nil
|
||||||
}
|
}
|
||||||
q = b
|
|
||||||
} else {
|
|
||||||
flat := map[string]string{}
|
flat := map[string]string{}
|
||||||
for k, v := range r.URL.Query() {
|
for k, v := range params {
|
||||||
if k != "full" && len(v) > 0 {
|
if !reservedParam[k] && len(v) > 0 {
|
||||||
flat[k] = v[0]
|
flat[k] = v[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
q, _ = json.Marshal(flat)
|
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)
|
out, err := sensors.Shape(s.sensors.All(), q, full)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -19,8 +19,13 @@ type Token struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Created time.Time `json:"created"`
|
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 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.
|
// node issued to us; the fingerprint pins its self-signed certificate.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ type Collector struct {
|
|||||||
procAt time.Time
|
procAt time.Time
|
||||||
lastTable *ProcTable
|
lastTable *ProcTable
|
||||||
upsProbe func() *UPS // nil when no UPS daemon is installed
|
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 {
|
func NewCollector() *Collector {
|
||||||
@@ -309,6 +312,28 @@ func (c *Collector) sample() {
|
|||||||
c.ring = c.ring[len(c.ring)-History:]
|
c.ring = c.ring[len(c.ring)-History:]
|
||||||
}
|
}
|
||||||
c.mu.Unlock()
|
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 }
|
type cpuTimes struct{ busy, total uint64 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,22 @@ type Historian interface {
|
|||||||
SensorHistory(id string) (s Series, ok bool)
|
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.
|
// F is a pointer to a float, for the optional bounds.
|
||||||
func F(v float64) *float64 { return &v }
|
func F(v float64) *float64 { return &v }
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sensors
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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
@@ -3,6 +3,9 @@ import { api, DashPrefs, Sample, Series, Temp } from './api'
|
|||||||
import Sparkline from './Sparkline'
|
import Sparkline from './Sparkline'
|
||||||
import { Loading, Spinner, toast } from './ui'
|
import { Loading, Spinner, toast } from './ui'
|
||||||
import { fmtMs, useRefreshMs } from './prefs'
|
import { fmtMs, useRefreshMs } from './prefs'
|
||||||
|
import { useSensorStream } from './sensors'
|
||||||
|
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
|
||||||
|
import WidgetPicker from './WidgetPicker'
|
||||||
|
|
||||||
const MAX = 300
|
const MAX = 300
|
||||||
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
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 DEFAULT_INTERVAL = 2000
|
||||||
const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
|
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.
|
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
|
||||||
function normalize(p: DashPrefs): Layout {
|
function normalize(p: DashPrefs): Layout {
|
||||||
@@ -35,9 +38,10 @@ function normalize(p: DashPrefs): Layout {
|
|||||||
const order = (p.order ?? []).filter((id) => known.has(id))
|
const order = (p.order ?? []).filter((id) => known.has(id))
|
||||||
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.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
|
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` }
|
const fmtWindow = (ms: number) => { const s = Math.round((MAX * ms) / 1000); return s >= 60 ? `${Math.round(s / 60)} min` : `${s} s` }
|
||||||
|
|
||||||
// ---- formatting ----
|
// ---- formatting ----
|
||||||
@@ -337,6 +341,10 @@ export default function Dashboard() {
|
|||||||
if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want }))
|
if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want }))
|
||||||
}, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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>
|
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
|
||||||
|
|
||||||
const edit: Edit = {
|
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>)}
|
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
|
||||||
</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 && (
|
{visible('card').length > 0 && (
|
||||||
<div className="cards">
|
<div className="cards">
|
||||||
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
|
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
|
||||||
|
|||||||
@@ -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
@@ -31,7 +31,8 @@ function Preview({ vars }: { vars: Record<string, string> }) {
|
|||||||
export default function Settings({ me }: { me: Me }) {
|
export default function Settings({ me }: { me: Me }) {
|
||||||
const [theme, setTheme] = useState(currentThemeId())
|
const [theme, setTheme] = useState(currentThemeId())
|
||||||
const [refresh, setRefresh] = useState(getRefreshMs())
|
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 [cur, setCur] = useState('')
|
||||||
const [pw1, setPw1] = useState('')
|
const [pw1, setPw1] = useState('')
|
||||||
const [pw2, setPw2] = 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>
|
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
||||||
<section className="card">
|
<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 === null && <Loading label="Loading…" />}
|
||||||
{tokens && tokens.length === 0 && <div className="empty">No tokens. No hub manages this host.</div>}
|
{tokens && tokens.length === 0 && <div className="empty">No tokens. No hub manages this host.</div>}
|
||||||
{tokens && tokens.map((t) => (
|
{tokens && tokens.map((t) => (
|
||||||
<div className="row" key={t.name}>
|
<div className="row" key={t.name}>
|
||||||
<span className="k mono">{t.name}</span>
|
<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>
|
<span className="muted small">issued {new Date(t.created).toLocaleString()}</span>
|
||||||
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
|
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<div className="dash-bar"><h2 className="section">About</h2></div>
|
<div className="dash-bar"><h2 className="section">About</h2></div>
|
||||||
|
|||||||
+2
-2
@@ -27,7 +27,7 @@ const FolderIcon = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const GROUPS: Group[] = [
|
const GROUPS: Group[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔' },
|
{ id: 'dashboard', label: 'Monitors', icon: '◔' },
|
||||||
{ id: 'procs', label: 'Processes', icon: '≡' },
|
{ id: 'procs', label: 'Processes', icon: '≡' },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
{ id: 'services', label: 'Services', 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>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<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>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import { wsURL } from './api'
|
import { wsURL } from './api'
|
||||||
|
|||||||
@@ -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
@@ -79,7 +79,7 @@ export interface Series {
|
|||||||
|
|
||||||
// Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched
|
// 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").
|
// 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 ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
|
||||||
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
|
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),
|
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),
|
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),
|
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),
|
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||||||
uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', 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),
|
saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req<void>('PUT', '/api/prefs/ui', p, true),
|
||||||
|
|||||||
+7
-5
@@ -1,17 +1,19 @@
|
|||||||
import React from 'react'
|
import React, { Suspense, lazy } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './App'
|
|
||||||
import { applyTheme, currentThemeId } from './themes'
|
import { applyTheme, currentThemeId } from './themes'
|
||||||
applyTheme(currentThemeId())
|
applyTheme(currentThemeId())
|
||||||
import '@xterm/xterm/css/xterm.css'
|
|
||||||
import './styles.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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<Suspense fallback={null}><Root /></Suspense>
|
||||||
</React.StrictMode>,
|
</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(() => {}) })
|
window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
}
|
||||||
@@ -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; }
|
.header .link, .header .icon-btn { min-height: 36px; display: inline-flex; align-items: center; }
|
||||||
.mod-tools { margin-top: 0; }
|
.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; }
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user