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

92 lines
3.9 KiB
Go

package api
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
)
// counterKinds maps a graph kind to the polled counters that carry its in and out directions and the unit of
// the rate. Only counters every profile polls for every port are listed (profiles/generic.yaml); a kind joins
// the list when its counters do. Packet and broadcast/multicast counters are not polled today.
var counterKinds = map[string]struct{ in, out, unit string }{
"errors": {"ifInErrors", "ifOutErrors", "errors/s"},
"discards": {"ifInDiscards", "ifOutDiscards", "discards/s"},
}
func (s *server) registerGraphCounters(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "get-graph-counters", Method: http.MethodGet, Path: "/api/v1/graph/counters", Tags: []string{"graph"},
Summary: "Errors or discards per second in and out of one interface",
Description: "The rate of the interface's error or discard counters, from the hot store only: these counters have no " +
"hourly rollup, so a range that starts before hot retention is moved up to its start and clamped says so. " +
"The same step and rate-window rules as /graph apply.",
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity, http.StatusTooManyRequests, http.StatusServiceUnavailable},
}, s.graphCounters)
}
type graphCountersIn struct {
Iface string `query:"iface" required:"true" minLength:"1" doc:"Interface ULID."`
Kind string `query:"kind" required:"true" enum:"errors,discards" doc:"Which counters."`
From time.Time `query:"from" doc:"Start, RFC 3339. Default one hour before to."`
To time.Time `query:"to" doc:"End, RFC 3339. Default now."`
Step int64 `query:"step" minimum:"1" doc:"Step in seconds. Default max(60, range/1000) rounded up to whole minutes."`
}
// CounterSeries holds the per-second rates of one counter pair.
type CounterSeries struct {
In []Point `json:"in"`
Out []Point `json:"out"`
}
// CounterGraphResponse is a rate graph of an interface's error or discard counters.
type CounterGraphResponse struct {
Iface GraphIface `json:"iface"`
Kind string `json:"kind" enum:"errors,discards"`
Unit string `json:"unit" doc:"Unit of the series values, for example errors/s."`
StepSeconds int64 `json:"step_seconds"`
From time.Time `json:"from" doc:"Start of the answered range, after any clamp to hot retention."`
To time.Time `json:"to"`
Clamped bool `json:"clamped" doc:"The requested start was before hot retention and was moved up to it."`
Series CounterSeries `json:"series" doc:"[unix_s, per-second rate|null] pairs."`
}
func (s *server) graphCounters(ctx context.Context, in *graphCountersIn) (*struct{ Body CounterGraphResponse }, error) {
i, err := s.Store.GetInterface(ctx, in.Iface)
if err != nil {
return nil, s.storeErr(err)
}
dev, err := s.Store.GetDeviceByID(ctx, i.DeviceID)
if err != nil {
return nil, s.storeErr(err)
}
plan, clamped, err := s.hotPlan(in.From, in.To, in.Step)
if err != nil {
return nil, err
}
kind := counterKinds[in.Kind]
step := plan.newest().step
every := dev.PollInterval(s.pollEvery())
grid := gridOf(plan)
resp := CounterGraphResponse{
Iface: GraphIface{
ID: i.ID, IfName: i.IfName, IfAlias: i.IfAlias, SpeedBPS: speedBPS(i), Device: DeviceRef{Slug: dev.Slug, Name: dev.Name},
},
Kind: in.Kind, Unit: kind.unit, StepSeconds: int64(step / time.Second), From: plan.from, To: plan.to, Clamped: clamped,
}
for _, c := range []struct {
name string
dst *[]Point
}{{kind.in, &resp.Series.In}, {kind.out, &resp.Series.Out}} {
expr := `rate(` + c.name + `{iface=` + quote(i.ID) + `}[` + secs(max(step, rateWindowFor(every))) + `])`
series, err := s.fetchMetricSeries(ctx, expr, plan)
if err != nil {
return nil, err
}
*c.dst = pointsOf(grid, series)
}
return &struct{ Body CounterGraphResponse }{resp}, nil
}