Sensors: one standard read interface — catalog, single value, history, shaped query

Every number the collector samples becomes a sensor with a stable dotted ID
(cpu.core.3.usage, fan.nct6775.fan2, fs.root.used_pct). Clients discover them
from the catalog and ask for exactly what they want, including as a shaped
query: send the object you want, get it back with the values filled in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 07:04:03 -07:00
co-authored by Claude Fable 5.1
parent 01aeb226ea
commit 43bf787696
8 changed files with 673 additions and 3 deletions
+84 -1
View File
@@ -34,6 +34,7 @@ import (
"hamlab/internal/metrics"
"hamlab/internal/nodes"
"hamlab/internal/priv"
"hamlab/internal/sensors"
"hamlab/internal/services"
"hamlab/internal/setup"
"hamlab/internal/storage"
@@ -54,6 +55,7 @@ type Server struct {
limiter auth.Limiter
mgr *term.Manager
metrics *metrics.Collector
sensors *sensors.Registry
store *store.Store
svc *services.Manager
cron *cron.Manager
@@ -74,7 +76,7 @@ type Server struct {
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server {
host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")}
if u, err := user.Current(); err == nil && u.Username != "" {
s.user = u.Username
@@ -109,6 +111,11 @@ 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/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))
@@ -568,6 +575,82 @@ func (s *Server) metricsHistory(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, s.metrics.History())
}
// ---- sensors: the standard read interface (SENSORS.md) ----
// sensorCatalog lists every sensor with its current value; ?ids= narrows it with
// comma-separated IDs or globs.
func (s *Server) sensorCatalog(w http.ResponseWriter, r *http.Request) {
all := s.sensors.All()
if ids := r.URL.Query().Get("ids"); ids != "" {
picked := []sensors.Sensor{}
for _, sel := range strings.Split(ids, ",") {
got, err := sensors.Select(all, strings.TrimSpace(sel))
if err != nil {
jsonErr(w, 400, err.Error())
return
}
picked = append(picked, got...)
}
all = picked
}
jsonOK(w, 200, map[string]any{"sensors": all})
}
// sensorOne is a single sensor; ?format=text is the bare value, for shell scripts and
// anything else that can only fetch a URL and read a number.
func (s *Server) sensorOne(w http.ResponseWriter, r *http.Request) {
got, _ := sensors.Select(s.sensors.All(), r.PathValue("id"))
if len(got) != 1 {
jsonErr(w, 404, "no such sensor")
return
}
if r.URL.Query().Get("format") == "text" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, got[0].Value)
return
}
jsonOK(w, 200, got[0])
}
func (s *Server) sensorHistory(w http.ResponseWriter, r *http.Request) {
h, ok := s.sensors.History(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no history for that sensor")
return
}
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
if r.Method == http.MethodPost {
b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
if err != nil {
jsonErr(w, 413, "query larger than 64 KiB")
return
}
q = b
} else {
flat := map[string]string{}
for k, v := range r.URL.Query() {
if k != "full" && len(v) > 0 {
flat[k] = v[0]
}
}
q, _ = json.Marshal(flat)
}
out, err := sensors.Shape(s.sensors.All(), q, full)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
jsonOK(w, 200, out)
}
// ---- services (systemd) ----
func (s *Server) listServices(w http.ResponseWriter, r *http.Request) {
+4 -2
View File
@@ -132,6 +132,8 @@ type Sample struct {
FS []FS `json:"fs"`
UPS *UPS `json:"ups"`
Uptime float64 `json:"uptime"`
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
}
// Series is the compact history the UI seeds its sparklines from.
@@ -292,11 +294,11 @@ func (c *Collector) sample() {
if c.lastFS == nil || now.Sub(c.fsAt) >= fsEvery {
c.lastFS, c.fsAt = filesystems(), now
}
s.FS = c.lastFS
s.FS, s.fsT = c.lastFS, c.fsAt.UnixMilli()
if c.upsProbe != nil && (c.upsAt.IsZero() || now.Sub(c.upsAt) >= upsEvery) {
c.lastUPS, c.upsAt = c.upsProbe(), now
}
s.UPS = c.lastUPS
s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli()
if first {
return // deltas are meaningless on the priming pass
+169
View File
@@ -0,0 +1,169 @@
package metrics
import (
"fmt"
"strconv"
"hamlab/internal/sensors"
)
// The collector as a sensors.Provider: every number in a Sample, one sensor each. The
// IDs are built from names the hardware reports (chip, label, interface, device, mount),
// never from enumeration order like hwmon3, which moves between boots.
// flattener turns one Sample into sensors. With only set it builds just that one, so
// walking the whole ring for a history costs string compares, not allocations.
type flattener struct {
only string
seen map[string]int
out []sensors.Sensor
}
type bounds struct{ min, max, warn, crit *float64 }
var pct = bounds{min: sensors.F(0), max: sensors.F(100)}
func upTo(max float64) bounds { return bounds{min: sensors.F(0), max: sensors.F(max)} }
func (f *flattener) add(id, name, kind, unit string, v any, t int64, b bounds, labels ...string) {
f.seen[id]++
if n := f.seen[id]; n > 1 { // two identical chips: the second is coretemp_2
id = fmt.Sprintf("%s_%d", id, n)
}
if f.only != "" && id != f.only {
return
}
s := sensors.Sensor{ID: id, Name: name, Kind: kind, Unit: unit, Value: v, T: t, Min: b.min, Max: b.max, Warn: b.warn, Crit: b.crit}
if len(labels) > 0 {
s.Labels = make(map[string]string, len(labels)/2)
for i := 0; i+1 < len(labels); i += 2 {
s.Labels[labels[i]] = labels[i+1]
}
}
f.out = append(f.out, s)
}
func ratio(part, whole uint64) float64 {
if whole == 0 {
return 0
}
return round1(float64(part) / float64(whole) * 100)
}
func flatten(s Sample, only string) []sensors.Sensor {
f := &flattener{only: only, seen: map[string]int{}}
t, none := s.T, bounds{}
f.add("cpu.usage", "CPU", "usage", "%", s.CPU, t, pct)
for i, c := range s.Cores {
n := strconv.Itoa(i)
f.add("cpu.core."+n+".usage", "Core "+n, "usage", "%", c, t, pct, "core", n)
}
if c := cpuTemp(s.Temps); c > 0 {
f.add("cpu.temp", "CPU temperature", "temperature", "°C", c, t, none)
}
for i, w := range []string{"1", "5", "15"} {
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
}
f.add("uptime", "Uptime", "duration", "s", s.Uptime, t, none)
for _, m := range []struct {
id, name string
v Mem
}{{"mem", "Memory", s.Mem}, {"swap", "Swap", s.Swap}} {
f.add(m.id+".total", m.name+" total", "bytes", "B", float64(m.v.Total), t, none)
f.add(m.id+".used", m.name+" used", "bytes", "B", float64(m.v.Used), t, upTo(float64(m.v.Total)))
f.add(m.id+".avail", m.name+" available", "bytes", "B", float64(m.v.Avail), t, upTo(float64(m.v.Total)))
f.add(m.id+".used_pct", m.name+" used", "usage", "%", ratio(m.v.Used, m.v.Total), t, pct)
}
f.add("mem.free", "Memory free", "bytes", "B", float64(s.Mem.Free), t, upTo(float64(s.Mem.Total)))
f.add("mem.cached", "Memory cached", "bytes", "B", float64(s.Mem.Cached), t, upTo(float64(s.Mem.Total)))
f.add("mem.buffers", "Memory buffers", "bytes", "B", float64(s.Mem.Buffers), t, upTo(float64(s.Mem.Total)))
for _, x := range s.Temps {
b := bounds{}
if x.Max > 0 {
b.warn = sensors.F(x.Max)
}
if x.Crit > 0 {
b.crit = sensors.F(x.Crit)
}
f.add(sensors.ID("temp", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "temperature", "°C", x.C, t, b, "chip", x.Chip, "label", x.Label)
}
for _, x := range s.Fans {
f.add(sensors.ID("fan", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "fan", "rpm", float64(x.RPM), t, none, "chip", x.Chip, "label", x.Label)
}
for _, x := range s.Net {
p := "net." + sensors.Seg(x.Iface)
f.add(p+".rx", x.Iface+" receive", "rate", "B/s", x.RxBps, t, none, "iface", x.Iface)
f.add(p+".tx", x.Iface+" transmit", "rate", "B/s", x.TxBps, t, none, "iface", x.Iface)
f.add(p+".rx_total", x.Iface+" received", "bytes", "B", float64(x.RxTotal), t, none, "iface", x.Iface)
f.add(p+".tx_total", x.Iface+" transmitted", "bytes", "B", float64(x.TxTotal), t, none, "iface", x.Iface)
f.add(p+".up", x.Iface+" link", "state", "", x.Up, t, none, "iface", x.Iface)
}
for _, x := range s.Disks {
p := "disk." + sensors.Seg(x.Name)
f.add(p+".read", x.Name+" read", "rate", "B/s", x.ReadBps, t, none, "device", x.Name, "model", x.Model)
f.add(p+".write", x.Name+" write", "rate", "B/s", x.WriteBps, t, none, "device", x.Name, "model", x.Model)
f.add(p+".util", x.Name+" busy", "usage", "%", x.UtilPct, t, pct, "device", x.Name, "model", x.Model)
f.add(p+".size", x.Name+" size", "bytes", "B", float64(x.Size), t, none, "device", x.Name, "model", x.Model)
if x.Temp > 0 {
f.add(p+".temp", x.Name+" temperature", "temperature", "°C", x.Temp, t, none, "device", x.Name, "model", x.Model)
}
}
for _, x := range s.FS {
p := "fs." + sensors.Seg(x.Mount)
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, "mount", x.Mount, "fstype", x.Type)
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), "mount", x.Mount, "fstype", x.Type)
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, "mount", x.Mount, "fstype", x.Type)
}
if u := s.UPS; u != nil {
ut := s.upsT
f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model)
f.add("ups.runtime", "UPS runtime", "duration", "min", u.RuntimeMin, ut, none, "model", u.Model)
f.add("ups.load", "UPS load", "power", "W", u.LoadW, ut, none, "model", u.Model)
f.add("ups.load_pct", "UPS load", "usage", "%", u.LoadPct, ut, pct, "model", u.Model)
f.add("ups.in_v", "UPS input", "voltage", "V", u.InV, ut, none, "model", u.Model)
f.add("ups.out_v", "UPS output", "voltage", "V", u.OutV, ut, none, "model", u.Model)
f.add("ups.on_battery", "UPS on battery", "state", "", u.OnBattery, ut, none, "model", u.Model)
f.add("ups.state", "UPS state", "state", "", u.State, ut, none, "model", u.Model)
f.add("ups.source", "UPS power source", "state", "", u.Source, ut, none, "model", u.Model)
}
return f.out
}
// Sensors implements sensors.Provider.
func (c *Collector) Sensors() []sensors.Sensor {
s, ok := c.Latest()
if !ok {
return nil
}
return flatten(s, "")
}
// SensorHistory implements sensors.Historian: the ring, seen through one sensor. Samples
// where the sensor didn't exist (a disk plugged in since) are left out, not zero-filled.
func (c *Collector) SensorHistory(id string) (sensors.Series, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
out := sensors.Series{ID: id, T: []int64{}, V: []float64{}}
found := false
for _, smp := range c.ring {
got := flatten(smp, id)
if len(got) == 0 {
continue
}
found = true
v, ok := got[0].Value.(float64)
if !ok {
return sensors.Series{}, false // states have no curve
}
out.Unit = got[0].Unit
out.T, out.V = append(out.T, smp.T), append(out.V, v)
}
return out, found
}
+50
View File
@@ -0,0 +1,50 @@
package metrics
import "testing"
func TestFlatten(t *testing.T) {
s := Sample{T: 1000, CPU: 10, Cores: []float64{5, 15}, Mem: Mem{Total: 200, Used: 50},
Temps: []Temp{{Chip: "nvme", Label: "Composite", C: 40, Crit: 85}, {Chip: "nvme", Label: "Composite", C: 45}},
Fans: []Fan{{Chip: "nct6775", Label: "fan2", RPM: 1100}},
FS: []FS{{Mount: "/", Size: 100, Used: 25}}, fsT: 900}
got := map[string]any{}
for _, x := range flatten(s, "") {
if _, dup := got[x.ID]; dup {
t.Errorf("duplicate id %s", x.ID)
}
got[x.ID] = x.Value
}
for id, want := range map[string]any{"cpu.usage": 10.0, "cpu.core.1.usage": 15.0, "mem.used_pct": 25.0, "temp.nvme.composite": 40.0,
"temp.nvme.composite_2": 45.0, "fan.nct6775.fan2": 1100.0, "fs.root.used_pct": 25.0} {
if got[id] != want {
t.Errorf("%s = %v, want %v", id, got[id], want)
}
}
one := flatten(s, "temp.nvme.composite_2")
if len(one) != 1 || one[0].Value != 45.0 {
t.Errorf("only: got %+v", one)
}
for _, x := range flatten(s, "fs.root.used") {
if x.T != 900 {
t.Errorf("fs sensor should carry the fs read time, got %d", x.T)
}
}
}
func TestSensorHistory(t *testing.T) {
c := NewCollector()
c.ring = []Sample{{T: 1, CPU: 10}, {T: 2, CPU: 20, Net: []Net{{Iface: "eth0", Up: true, RxBps: 5}}}}
h, ok := c.SensorHistory("cpu.usage")
if !ok || len(h.V) != 2 || h.V[1] != 20 || h.Unit != "%" {
t.Fatalf("cpu: %+v %v", h, ok)
}
if h, ok := c.SensorHistory("net.eth0.rx"); !ok || len(h.V) != 1 || h.T[0] != 2 {
t.Fatalf("net: a sensor that appeared later has a shorter history, got %+v %v", h, ok)
}
if _, ok := c.SensorHistory("net.eth0.up"); ok {
t.Error("a state has no history")
}
if _, ok := c.SensorHistory("nope"); ok {
t.Error("unknown id")
}
}
+217
View File
@@ -0,0 +1,217 @@
// Package sensors is the one standard way anything reads what Ham Lab measures.
//
// Every measurable thing is a Sensor: a stable dotted ID ("cpu.core.3.usage",
// "fan.nct6775.fan2", "fs.root.used_pct"), a little metadata, and a current value. A
// consumer discovers them from the catalog, then asks for exactly the ones it wants —
// one by ID, many by glob, or as a shaped query where the client sends the object it
// wants and gets it back with the values filled in. See SENSORS.md.
//
// Sources of sensors are Providers. The registry only joins them; it measures nothing.
package sensors
import (
"encoding/json"
"errors"
"fmt"
"path"
"sort"
"strings"
)
// Sensor is one measurable thing and its reading. Value is a float64, bool or string.
type Sensor struct {
ID string `json:"id"`
Name string `json:"name"` // human label
Kind string `json:"kind"` // usage | load | temperature | fan | rate | bytes | duration | voltage | power | state
Unit string `json:"unit,omitempty"` // % · °C · rpm · B/s · B · s · min · V · W · MHz
Value any `json:"value"`
T int64 `json:"t"` // unix ms the value was read — slow sensors are older than the tick
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
Warn *float64 `json:"warn,omitempty"` // the hardware's own thresholds, where it states them
Crit *float64 `json:"crit,omitempty"`
Labels map[string]string `json:"labels,omitempty"` // the real names behind the ID: chip, iface, mount…
}
// Series is a numeric sensor's recent past.
type Series struct {
ID string `json:"id"`
Unit string `json:"unit,omitempty"`
T []int64 `json:"t"`
V []float64 `json:"v"`
}
// Provider is a source of sensors. Sensors returns every sensor it has, with current values.
type Provider interface {
Sensors() []Sensor
}
// Historian is a Provider that also remembers. ok is false for an ID it doesn't hold.
type Historian interface {
SensorHistory(id string) (s Series, ok bool)
}
// F is a pointer to a float, for the optional bounds.
func F(v float64) *float64 { return &v }
// Seg makes one ID segment from a real-world name: lower case, [a-z0-9_-] only, so IDs
// survive URLs, query strings and templating languages untouched. "Package id 0" →
// "package_id_0", "/mnt/tank" → "mnt_tank", "/" → "root".
func Seg(name string) string {
if name == "/" {
return "root"
}
var b strings.Builder
gap := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
if gap && b.Len() > 0 {
b.WriteByte('_')
}
gap = false
b.WriteRune(r)
default:
gap = true
}
}
if b.Len() == 0 {
return "unnamed"
}
return b.String()
}
// ID joins already-safe segments.
func ID(segs ...string) string { return strings.Join(segs, ".") }
// Registry joins providers into one namespace.
type Registry struct{ providers []Provider }
func NewRegistry(p ...Provider) *Registry { return &Registry{providers: p} }
func (r *Registry) Add(p Provider) { r.providers = append(r.providers, p) }
// All returns every sensor, sorted by ID. Two sensors that would share an ID (two
// identical chips) get "_2", "_3" on the colliding one, in the provider's order.
func (r *Registry) All() []Sensor {
var out []Sensor
seen := map[string]int{}
for _, p := range r.providers {
for _, s := range p.Sensors() {
seen[s.ID]++
if n := seen[s.ID]; n > 1 {
s.ID = fmt.Sprintf("%s_%d", s.ID, n)
}
out = append(out, s)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// History finds the provider that remembers id.
func (r *Registry) History(id string) (Series, bool) {
for _, p := range r.providers {
if h, ok := p.(Historian); ok {
if s, ok := h.SensorHistory(id); ok {
return s, true
}
}
}
return Series{}, false
}
// IsGlob reports whether a selector names many sensors rather than one.
func IsGlob(sel string) bool { return strings.ContainsAny(sel, "*?[") }
// Select returns the sensors a selector names: an exact ID, or a glob where * crosses
// dots ("fan.*" is every fan, "cpu.core.*.usage" every core).
func Select(all []Sensor, sel string) ([]Sensor, error) {
if !IsGlob(sel) {
for _, s := range all {
if s.ID == sel {
return []Sensor{s}, nil
}
}
return nil, nil
}
if _, err := path.Match(sel, ""); err != nil {
return nil, fmt.Errorf("bad pattern %q", sel)
}
var out []Sensor
for _, s := range all {
if ok, _ := path.Match(sel, s.ID); ok {
out = append(out, s)
}
}
return out, nil
}
// Shape answers a shaped query: the client sends the object it wants, with selectors
// where the values go, and gets the same object back with them filled in.
//
// {"cpu": "cpu.usage", "fans": "fan.*", "box": {"up": "uptime"}}
// → {"cpu": 12.4, "fans": {"fan.nct6775.fan1": 820}, "box": {"up": 80520}}
//
// An exact ID becomes its value (null if there is no such sensor here — machines differ,
// and a dashboard shouldn't fail because one has no fans). A glob becomes an object keyed
// by ID. Objects and arrays are walked. With full, every value is the whole Sensor.
func Shape(all []Sensor, query json.RawMessage, full bool) (any, error) {
var q any
if err := json.Unmarshal(query, &q); err != nil {
return nil, errors.New("query is not valid JSON")
}
return shape(all, q, full, 0)
}
func shape(all []Sensor, q any, full bool, depth int) (any, error) {
if depth > 16 {
return nil, errors.New("query nested too deep")
}
leaf := func(s Sensor) any {
if full {
return s
}
return s.Value
}
switch v := q.(type) {
case string:
got, err := Select(all, v)
if err != nil {
return nil, err
}
if !IsGlob(v) {
if len(got) == 0 {
return nil, nil
}
return leaf(got[0]), nil
}
m := make(map[string]any, len(got))
for _, s := range got {
m[s.ID] = leaf(s)
}
return m, nil
case map[string]any:
m := make(map[string]any, len(v))
for k, sub := range v {
r, err := shape(all, sub, full, depth+1)
if err != nil {
return nil, err
}
m[k] = r
}
return m, nil
case []any:
a := make([]any, len(v))
for i, sub := range v {
r, err := shape(all, sub, full, depth+1)
if err != nil {
return nil, err
}
a[i] = r
}
return a, nil
default:
return nil, fmt.Errorf("a query holds sensor IDs, objects and arrays; got %v", v)
}
}
+53
View File
@@ -0,0 +1,53 @@
package sensors
import (
"encoding/json"
"reflect"
"testing"
)
type fake []Sensor
func (f fake) Sensors() []Sensor { return f }
func TestSeg(t *testing.T) {
for in, want := range map[string]string{"/": "root", "/mnt/tank": "mnt_tank", "Package id 0": "package_id_0", "enp3s0": "enp3s0", " ": "unnamed", "Tctl+": "tctl", "nvme0n1": "nvme0n1"} {
if got := Seg(in); got != want {
t.Errorf("Seg(%q) = %q, want %q", in, got, want)
}
}
}
func TestRegistryDedupes(t *testing.T) {
r := NewRegistry(fake{{ID: "temp.nvme.composite", Value: 40.0}}, fake{{ID: "temp.nvme.composite", Value: 50.0}})
all := r.All()
if len(all) != 2 || all[0].ID != "temp.nvme.composite" || all[1].ID != "temp.nvme.composite_2" {
t.Fatalf("got %+v", all)
}
}
func TestShape(t *testing.T) {
all := []Sensor{{ID: "cpu.usage", Value: 12.5, Unit: "%"}, {ID: "fan.a.fan1", Value: 800.0}, {ID: "fan.a.fan2", Value: 900.0}, {ID: "net.eth0.up", Value: true}}
got, err := Shape(all, json.RawMessage(`{"cpu":"cpu.usage","fans":"fan.*","box":{"link":"net.eth0.up","gone":"ups.battery"},"list":["cpu.usage"]}`), false)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"cpu": 12.5,
"fans": map[string]any{"fan.a.fan1": 800.0, "fan.a.fan2": 900.0},
"box": map[string]any{"link": true, "gone": nil},
"list": []any{12.5},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v\nwant %#v", got, want)
}
full, _ := Shape(all, json.RawMessage(`"cpu.usage"`), true)
if s, ok := full.(Sensor); !ok || s.Unit != "%" {
t.Fatalf("full: got %#v", full)
}
for _, bad := range []string{`{"x": 5}`, `{"x": "fan.["}`, `not json`} {
if _, err := Shape(all, json.RawMessage(bad), false); err == nil {
t.Errorf("Shape(%s) should fail", bad)
}
}
}