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
+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)
}
}
}