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
@@ -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)
|
||||
}
|
||||
|
||||
// Ticker is a Provider that can say when its values have changed.
|
||||
type Ticker interface {
|
||||
Subscribe() (tick <-chan struct{}, cancel func())
|
||||
}
|
||||
|
||||
// Subscribe follows the first provider that ticks. ok is false when none does.
|
||||
func (r *Registry) Subscribe() (tick <-chan struct{}, cancel func(), ok bool) {
|
||||
for _, p := range r.providers {
|
||||
if t, is := p.(Ticker); is {
|
||||
tick, cancel = t.Subscribe()
|
||||
return tick, cancel, true
|
||||
}
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
// F is a pointer to a float, for the optional bounds.
|
||||
func F(v float64) *float64 { return &v }
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package sensors
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -51,3 +52,26 @@ func TestShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProm(t *testing.T) {
|
||||
var b strings.Builder
|
||||
WriteProm(&b, []Sensor{
|
||||
{ID: "cpu.usage", Kind: "usage", Unit: "%", Value: 12.5},
|
||||
{ID: "ups.runtime", Kind: "duration", Unit: "min", Value: 2.0},
|
||||
{ID: "net.eth0.up", Kind: "state", Value: true, Labels: map[string]string{"iface": "eth0"}},
|
||||
{ID: "ups.state", Kind: "state", Value: `Nor"mal`},
|
||||
{ID: "updates.pending", Kind: "count", Value: 3.0},
|
||||
})
|
||||
got := b.String()
|
||||
for _, want := range []string{
|
||||
"# TYPE hamlab_usage_percent gauge\n" + `hamlab_usage_percent{id="cpu.usage"} 12.5`,
|
||||
`hamlab_duration_seconds{id="ups.runtime"} 120`,
|
||||
`hamlab_state{id="net.eth0.up",iface="eth0"} 1`,
|
||||
`hamlab_state{id="ups.state",value="Nor\"mal"} 1`,
|
||||
`hamlab_updates_pending{id="updates.pending"} 3`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user