Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
175 lines
6.6 KiB
Go
175 lines
6.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"github.com/v10networks/nmon/internal/p95"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// maxGroupSpan is how far back a group graph may reach: the 5-minute rates it sums live for retention.fiveminute.
|
|
const maxGroupSpan = 125 * day
|
|
|
|
// GroupGraphResponse is the oriented sum of a group's members.
|
|
type GroupGraphResponse struct {
|
|
Group GroupView `json:"group"`
|
|
From time.Time `json:"from" doc:"First sample time of the grid."`
|
|
To time.Time `json:"to"`
|
|
StepSeconds int64 `json:"step_seconds"`
|
|
Series GraphSeries `json:"series" doc:"Bits per second in and out, oriented by each member's direction. Points are null where no member had data."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
|
Partial []TimeSpan `json:"partial" doc:"Stretches where at least one member had no sample, so the sum is a lower bound."`
|
|
Flags []GroupFlag `json:"flags"`
|
|
P95 *GraphP95 `json:"p95,omitempty" doc:"The group's monthly p95 of the summed series (never a sum of per-port figures)."`
|
|
}
|
|
|
|
type groupGraphIn struct {
|
|
ID string `path:"id" doc:"Group id."`
|
|
From time.Time `query:"from" doc:"Start, RFC 3339. Default 24 hours before to."`
|
|
To time.Time `query:"to" doc:"End, RFC 3339. Default now."`
|
|
Step int64 `query:"step" minimum:"1" doc:"Step in seconds, rounded up to a multiple of 300. Default max(300, range/1000)."`
|
|
P95 bool `query:"p95" doc:"Also return the group's p95 for month."`
|
|
Month string `query:"month" pattern:"^\\d{4}-\\d{2}$" doc:"Month for p95, YYYY-MM in the server timezone. Default: the month containing to."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
|
}
|
|
|
|
func (s *server) registerGroupGraph(api huma.API) {
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "get-group-graph", Method: http.MethodGet, Path: "/api/v1/groups/{id}/graph", Tags: []string{"groups"},
|
|
Summary: "Oriented in/out sum of a group's members",
|
|
Description: "Membership is resolved per timestamp, so a port added in February never changes January. Interfaces with " +
|
|
"unreliable counters are left out, a LAG member is dropped when its LAG is in the group, and a member with direction " +
|
|
"away has in and out swapped. Built from 5-minute rates, so it reaches back retention.fiveminute only.",
|
|
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity, http.StatusServiceUnavailable},
|
|
}, s.groupGraph)
|
|
}
|
|
|
|
func (s *server) groupGraph(ctx context.Context, in *groupGraphIn) (*struct{ Body GroupGraphResponse }, error) {
|
|
if s.Groups == nil {
|
|
return nil, errNotConfigured("group traffic")
|
|
}
|
|
id, err := idParam(in.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ref, err := s.Store.GetGroup(ctx, id)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
from, to, step, err := s.groupRange(in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
release, err := s.admit(ctx, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
qctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
|
defer cancel()
|
|
res, err := s.Groups.GroupSeries(qctx, id, from, to, step)
|
|
if err != nil {
|
|
return nil, s.vmErr(qctx, err)
|
|
}
|
|
out := GroupGraphResponse{
|
|
Group: GroupView{ID: strconv.FormatInt(ref.ID, 10), Name: ref.Name, Kind: ref.Kind}, From: from.UTC(), To: to.UTC(),
|
|
StepSeconds: int64(step / time.Second), Partial: []TimeSpan{}, Flags: []GroupFlag{},
|
|
}
|
|
out.Series.In, out.Series.Out = gridPoints(res.In, from, to, step), gridPoints(res.Out, from, to, step)
|
|
for _, sp := range res.Partial {
|
|
out.Partial = append(out.Partial, TimeSpan{From: sp.From, To: sp.To})
|
|
}
|
|
for _, f := range res.Flags {
|
|
out.Flags = append(out.Flags, GroupFlag{Kind: f.Kind, A: f.A, B: f.B})
|
|
}
|
|
if in.P95 {
|
|
if out.P95, err = s.groupMonthP95(qctx, id, in.Month, to); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &struct{ Body GroupGraphResponse }{out}, nil
|
|
}
|
|
|
|
// groupRange applies the defaults and the 5-minute grid.
|
|
func (s *server) groupRange(in *groupGraphIn) (from, to time.Time, step time.Duration, err error) {
|
|
to = in.To
|
|
if to.IsZero() {
|
|
to = s.Clock.Now()
|
|
}
|
|
from = in.From
|
|
if from.IsZero() {
|
|
from = to.Add(-24 * time.Hour)
|
|
}
|
|
to, from = to.UTC(), from.UTC()
|
|
switch {
|
|
case !from.Before(to):
|
|
return from, to, 0, huma.Error422UnprocessableEntity("from must be before to")
|
|
case to.Sub(from) > maxGroupSpan:
|
|
return from, to, 0, huma.Error422UnprocessableEntity("a group graph reaches back at most 125 days: it is built from 5-minute rates")
|
|
}
|
|
step = time.Duration(in.Step) * time.Second
|
|
if step <= 0 {
|
|
step = to.Sub(from) / targetPoints
|
|
}
|
|
step = max(p95.Window, ceilDur(step, p95.Window))
|
|
if n := to.Sub(from) / step; n > maxGraphPoints {
|
|
return from, to, 0, huma.Error422UnprocessableEntity("too many points; use a larger step")
|
|
}
|
|
return from.Truncate(p95.Window), to, step, nil
|
|
}
|
|
|
|
// gridPoints lays sparse [ts, value] points on the bucket grid, null where a bucket had none.
|
|
func gridPoints(pts [][2]float64, from, to time.Time, step time.Duration) []Point {
|
|
byTS := make(map[int64]float64, len(pts))
|
|
for _, p := range pts {
|
|
byTS[int64(p[0])] = p[1]
|
|
}
|
|
out := []Point{}
|
|
for t := from; t.Before(to); t = t.Add(step) {
|
|
p := Point{TS: t.Unix()}
|
|
if v, ok := byTS[t.Unix()]; ok {
|
|
p.V = &v
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// groupMonthP95 is the group's p95 for month (default: the month containing to, in the server timezone).
|
|
func (s *server) groupMonthP95(ctx context.Context, id int64, month string, to time.Time) (*GraphP95, error) {
|
|
if month == "" {
|
|
month = p95.MonthContaining(to.Add(-time.Nanosecond), s.Zones.For("")).Label
|
|
}
|
|
r, err := s.Groups.GroupP95(ctx, id, month)
|
|
switch {
|
|
case errors.Is(err, p95.ErrNoData):
|
|
return &GraphP95{Source: string(p95.SourceLive), Month: month, Partial: true}, nil
|
|
case err != nil:
|
|
return nil, s.p95Err(ctx, err)
|
|
}
|
|
return p95Graph(r.Result), nil
|
|
}
|
|
|
|
// p95Graph is the GraphP95 form of a figure.
|
|
func p95Graph(r p95.Result) *GraphP95 {
|
|
in, out := r.InBps, r.OutBps
|
|
return &GraphP95{InBPS: &in, OutBPS: &out, Source: string(r.Source), Coverage: r.Coverage, Month: r.Month, Partial: r.Partial()}
|
|
}
|
|
|
|
// p95Err maps p95 and group failures to problems.
|
|
func (s *server) p95Err(ctx context.Context, err error) error {
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
return huma.Error404NotFound("not found")
|
|
case errors.Is(err, p95.ErrUnreliable):
|
|
return huma.Error422UnprocessableEntity("the interface's counters are unreliable, so no p95 is reported")
|
|
case errors.Is(err, p95.ErrNoData):
|
|
return huma.Error422UnprocessableEntity("no 5-minute samples in that month")
|
|
}
|
|
return s.vmErr(ctx, err)
|
|
}
|