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
108 lines
3.7 KiB
Go
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")
|
|
}
|
|
}
|