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
345 lines
10 KiB
Go
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
|
|
}
|