Files
nmon/internal/api/device_metrics_test.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

209 lines
7.2 KiB
Go

package api_test
import (
"strings"
"testing"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/store"
)
func withShipped(t *testing.T) func(*api.Deps) {
t.Helper()
set, err := profile.Load("")
if err != nil {
t.Fatal(err)
}
return func(d *api.Deps) { d.Profiles = set }
}
func lbl(dev, k, comp string) map[string]string {
m := map[string]string{"device": dev, "k": k}
if comp != "" {
m["component"] = comp
}
return m
}
// rd is a value row plus its timestamp row.
func rd(dev, metric, comp string, v float64) []vrow {
return []vrow{{lbl(dev, metric, comp), v}, {lbl(dev, "@"+metric, comp), 1_790_000_000}}
}
func TestDeviceMetricsByCategory(t *testing.T) {
e := newEnvDeps(t, 0, withShipped(t))
d := e.device(t, "srv1", "yyz", "server")
var rows []vrow
rows = append(rows, rd(d.Slug, "hrProcessorLoad", "CPU 0", 12)...)
rows = append(rows, rd(d.Slug, "hrStorageUsed", "/", 50)...)
rows = append(rows, rd(d.Slug, "hrStorageSize", "/", 100)...)
rows = append(rows, rd(d.Slug, "hrStorageAllocationUnits", "/", 1024)...)
e.q.body = vector(rows...)
var got api.MetricsResponse
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/metrics", &got)
if len(got.Categories) != 1 || got.Categories[0].Name != "health" || got.Categories[0].Label != "Health" {
t.Fatalf("categories = %+v", got.Categories)
}
groups := map[string]api.MetricGroup{}
for _, g := range got.Categories[0].Groups {
groups[g.Metric] = g
}
if _, ok := groups["hrStorageSize"]; ok {
t.Error("a denominator is listed on its own")
}
cpu := groups["hrProcessorLoad"]
if cpu.Unit != "percent" || cpu.Display != "gauge" || len(cpu.Components) != 1 || *cpu.Components[0].Value != 12 {
t.Errorf("cpu = %+v", cpu)
}
st := groups["hrStorageUsed"]
c := st.Components[0]
if st.Unit != "bytes" || st.Display != "percent" || *c.Value != 51200 || *c.Max != 102400 || *c.Percent != 50 {
t.Errorf("storage = %+v %+v", st, c)
}
if c.TS.Unix() != 1_790_000_000 {
t.Errorf("ts = %v", c.TS)
}
qs := e.q.queries()
if len(qs) != 1 || qs[0].store != "hot" || !strings.Contains(qs[0].expr, `device="`+d.Slug+`"`) {
t.Errorf("queries = %+v", qs)
}
}
func TestDeviceMetricsEmptyAndUnknown(t *testing.T) {
e := newEnvDeps(t, 0, withShipped(t))
d := e.device(t, "srv1", "yyz", "server")
var got api.MetricsResponse
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/metrics", &got)
if got.Categories == nil || len(got.Categories) != 0 {
t.Errorf("categories = %#v, want empty list", got.Categories)
}
isProblem(t, e.do("GET", "/api/v1/devices/nope/metrics", ""), 404)
}
func printerRows(dev string) []vrow {
var rows []vrow
rows = append(rows, rd(dev, "prtMarkerSuppliesLevel", "Black", 8)...)
rows = append(rows, rd(dev, "prtMarkerSuppliesMaxCapacity", "Black", 100)...)
rows = append(rows, rd(dev, "prtMarkerSuppliesLevel", "Cyan", -3)...)
rows = append(rows, rd(dev, "prtMarkerSuppliesMaxCapacity", "Cyan", 100)...)
rows = append(rows, rd(dev, "prtMarkerSuppliesLevel", "Magenta", -2)...)
rows = append(rows, rd(dev, "prtAlertSeverityLevel", "1", 3)...)
rows = append(rows, rd(dev, "prtMarkerLifeCount", "1", 4321)...)
return rows
}
func TestPrinterMetricsSentinelsAndState(t *testing.T) {
e := newEnvDeps(t, 0, withShipped(t))
d := e.device(t, "prn1", "yyz", "printer", func(d *store.Device) { d.Profile = "printer" })
e.q.body = vector(printerRows(d.Slug)...)
var got api.MetricsResponse
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/metrics", &got)
if len(got.Categories) != 1 || got.Categories[0].Name != "printer" {
t.Fatalf("categories = %+v", got.Categories)
}
by := map[string]map[string]api.MetricComponent{}
for _, g := range got.Categories[0].Groups {
by[g.Metric] = map[string]api.MetricComponent{}
for _, c := range g.Components {
by[g.Metric][c.Component] = c
}
}
lv := by["prtMarkerSuppliesLevel"]
if p := lv["Black"].Percent; p == nil || *p != 8 {
t.Errorf("black = %+v", lv["Black"])
}
if c := lv["Cyan"]; c.Value != nil || c.Text == nil || *c.Text != "some remaining" || c.Percent != nil {
t.Errorf("cyan = %+v", c)
}
if c := lv["Magenta"]; c.Text == nil || *c.Text != "unknown" {
t.Errorf("magenta = %+v", c)
}
if s := by["prtAlertSeverityLevel"]["1"].State; s == nil || *s != "crit" {
t.Errorf("alert state = %v", s)
}
if _, listed := by["prtMarkerSuppliesMaxCapacity"]; listed {
t.Error("capacity listed on its own")
}
if v := by["prtMarkerLifeCount"]["1"].Value; v == nil || *v != 4321 {
t.Errorf("pages = %v", v)
}
}
func TestPrinterFleetSortedByLowestSupply(t *testing.T) {
e := newEnvDeps(t, 0, withShipped(t))
asPrinter := func(d *store.Device) { d.Profile = "printer" }
low := e.device(t, "lowprn", "yyz", "printer", asPrinter)
full := e.device(t, "fullprn", "yyz", "printer", asPrinter)
e.device(t, "a-switch", "yyz", "switch")
var rows []vrow
rows = append(rows, printerRows(low.Slug)...)
rows = append(rows, rd(full.Slug, "prtMarkerSuppliesLevel", "Black", 90)...)
rows = append(rows, rd(full.Slug, "prtMarkerSuppliesMaxCapacity", "Black", 100)...)
e.q.body = vector(rows...)
var got api.PrinterList
e.getJSON(t, "/api/v1/printers", &got)
if got.Total != 2 || len(got.Items) != 2 || got.Items[0].Device.Slug != low.Slug {
t.Fatalf("fleet = %+v", got)
}
sup := got.Items[0].Supplies
if len(sup) != 3 || sup[0].Component != "Black" || sup[0].State != "warn" || *sup[0].Percent != 8 {
t.Errorf("supplies = %+v", sup)
}
if sup[1].Component != "Cyan" || *sup[1].Text != "some remaining" || sup[1].State != "ok" {
t.Errorf("cyan = %+v", sup[1])
}
if sup[2].State != "unknown" {
t.Errorf("magenta = %+v", sup[2])
}
if p := got.Items[0].PagesTotal; p == nil || *p != 4321 {
t.Errorf("pages = %v", p)
}
if got.Items[1].Supplies[0].State != "ok" || got.Items[0].Reachability == "" {
t.Errorf("second = %+v", got.Items[1])
}
n := 0
for _, q := range e.q.queries() {
if strings.Contains(q.expr, "prtMarkerSuppliesLevel") {
n++
}
}
if n != 1 {
t.Errorf("%d supply queries for the fleet, want 1", n)
}
}
func TestMetricGraphExpressions(t *testing.T) {
e := newEnvDeps(t, 0, withShipped(t))
d := e.device(t, "srv1", "yyz", "server")
now := e.clk.Now().Unix()
e.q.body = matrix(mrow{lbl(d.Slug, "", "/"), map[int64]float64{now - 600: 25, now - 540: 30}})
var got api.MetricGraphResponse
path := "/api/v1/devices/" + d.Slug + "/metrics/graph?metric=hrStorageUsed&component=%2F"
e.getJSON(t, path, &got)
expr := e.q.q
for _, want := range []string{`hrStorageUsed{device="` + d.Slug + `",component="/"}`, `hrStorageSize{`, `hrStorageAllocationUnits{`, `100 *`} {
if !strings.Contains(expr, want) {
t.Errorf("expr %q lacks %q", expr, want)
}
}
if got.Display != "percent" || got.Unit != "bytes" || got.StepSeconds != 60 || len(got.Series) == 0 {
t.Errorf("graph = %+v", got)
}
var withValue int
for _, p := range got.Series {
if p.V != nil {
withValue++
}
}
if withValue != 2 {
t.Errorf("%d points with values, want 2", withValue)
}
// A counter is a rate; a gauge is raw.
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/metrics/graph?metric=hrProcessorLoad&component=CPU+0", &got)
if strings.Contains(e.q.q, "rate(") || !strings.Contains(e.q.q, `hrProcessorLoad{`) {
t.Errorf("gauge expr %q", e.q.q)
}
isProblem(t, e.do("GET", "/api/v1/devices/"+d.Slug+"/metrics/graph?metric=nope", ""), 404)
}