Files
nmon/internal/api/graph_test.go
Vibe Coder 26fdcdc124
Some checks failed
ci / lint (push) Successful in 1m25s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 3m32s
ci / test (push) Failing after 17m53s
Match LibreNMS port graphs: totals, max, aggregate 95th, compare, utilisation
Operators coming from LibreNMS expect the same figures under a port
graph. Add them:

- Totals: bytes transferred in and out over the selected range
  (GraphResponse.totals), integrated from the average rate across the
  stitched raw and hourly tiers without double counting; partial
  coverage is flagged.
- Summaries read Now / Avg / Max / 95th / Total per direction, plus an
  aggregate (in+out) 95th (p95.aggregate_bps, hot store only).
- Utilisation as a percentage of port speed, and a "% of speed" axis.
- "Compare previous period" overlay on the graph view and expanded row.
- Errors and discards graphs (GET /api/v1/graph/counters), hot-store
  range only since they have no rollups. Packet and broadcast/multicast
  graphs stay disabled: those counters are not polled (DEVIATIONS 56).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-25 21:02:32 +00:00

468 lines
18 KiB
Go

package api_test
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/url"
"sort"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/vmsup"
)
// graphVM fakes the two stores. hot and long say whether a store has a sample
// at ts; the value is a function of direction and time so tests can check which store answered.
type graphVM struct {
hot, long func(ts int64) bool
hotVal func(dir string, ts int64) float64
longVal func(dir string, ts int64) float64
longErr error
}
func dirOf(expr string) string {
if strings.Contains(expr, `dir="in"`) || strings.Contains(expr, "InOctets") {
return "in"
}
return "out"
}
func (g *graphVM) fn(storeName, expr string, from, to time.Time, step time.Duration) ([]byte, error) {
has, val := g.hot, g.hotVal
if storeName == "long" {
if g.longErr != nil {
return nil, g.longErr
}
has, val = g.long, g.longVal
}
dir := dirOf(expr)
vals := map[int64]float64{}
for ts := from.Unix(); ts <= to.Unix(); ts += int64(step / time.Second) {
if has == nil || has(ts) {
vals[ts] = val(dir, ts)
}
}
if len(vals) == 0 {
return emptyMatrix, nil
}
return matrix(mrow{labels: map[string]string{"dir": dir}, vals: vals}), nil
}
func rising(dir string, ts int64) float64 {
if dir == "in" {
return 1e6 + float64(ts%100000)
}
return 2e6 + float64(ts%100000)
}
func newGraphEnv(t *testing.T, vm *graphVM, mut ...func(*store.Interface)) (*env, store.Interface) {
e := newEnv(t, 0)
e.q.fn = vm.fn
d := e.device(t, "Core", "", "")
return e, e.iface(t, d, 1, "xe-0/0/1", "uplink", mut...)
}
func iso(ts time.Time) string { return ts.UTC().Format(time.RFC3339) }
func graphURL(id string, from, to time.Time, extra string) string {
return "/api/v1/graph?iface=" + id + "&from=" + url.QueryEscape(iso(from)) + "&to=" + url.QueryEscape(iso(to)) + extra
}
func TestGraphRawTierWithinHotRetention(t *testing.T) {
vm := &graphVM{hotVal: rising}
e, i := newGraphEnv(t, vm)
var g api.GraphResponse
rec := e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, ""), &g)
assertNoLeaks(t, "graph", rec.Body.Bytes())
if g.Tier != "raw" || g.StepSeconds != 60 || g.StitchedAt != nil || len(g.Tiers) != 0 || g.Unreliable {
t.Fatalf("raw graph: %+v", g)
}
if g.Partial == nil || len(g.Partial) != 0 {
t.Errorf("partial must be [] when complete: %v", g.Partial)
}
if g.Totals == nil || g.Totals.InBytes == nil || g.Totals.OutBytes == nil || *g.Totals.InBytes <= 0 ||
g.Totals.CoveredSeconds != 3600 || g.Totals.RangeSeconds != 3600 {
t.Errorf("totals: %+v", g.Totals)
}
if g.Iface.ID != i.ID || g.Iface.IfName != "xe-0/0/1" || g.Iface.IfAlias != "uplink" || g.Iface.SpeedBPS != 1_000_000_000 || g.Iface.Device.Name != "Core" {
t.Errorf("iface: %+v", g.Iface)
}
if len(g.Series.In) != 61 || len(g.Series.Out) != 61 || g.Series.InMax != nil {
t.Fatalf("series: %d in, %d out", len(g.Series.In), len(g.Series.Out))
}
first, last := g.Series.In[0], g.Series.In[60]
if first.TS != t0.Add(-time.Hour).Unix() || last.TS != t0.Unix() || first.V == nil || *first.V != rising("in", first.TS) {
t.Errorf("first %+v last %+v", first, last)
}
if *g.Series.Out[10].V != rising("out", g.Series.Out[10].TS) {
t.Errorf("out series %+v", g.Series.Out[10])
}
qs := e.q.queries()
if len(qs) != 2 || qs[0].store != "hot" || qs[0].step != time.Minute ||
qs[0].expr != `rate(ifHCInOctets{iface="`+i.ID+`"}[600s])*8` || !strings.Contains(qs[1].expr, "ifHCOutOctets") {
t.Errorf("queries: %+v", qs)
}
var raw map[string]json.RawMessage
_ = json.Unmarshal(rec.Body.Bytes(), &raw)
for _, k := range []string{"stitched_at", "tiers", "unreliable_reason", "p95"} {
if _, ok := raw[k]; ok {
t.Errorf("%s must be omitted from a plain raw graph", k)
}
}
if !strings.HasPrefix(string(raw["series"]), `{"in":[[`) {
t.Errorf("points must be [ts,value] pairs: %.60s", raw["series"])
}
}
func TestGraphUses32BitCountersWhenNoHC(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising}, func(i *store.Interface) { i.HasHCCounters = false })
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, ""), &g)
if q := e.q.queries()[0].expr; !strings.Contains(q, "ifInOctets{") || strings.Contains(q, "HC") {
t.Errorf("32-bit query: %s", q)
}
}
func TestGraphStepDefaultsAndLimits(t *testing.T) {
for _, tc := range []struct {
rng time.Duration
step int64
}{{time.Hour, 60}, {24 * time.Hour, 120}, {7 * 24 * time.Hour, 660}, {30 * 24 * time.Hour, 2640}} {
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-tc.rng), t0, ""), &g)
if g.StepSeconds != tc.step || g.Tier != "raw" || g.StitchedAt != nil {
t.Errorf("range %s: step %d tier %s stitched %v, want step %d raw", tc.rng, g.StepSeconds, g.Tier, g.StitchedAt, tc.step)
}
if n := len(g.Series.In); n < 700 && tc.rng >= 24*time.Hour || n > 1001 {
t.Errorf("range %s: %d points", tc.rng, n)
}
for _, p := range g.Series.In {
if p.TS%g.StepSeconds != 0 {
t.Fatalf("range %s: sample %d is not aligned to the %d s step", tc.rng, p.TS, g.StepSeconds)
}
}
}
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, "&step=300"), &g)
if g.StepSeconds != 300 || len(g.Series.In) != 13 {
t.Errorf("explicit step: %d, %d points", g.StepSeconds, len(g.Series.In))
}
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-30*24*time.Hour), t0, "&step=1"), ""), 422)
}
func TestGraphStitchesHourlyLongDataToRawHot(t *testing.T) {
join := t0.Add(-30 * 24 * time.Hour)
vm := &graphVM{
hot: func(ts int64) bool { return ts >= join.Unix() }, hotVal: rising,
longVal: func(dir string, ts int64) float64 { return -1 }, // long values are recognisable
}
e, i := newGraphEnv(t, vm)
from := t0.Add(-40 * 24 * time.Hour)
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, from, t0, "&max=true"), &g)
if g.StitchedAt == nil || g.Tier != "raw" || len(g.Tiers) != 2 {
t.Fatalf("stitched graph: stitched_at=%v tier=%s tiers=%+v", g.StitchedAt, g.Tier, g.Tiers)
}
if g.Tiers[0].Tier != "1h" || g.Tiers[0].StepSeconds != 3600 || g.Tiers[1].Tier != "raw" || g.Tiers[1].StepSeconds != g.StepSeconds {
t.Errorf("tiers: %+v step=%d", g.Tiers, g.StepSeconds)
}
if !g.Tiers[0].To.Equal(*g.StitchedAt) || !g.Tiers[1].From.Equal(*g.StitchedAt) || g.StitchedAt.Before(join) ||
g.StitchedAt.Sub(join) >= time.Duration(g.StepSeconds)*time.Second {
t.Errorf("stitched_at %v should be the hot retention edge %v rounded up by less than a step", g.StitchedAt, join)
}
var long, rawN int
prev := int64(0)
for _, p := range g.Series.In {
if p.TS <= prev {
t.Fatalf("series not strictly increasing at %d", p.TS)
}
prev = p.TS
if p.V == nil {
t.Fatalf("hole at %d: both stores have data here", p.TS)
}
if *p.V == -1 {
long++
if p.TS >= g.StitchedAt.Unix() || p.TS%3600 != 0 {
t.Errorf("long sample %d misplaced", p.TS)
}
} else {
rawN++
if p.TS < g.StitchedAt.Unix() {
t.Errorf("raw sample %d before the join", p.TS)
}
}
}
if long < 200 || rawN < 400 {
t.Errorf("%d long and %d raw samples", long, rawN)
}
if len(g.Partial) != 0 {
t.Errorf("no gaps expected: %v", g.Partial)
}
if len(g.Series.InMax) != len(g.Series.In) || len(g.Series.OutMax) != len(g.Series.Out) {
t.Errorf("max series lengths %d/%d", len(g.Series.InMax), len(g.Series.OutMax))
}
var sawLong, sawHot bool
for _, c := range e.q.queries() {
switch c.store {
case "long":
sawLong = true
if !strings.Contains(c.expr, "sum without(v)") || !strings.Contains(c.expr, `iface="`+i.ID+`"`) ||
(!strings.Contains(c.expr, "nmon_if_rate_bps_avg_1h") && !strings.Contains(c.expr, "nmon_if_rate_bps_max_1h")) {
t.Errorf("long query: %s", c.expr)
}
case "hot":
sawHot = true
if c.from.Before(*g.StitchedAt) {
t.Errorf("hot queried before the join: %v", c.from)
}
}
}
if !sawLong || !sawHot {
t.Error("a stitched graph must query both stores")
}
}
func TestGraphLongRangeUsesCoarserStepAndHourlyAverages(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising, longVal: rising})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-365*24*time.Hour), t0, ""), &g)
if g.StitchedAt == nil || g.Tiers[0].StepSeconds%3600 != 0 || g.Tiers[0].StepSeconds <= 3600 {
t.Fatalf("1y tiers: %+v", g.Tiers)
}
var longExpr string
for _, c := range e.q.queries() {
if c.store == "long" {
longExpr = c.expr
}
}
if !strings.Contains(longExpr, "avg_over_time((sum without(v)") || !strings.Contains(longExpr, ":3600s])") {
t.Errorf("coarse long query should average the hourly points: %s", longExpr)
}
}
func TestGraphOlderThanHotIsLongTierOnly(t *testing.T) {
vm := &graphVM{hot: func(int64) bool { return false }, hotVal: rising, longVal: rising}
e, i := newGraphEnv(t, vm)
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-90*24*time.Hour), t0.Add(-60*24*time.Hour), ""), &g)
if g.Tier != "1h" || g.StepSeconds != 3600 || g.StitchedAt != nil || len(g.Tiers) != 0 || len(g.Series.In) < 700 {
t.Errorf("long-only graph: tier=%s step=%d stitched=%v %d points", g.Tier, g.StepSeconds, g.StitchedAt, len(g.Series.In))
}
for _, c := range e.q.queries() {
if c.store != "long" {
t.Errorf("hot queried for a range wholly beyond hot retention: %+v", c)
}
}
}
func TestGraphReportsPartialWhereNeitherStoreHasData(t *testing.T) {
// Until M5's rollup exists the long store is empty: the stitched span is partial, honestly.
join := t0.Add(-30 * 24 * time.Hour)
vm := &graphVM{hot: func(ts int64) bool { return ts >= join.Unix() }, hotVal: rising, long: func(int64) bool { return false }}
e, i := newGraphEnv(t, vm)
from := t0.Add(-40 * 24 * time.Hour)
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, from, t0, ""), &g)
if g.StitchedAt == nil || len(g.Partial) != 1 {
t.Fatalf("partial: %+v stitched %v", g.Partial, g.StitchedAt)
}
p := g.Partial[0]
if p.From.After(from.Add(time.Hour)) || p.To.Before(join) || p.To.After(join.Add(2*time.Hour)) {
t.Errorf("gap %v..%v, want about %v..%v", p.From, p.To, from, join)
}
for _, pt := range g.Series.In {
if pt.TS < g.StitchedAt.Unix() && pt.V != nil {
t.Fatalf("a null expected before the join, got %v at %d", *pt.V, pt.TS)
}
}
// a short hole in raw data is a partial span; a single missed sample is not
e2, i2 := newGraphEnv(t, &graphVM{hotVal: rising, hot: func(ts int64) bool {
m := (ts - t0.Add(-time.Hour).Unix()) / 60
return m < 20 || m > 50 && m != 55
}})
e2.getJSON(t, graphURL(i2.ID, t0.Add(-time.Hour), t0, ""), &g)
if len(g.Partial) != 1 || !g.Partial[0].From.Equal(t0.Add(-41*time.Minute)) || !g.Partial[0].To.Equal(t0.Add(-9*time.Minute)) {
t.Errorf("hole: %+v", g.Partial)
}
// no data at all: one partial span covering the range
e3, i3 := newGraphEnv(t, &graphVM{hotVal: rising, hot: func(int64) bool { return false }})
e3.getJSON(t, graphURL(i3.ID, t0.Add(-time.Hour), t0, ""), &g)
if len(g.Partial) != 1 || !g.Partial[0].From.Equal(t0.Add(-time.Hour)) || !g.Partial[0].To.Equal(t0) {
t.Errorf("empty: %+v", g.Partial)
}
// data that starts late: a leading gap
e4, i4 := newGraphEnv(t, &graphVM{hotVal: rising, hot: func(ts int64) bool { return ts >= t0.Add(-20*time.Minute).Unix() }})
e4.getJSON(t, graphURL(i4.ID, t0.Add(-time.Hour), t0, ""), &g)
if len(g.Partial) != 1 || !g.Partial[0].From.Equal(t0.Add(-time.Hour)) || !g.Partial[0].To.Equal(t0.Add(-20*time.Minute)) {
t.Errorf("leading gap: %+v", g.Partial)
}
}
func TestGraphLongStoreDownIsPartialNotAnError(t *testing.T) {
join := t0.Add(-30 * 24 * time.Hour)
vm := &graphVM{hot: func(ts int64) bool { return ts >= join.Unix() }, hotVal: rising, longErr: fmt.Errorf("%w: long", vmsup.ErrStoreDown)}
e, i := newGraphEnv(t, vm)
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-40*24*time.Hour), t0, ""), &g)
if g.StitchedAt == nil || len(g.Partial) != 1 {
t.Errorf("long store down: %+v", g.Partial)
}
}
func TestGraphUnreliableInterfaceHasNoSeries(t *testing.T) {
for name, tc := range map[string]struct {
mut func(*store.Interface)
reason string
}{
"32-bit fast link": {func(i *store.Interface) { i.Unreliable, i.HasHCCounters = true, false }, "counter_32bit_fast_link"},
"discontinuities": {func(i *store.Interface) { i.Unreliable, i.CounterDiscontinuity = true, 3 }, "counter_discontinuity"},
"unreliable for HC": {func(i *store.Interface) { i.Unreliable = true }, "counter_unreliable"},
} {
t.Run(name, func(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising}, tc.mut)
var g api.GraphResponse
rec := e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, "&max=true&p95=true"), &g)
if !g.Unreliable || g.UnreliableReason != tc.reason || len(g.Series.In) != 0 || len(g.Series.Out) != 0 ||
g.P95 != nil || g.Totals != nil || g.Partial == nil || g.Tier != "raw" || g.StepSeconds != 60 {
t.Errorf("unreliable graph: %+v", g)
}
if n := len(e.q.queries()); n != 0 {
t.Errorf("an unreliable interface must not be queried, %d queries", n)
}
if !strings.Contains(rec.Body.String(), `"in":[],"out":[]`) {
t.Errorf("series must be empty arrays: %s", rec.Body)
}
})
}
}
func TestGraphDirectionFilterAndMax(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, "&dir=out&max=true"), &g)
if len(g.Series.In) != 0 || len(g.Series.Out) != 61 || len(g.Series.OutMax) != 61 || g.Series.InMax != nil {
t.Errorf("dir=out: in=%d out=%d out_max=%d", len(g.Series.In), len(g.Series.Out), len(g.Series.OutMax))
}
if g.Totals == nil || g.Totals.InBytes != nil || g.Totals.OutBytes == nil {
t.Errorf("dir=out totals: %+v", g.Totals)
}
qs := e.q.queries()
if len(qs) != 2 {
t.Fatalf("queries %d", len(qs))
}
for _, c := range qs {
if strings.Contains(c.expr, "In") {
t.Errorf("dir=out queried the in counter: %s", c.expr)
}
}
if !strings.Contains(qs[1].expr, "max_over_time((irate(ifHCOutOctets{iface=") || !strings.Contains(qs[1].expr, "[60s:60s])") {
t.Errorf("max query: %s", qs[1].expr)
}
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-time.Hour), t0, "&dir=sideways"), ""), 422)
}
// referenceP95 is an independent nearest-rank implementation: the smallest
// value such that at least 95% of the values are at or below it.
func referenceP95(vals []float64) float64 {
s := append([]float64(nil), vals...)
sort.Float64s(s)
for _, v := range s {
n := 0
for _, x := range s {
if x <= v {
n++
}
}
if float64(n)*100 >= 95*float64(len(s)) {
return v
}
}
return math.NaN()
}
func TestGraphP95IsNearestRankOverFiveMinuteAverages(t *testing.T) {
// 24 h of 5-minute averages; one hour of the day has no data.
from := t0.Add(-24 * time.Hour)
vals := func(dir string, ts int64) float64 {
k := float64((ts / 300 * 7919) % 1000) // scrambled but deterministic
if dir == "out" {
k *= 3
}
return k * 1000
}
hole := func(ts int64) bool { return ts < t0.Add(-5*time.Hour).Unix() || ts >= t0.Add(-4*time.Hour).Unix() }
e, i := newGraphEnv(t, &graphVM{hotVal: vals, hot: hole})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, from, t0, "&p95=true"), &g)
if g.P95 == nil || g.P95.Source != "live" || g.P95.InBPS == nil || g.P95.OutBPS == nil {
t.Fatalf("p95: %+v", g.P95)
}
var in, out []float64
for ts := from.Unix(); ts <= t0.Unix(); ts += 300 {
if hole(ts) {
in, out = append(in, vals("in", ts)), append(out, vals("out", ts))
}
}
if *g.P95.InBPS != referenceP95(in) || *g.P95.OutBPS != referenceP95(out) {
t.Errorf("p95 in %v out %v, reference %v / %v", *g.P95.InBPS, *g.P95.OutBPS, referenceP95(in), referenceP95(out))
}
agg := make([]float64, len(in))
for k := range in {
agg[k] = in[k] + out[k]
}
if g.P95.AggregateBPS == nil || *g.P95.AggregateBPS != referenceP95(agg) {
t.Errorf("aggregate p95 %v, reference %v", g.P95.AggregateBPS, referenceP95(agg))
}
wantCov := float64(len(in)) / (24 * 12)
if math.Abs(g.P95.Coverage-wantCov) > 0.01 || g.P95.Coverage >= 1 {
t.Errorf("coverage %v, want about %v", g.P95.Coverage, wantCov)
}
var withoutP95 api.GraphResponse
e.getJSON(t, graphURL(i.ID, from, t0, ""), &withoutP95)
if withoutP95.P95 != nil {
t.Error("p95 only when asked")
}
}
func TestGraphErrors(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
isProblem(t, e.do("GET", "/api/v1/graph", ""), 422)
isProblem(t, e.do("GET", "/api/v1/graph?iface=01ARZ3NDEKTSV4RRFFQ69G5FAV", ""), 404)
isProblem(t, e.do("GET", graphURL(i.ID, t0, t0.Add(-time.Hour), ""), ""), 422)
isProblem(t, e.do("GET", "/api/v1/graph?iface="+i.ID+"&from=yesterday", ""), 422)
isProblem(t, e.do("GET", "/api/v1/graph?iface="+i.ID+"&step=0", ""), 422)
e.q.fn = func(string, string, time.Time, time.Time, time.Duration) ([]byte, error) {
return nil, fmt.Errorf("%w: hot", vmsup.ErrStoreDown)
}
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-time.Hour), t0, ""), ""), 503)
e.q.fn = func(string, string, time.Time, time.Time, time.Duration) ([]byte, error) {
return nil, errors.New("boom")
}
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-time.Hour), t0, ""), ""), 502)
// defaults: to=now, from=1h earlier
e.q.fn = (&graphVM{hotVal: rising}).fn
var g api.GraphResponse
e.getJSON(t, "/api/v1/graph?iface="+i.ID, &g)
if len(g.Series.In) != 61 || g.Series.In[60].TS != t0.Unix() {
t.Errorf("default range: %d points", len(g.Series.In))
}
}
func TestGraphAggregateP95NeedsBothDirections(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-2*time.Hour), t0, "&p95=true&dir=in"), &g)
if g.P95 == nil || g.P95.InBPS == nil || g.P95.AggregateBPS != nil {
t.Fatalf("one direction asked for: p95 %+v", g.P95)
}
}