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
209 lines
7.2 KiB
Go
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)
|
|
}
|