Files
nmon/internal/api/p95.go

100 lines
3.5 KiB
Go

package api
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/p95"
)
// P95Response is a monthly 95th percentile in bits per second. It is never a bare number: source says
// whether it was materialised or computed now, and coverage how much of the month had data.
type P95Response struct {
Scope string `json:"scope" enum:"iface,group"`
Month string `json:"month" doc:"YYYY-MM in the site timezone."`
InBPS float64 `json:"in_bps"`
OutBPS float64 `json:"out_bps"`
Coverage float64 `json:"coverage" doc:"Share of the month's 5-minute windows that had samples, 0 to 1."`
Partial bool `json:"partial" doc:"Coverage is below 90%."`
Source string `json:"source" enum:"materialised,live"`
MembersHash string `json:"members_digest,omitempty" doc:"Groups: names the membership the figure was computed from."`
Flags []GroupFlag `json:"flags,omitempty"`
}
func (s *server) registerP95(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "get-p95", Method: http.MethodGet, Path: "/api/v1/p95", Tags: []string{"graph"},
Summary: "Monthly 95th percentile of an interface or a group",
Description: "Nearest rank over the 5-minute average rates of one calendar month in the site's timezone, per direction. " +
"A finished month is answered from the materialised record when there is one; otherwise it is computed now. " +
"Give exactly one of iface and group. An interface with unreliable counters, or a month without samples, is a 422.",
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity, http.StatusServiceUnavailable},
}, s.getP95)
}
type p95In struct {
Iface string `query:"iface" doc:"Interface ULID."`
Group string `query:"group" doc:"Group id."`
Month string `query:"month" required:"true" pattern:"^\\d{4}-(0[1-9]|1[0-2])$" doc:"YYYY-MM."`
}
func (s *server) getP95(ctx context.Context, in *p95In) (*struct{ Body P95Response }, error) {
if (in.Iface == "") == (in.Group == "") {
return nil, huma.Error422UnprocessableEntity("give exactly one of iface and group")
}
release, err := s.admit(ctx, false)
if err != nil {
return nil, err
}
defer release()
qctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
if in.Iface != "" {
return s.ifaceP95(qctx, in)
}
return s.groupP95(qctx, in)
}
func (s *server) ifaceP95(ctx context.Context, in *p95In) (*struct{ Body P95Response }, error) {
if s.P95 == nil {
return nil, errNotConfigured("p95")
}
r, err := s.P95.P95(ctx, in.Iface, in.Month)
if err != nil {
return nil, s.p95Err(ctx, err)
}
return &struct{ Body P95Response }{p95Response("iface", r)}, nil
}
func (s *server) groupP95(ctx context.Context, in *p95In) (*struct{ Body P95Response }, error) {
if s.Groups == nil {
return nil, errNotConfigured("group traffic")
}
id, err := idParam(in.Group)
if err != nil {
return nil, err
}
if _, err := s.Store.GetGroup(ctx, id); err != nil {
return nil, s.storeErr(err)
}
r, err := s.Groups.GroupP95(ctx, id, in.Month)
if err != nil {
return nil, s.p95Err(ctx, err)
}
out := p95Response("group", r.Result)
out.Flags = []GroupFlag{}
for _, f := range r.Flags {
out.Flags = append(out.Flags, GroupFlag{Kind: f.Kind, A: f.A, B: f.B})
}
return &struct{ Body P95Response }{out}, nil
}
func p95Response(scope string, r p95.Result) P95Response {
return P95Response{
Scope: scope, Month: r.Month, InBPS: r.InBps, OutBPS: r.OutBps, Coverage: r.Coverage, Partial: r.Partial(),
Source: string(r.Source), MembersHash: r.MembersHash,
}
}