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
92 lines
3.9 KiB
Go
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
|
|
}
|