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:
co-authored by
Claude Fable 5.1
parent
01aeb226ea
commit
43bf787696
+84
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user