Files
hamlab/internal/metrics/sensors.go
T
Mitchell-JohnsonandClaude Fable 5.1 43bf787696 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>
2026-09-19 07:04:03 -07:00

170 lines
6.6 KiB
Go

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
}