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

108 lines
3.7 KiB
Go

package api
import (
"math"
"testing"
"time"
)
func totalsPlan(t *testing.T, from, to, hotStart time.Time) *graphPlan {
t.Helper()
p, err := planGraph(from, to, hotStart, 0)
if err != nil {
t.Fatal(err)
}
return p
}
// fill gives every slot of every tier the value v in both directions, except skip.
func fill(p *graphPlan, v float64, skip func(ts int64) bool) map[string]map[int64]float64 {
m := map[string]map[int64]float64{dirIn: {}, dirOut: {}}
for _, w := range p.tiers {
for _, ts := range w.slots() {
if skip != nil && skip(ts) {
continue
}
m[dirIn][ts], m[dirOut][ts] = v, v/2
}
}
return m
}
func near(a *float64, want float64) bool { return a != nil && math.Abs(*a-want) <= want*1e-9+1e-6 }
func TestGraphTotalsRawOnly(t *testing.T) {
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
from := now.Add(-time.Hour)
p := totalsPlan(t, from, now, now.Add(-30*24*time.Hour))
got := graphTotals(p, []string{dirIn, dirOut}, fill(p, 8000, nil), from, now)
// 8 kbit/s for 3600 s is 3.6 MB; out is half.
if got == nil || !near(got.InBytes, 3_600_000) || !near(got.OutBytes, 1_800_000) {
t.Fatalf("totals %+v", got)
}
if got.CoveredSeconds != 3600 || got.RangeSeconds != 3600 {
t.Errorf("covered %d range %d", got.CoveredSeconds, got.RangeSeconds)
}
}
func TestGraphTotalsUnalignedRangeIsClipped(t *testing.T) {
now := time.Date(2026, 10, 22, 12, 0, 30, 0, time.UTC)
from := now.Add(-time.Hour)
p := totalsPlan(t, from, now, now.Add(-30*24*time.Hour))
got := graphTotals(p, []string{dirIn}, fill(p, 8000, nil), from, now)
if got == nil || got.OutBytes != nil || got.InBytes == nil || *got.InBytes > 3_600_000+1 {
t.Fatalf("totals %+v", got)
}
}
func TestGraphTotalsStitchedNoDoubleCount(t *testing.T) {
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
hotStart := now.Add(-30 * 24 * time.Hour)
from := now.Add(-40 * 24 * time.Hour)
p := totalsPlan(t, from, now, hotStart)
if p.stitchedAt == nil {
t.Fatal("not stitched")
}
got := graphTotals(p, []string{dirIn}, fill(p, 8000, nil), from, now)
// 8 kbit/s is 1000 B/s: the total must be exactly that over what was covered, so nothing is counted twice.
if got == nil || !near(got.InBytes, 1000*float64(got.CoveredSeconds)) {
t.Fatalf("totals %+v", got)
}
// Only the partial step at each end (aligned away) may be missing.
if lost := got.RangeSeconds - got.CoveredSeconds; lost < 0 || lost > int64(2*p.newest().step/time.Second) {
t.Errorf("covered %d of %d", got.CoveredSeconds, got.RangeSeconds)
}
}
func TestGraphTotalsGapsContributeNothing(t *testing.T) {
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
from := now.Add(-time.Hour)
p := totalsPlan(t, from, now, now.Add(-30*24*time.Hour))
half := now.Add(-30 * time.Minute).Unix()
got := graphTotals(p, []string{dirIn}, fill(p, 8000, func(ts int64) bool { return ts > half }), from, now)
if got == nil || !near(got.InBytes, 1_800_000) || got.CoveredSeconds != 1800 {
t.Fatalf("totals %+v", got)
}
if graphTotals(p, []string{dirIn}, fill(p, 8000, func(int64) bool { return true }), from, now) != nil {
t.Error("no data should give nil totals")
}
}
func TestAggregateP95SumsOnlySlotsWithBothDirections(t *testing.T) {
slots := map[string]map[int64]float64{
dirIn: {300: 10, 600: 20, 900: 30, 1200: 1000},
dirOut: {300: 1, 600: 2, 900: 3},
}
got := aggregateP95(slots)
// slot 1200 has no out sample and is ignored: sums are 11, 22, 33 and the nearest-rank 95th of three is the top.
if !near(got, 33) {
t.Fatalf("aggregate %v, want 33", got)
}
if aggregateP95(map[string]map[int64]float64{dirIn: {300: 1}}) != nil {
t.Error("one direction only gives no aggregate")
}
if aggregateP95(map[string]map[int64]float64{dirIn: {300: 1}, dirOut: {600: 1}}) != nil {
t.Error("no shared slot gives no aggregate")
}
}