Files
nmon/internal/api/device_metrics.go
jleung e25143881c Device metrics API, printer fleet, profile presentation schema (v1.0.1 backend)
Profiles gain category/label/of/display/scale_by/sentinels/enum_state and component_attrs;
every pollable non-interface metric is annotated and an orphan test enforces it. New routes
GET /devices/{slug}/metrics, /metrics/graph and /printers; printer supply type/colour read at
discovery into device_components.attrs; printer_supply_empty alert; supply_replaced audit event;
docs/coverage.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 05:33:07 +00:00

345 lines
10 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/profile"
)
// latestWindow is how far back the latest reading of a metric may be; the slowest profile interval is 15 minutes.
const latestWindow = "1h"
var categoryLabels = map[string]string{
"system": "System", "health": "Health", "power": "Power", "environment": "Environment", "wireless": "Wireless",
"printer": "Printer", "routing": "Routing", "optics": "Optics", "firewall": "Firewall",
}
func (s *server) registerDeviceMetrics(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "get-device-metrics", Method: http.MethodGet, Path: "/api/v1/devices/{slug}/metrics", Tags: []string{"devices"},
Summary: "Latest non-interface metrics of a device, by category",
Description: "Driven by the device's profile: each pollable metric names a category, a label and a display. Values come " +
"from the hot store in one batched query. max is set for metrics that are a fraction of another (profile of), percent " +
"is computed here, sentinel values (a printer's -3) come back as text, and enum metrics carry a state. Categories " +
"with no data are left out.",
Errors: []int{http.StatusNotFound, http.StatusTooManyRequests, http.StatusServiceUnavailable},
}, s.deviceMetrics)
huma.Register(api, huma.Operation{
OperationID: "get-device-metric-graph", Method: http.MethodGet, Path: "/api/v1/devices/{slug}/metrics/graph", Tags: []string{"graph"},
Summary: "One metric component over time",
Description: "Gauges raw, counters as a per-second rate, percent displays as percent. The step rules are those of /graph; " +
"only the hot store's retention is searched.",
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity, http.StatusTooManyRequests, http.StatusServiceUnavailable},
}, s.metricGraph)
}
// reading is the latest sample of one metric component.
type reading struct {
v float64
ts time.Time
hasTS bool
}
// readings is metric -> component -> latest sample.
type readings map[string]map[string]*reading
func (r readings) at(metric, comp string) *reading { return r[metric][comp] }
// listedMetrics are the metrics a device's tabs can show: polled, not interface metrics, with a category.
func listedMetrics(res *profile.Resolved) []profile.Metric {
var out []profile.Metric
for _, m := range res.Metrics {
if m.NeedsCategory() && m.Category != "" {
out = append(out, m)
}
}
return out
}
// latestExpr is one query yielding, for every metric, its last value (k=name) and the time of that
// sample (k=@name) for the device.
func latestExpr(match string, names []string) string {
parts := make([]string, 0, 2*len(names))
for _, n := range names {
sel := n + `{` + match + `}[` + latestWindow + `]`
parts = append(parts,
`label_set(last_over_time(`+sel+`), "k", `+quote(n)+`)`,
`label_set(tlast_over_time(`+sel+`), "k", `+quote("@"+n)+`)`)
}
return strings.Join(parts, " or ")
}
// queryLatest runs the batched query and indexes the result by device.
func (s *server) queryLatest(ctx context.Context, match string, names []string) (map[string]readings, error) {
if len(names) == 0 {
return map[string]readings{}, nil
}
release, err := s.admit(ctx, false)
if err != nil {
return nil, err
}
defer release()
qctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
body, err := s.Querier.Query(qctx, "hot", latestExpr(match, names), s.Clock.Now())
if err != nil {
return nil, s.vmErr(qctx, err)
}
rows, err := parseVM(body)
if err != nil {
return nil, fmt.Errorf("metrics: %w", err)
}
return indexReadings(rows), nil
}
// indexReadings sorts result rows by device, metric and component.
func indexReadings(rows []vmSeries) map[string]readings {
all := map[string]readings{}
for _, r := range rows {
out := all[r.Metric["device"]]
if out == nil {
out = readings{}
all[r.Metric["device"]] = out
}
if r.Value == nil || !r.Value.OK {
continue
}
k := r.Metric["k"]
isTS := strings.HasPrefix(k, "@")
name := strings.TrimPrefix(k, "@")
if out[name] == nil {
out[name] = map[string]*reading{}
}
rd := out[name][r.Metric["component"]]
if rd == nil {
rd = &reading{}
out[name][r.Metric["component"]] = rd
}
if isTS {
rd.ts, rd.hasTS = time.Unix(int64(r.Value.V), 0).UTC(), true
} else {
rd.v = r.Value.V
if !rd.hasTS {
rd.ts = time.Unix(r.Value.TS, 0).UTC()
}
}
}
return all
}
func (s *server) deviceMetrics(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
}) (*struct{ Body MetricsResponse }, error) {
d, err := s.Store.GetDeviceBySlug(ctx, in.Slug)
if err != nil {
return nil, s.storeErr(err)
}
if d.PurgedAt != nil {
return nil, huma.Error404NotFound("not found")
}
out := MetricsResponse{Categories: []MetricCategory{}}
if s.Profiles == nil {
return &struct{ Body MetricsResponse }{out}, nil
}
res := s.Profiles.Get(d.Profile)
if res == nil {
return &struct{ Body MetricsResponse }{out}, nil
}
ms := listedMetrics(res)
names := make([]string, len(ms))
for i, m := range ms {
names[i] = m.Name
}
all, err := s.queryLatest(ctx, `device=`+quote(d.Slug), names)
if err != nil {
return nil, err
}
attrs, err := s.componentAttrs(ctx, d.ID)
if err != nil {
return nil, s.storeErr(err)
}
b := &metricsBuilder{res: res, rd: all[d.Slug], attrs: attrs}
out.Categories = b.categories(ms)
return &struct{ Body MetricsResponse }{out}, nil
}
// componentAttrs loads the discovery attributes of a device's rows, keyed by table and label.
func (s *server) componentAttrs(ctx context.Context, deviceID int64) (map[string]map[string]string, error) {
rows, err := s.Store.ListComponents(ctx, deviceID)
if err != nil {
return nil, err
}
out := map[string]map[string]string{}
for _, c := range rows {
if c.Attrs == "" || c.Attrs == "{}" {
continue
}
var m map[string]string
if json.Unmarshal([]byte(c.Attrs), &m) == nil && len(m) > 0 {
out[attrKey(c.TableName, c.Label)] = m
}
}
return out, nil
}
func attrKey(table, label string) string { return table + "\x00" + label }
// metricsBuilder turns readings into the response, using the profile's presentation fields.
type metricsBuilder struct {
res *profile.Resolved
rd readings
attrs map[string]map[string]string
}
func (b *metricsBuilder) categories(ms []profile.Metric) []MetricCategory {
byCat := map[string][]MetricGroup{}
for _, m := range ms {
if b.res.Referenced(m.Name) {
continue // a denominator is shown through the metric that refers to it
}
if g, ok := b.group(m); ok {
byCat[m.Category] = append(byCat[m.Category], g)
}
}
out := []MetricCategory{}
for _, c := range profile.Categories {
if gs := byCat[c]; len(gs) > 0 {
out = append(out, MetricCategory{Name: c, Label: categoryLabels[c], Groups: gs})
}
}
return out
}
func (b *metricsBuilder) group(m profile.Metric) (MetricGroup, bool) {
g := MetricGroup{Metric: m.Name, Label: m.Title(), Unit: b.unitOf(m), Display: m.EffectiveDisplay(), Components: []MetricComponent{}}
for comp, rd := range b.rd[m.Name] {
g.Components = append(g.Components, b.component(m, comp, rd))
}
sort.Slice(g.Components, func(i, j int) bool { return g.Components[i].Component < g.Components[j].Component })
return g, len(g.Components) > 0
}
// unitOf is the metric's unit, or the scale_by metric's when the value is multiplied by it.
func (b *metricsBuilder) unitOf(m profile.Metric) string {
if m.ScaleBy != "" {
if sb, ok := b.res.Metric(m.ScaleBy); ok {
return sb.Unit
}
}
return m.Unit
}
// scaled is the metric's value at comp after scale_by.
func (b *metricsBuilder) scaled(m profile.Metric, comp string) (float64, bool) {
rd := b.rd.at(m.Name, comp)
if rd == nil {
return 0, false
}
if m.ScaleBy == "" {
return rd.v, true
}
if f := b.rd.at(m.ScaleBy, comp); f != nil {
return rd.v * f.v, true
}
return 0, false
}
func (b *metricsBuilder) component(m profile.Metric, comp string, rd *reading) MetricComponent {
c := MetricComponent{Component: comp, TS: rd.ts}
if words, ok := sentinelText(m, rd.v); ok {
c.Text = &words
} else if v, ok := b.scaled(m, comp); ok && !math.IsNaN(v) {
c.Value = &v
}
b.fillMax(&c, m)
b.fillState(&c, m, rd.v)
if m.EffectiveDisplay() == "text" && c.Text == nil && c.Value != nil {
t := strconv.FormatFloat(*c.Value, 'f', -1, 64)
c.Text = &t
}
c.Attrs = b.attrs[attrKey(m.TableKey(), comp)]
return c
}
// sentinelText is the profile's words for a raw value like -3.
func sentinelText(m profile.Metric, v float64) (string, bool) {
if v != math.Trunc(v) {
return "", false
}
t, ok := m.Sentinels[int(v)]
return t, ok
}
// fillMax sets max and percent for a metric that is a fraction of another.
func (b *metricsBuilder) fillMax(c *MetricComponent, m profile.Metric) {
if m.Of == "" {
return
}
om, ok := b.res.Metric(m.Of)
if !ok {
return
}
mx, ok := b.scaled(om, c.Component)
if !ok {
return
}
c.Max = &mx
if c.Value != nil && mx > 0 && *c.Value >= 0 {
p := 100 * *c.Value / mx
c.Percent = &p
}
}
func (b *metricsBuilder) fillState(c *MetricComponent, m profile.Metric, raw float64) {
if m.Type != profile.Status {
return
}
name := m.Enum[int(raw)]
st := stateOf(m, name)
c.State = &st
}
var (
critWords = []string{"crit", "fail", "fault", "depleted", "error", "down"}
warnWords = []string{"warn", "low", "degrad", "bypass", "test"}
unknownWords = []string{"unknown", "other", "notpresent"}
)
// stateOf maps an enum name to ok, warn, crit or unknown: the profile's enum_state, else by name.
func stateOf(m profile.Metric, name string) string {
if st, ok := m.EnumState[name]; ok {
return st
}
l := strings.ToLower(name)
switch {
case name == "":
return "unknown"
case containsAny(l, unknownWords):
return "unknown"
case containsAny(l, critWords):
return "crit"
case containsAny(l, warnWords):
return "warn"
}
return "ok"
}
func containsAny(s string, words []string) bool {
for _, w := range words {
if strings.Contains(s, w) {
return true
}
}
return false
}