Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
143 lines
4.8 KiB
Go
143 lines
4.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
func (s *server) registerGraph(api huma.API) {
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "get-graph", Method: http.MethodGet, Path: "/api/v1/graph", Tags: []string{"graph"},
|
|
Summary: "Bits per second in and out of one interface",
|
|
Description: "From the hot store when the range lies within hot retention; older data comes from the long store's " +
|
|
"hourly rollups, and a range that needs both is stitched at stitched_at. Stretches with no data in either store " +
|
|
"are listed in partial. An unreliable interface returns no series. max=true adds the per-step maximum; " +
|
|
"p95=true adds the 95th percentile of 5-minute averages, computed live.",
|
|
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity, http.StatusTooManyRequests, http.StatusServiceUnavailable},
|
|
}, s.graph)
|
|
}
|
|
|
|
type graphIn struct {
|
|
Iface string `query:"iface" required:"true" minLength:"1" doc:"Interface ULID."`
|
|
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."`
|
|
Dir string `query:"dir" enum:"in,out" doc:"Only this direction; the other series is empty."`
|
|
Max bool `query:"max" doc:"Also return in_max and out_max, the highest one-minute rate within each step."`
|
|
P95 bool `query:"p95" doc:"Also return the live 95th percentile of 5-minute averages."`
|
|
}
|
|
|
|
func (s *server) graph(ctx context.Context, in *graphIn) (*struct{ Body GraphResponse }, 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)
|
|
}
|
|
from, to, err := s.graphRange(in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := GraphResponse{
|
|
Iface: GraphIface{
|
|
ID: i.ID, IfName: i.IfName, IfAlias: i.IfAlias, SpeedBPS: speedBPS(i), Device: DeviceRef{Slug: dev.Slug, Name: dev.Name},
|
|
},
|
|
Partial: []TimeSpan{}, Series: GraphSeries{In: []Point{}, Out: []Point{}},
|
|
}
|
|
hotStart := s.Clock.Now().Add(-s.HotRetention)
|
|
plan, err := planGraph(from, to, hotStart, time.Duration(in.Step)*time.Second)
|
|
if err != nil {
|
|
return nil, huma.Error422UnprocessableEntity(err.Error())
|
|
}
|
|
resp.StepSeconds, resp.Tier = int64(plan.newest().step/time.Second), plan.newest().tier
|
|
if i.Unreliable {
|
|
resp.Unreliable, resp.UnreliableReason = true, unreliableReason(i)
|
|
return &struct{ Body GraphResponse }{resp}, nil
|
|
}
|
|
if err := s.fillGraph(ctx, &resp, i, plan, in, hotStart); err != nil {
|
|
return nil, err
|
|
}
|
|
return &struct{ Body GraphResponse }{resp}, nil
|
|
}
|
|
|
|
// graphRange applies the defaults and validates the requested range.
|
|
func (s *server) graphRange(in *graphIn) (from, to time.Time, err error) {
|
|
to = in.To
|
|
if to.IsZero() {
|
|
to = s.Clock.Now()
|
|
}
|
|
from = in.From
|
|
if from.IsZero() {
|
|
from = to.Add(-time.Hour)
|
|
}
|
|
if !from.Before(to) {
|
|
return from, to, huma.Error422UnprocessableEntity("from must be before to")
|
|
}
|
|
return from.UTC(), to.UTC(), nil
|
|
}
|
|
|
|
// fillGraph queries the stores the plan names and lays the samples out on the grid.
|
|
func (s *server) fillGraph(
|
|
ctx context.Context, resp *GraphResponse, i *store.Interface, p *graphPlan, in *graphIn, hotStart time.Time,
|
|
) error {
|
|
release, err := s.admit(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
qctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
|
defer cancel()
|
|
q := &graphQuery{iface: i, dirs: []string{dirIn, dirOut}, withMax: in.Max}
|
|
if in.Dir != "" {
|
|
q.dirs = []string{in.Dir}
|
|
}
|
|
data := newGraphData()
|
|
for _, w := range p.tiers {
|
|
if err := s.fetchTier(qctx, q, w, data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
assemble(resp, p, q, data)
|
|
if in.P95 {
|
|
p95, err := s.livep95(qctx, q, p, hotStart)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp.P95 = p95
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// assemble turns the samples into series, tiers, stitched_at and partial spans.
|
|
func assemble(resp *GraphResponse, p *graphPlan, q *graphQuery, d *graphData) {
|
|
grid := gridOf(p)
|
|
for _, dir := range q.dirs {
|
|
resp.Series.set(dir, false, pointsOf(grid, d.avg[dir]))
|
|
if q.withMax {
|
|
resp.Series.set(dir, true, pointsOf(grid, d.max[dir]))
|
|
}
|
|
}
|
|
has := func(ts int64) bool {
|
|
for _, dir := range q.dirs {
|
|
if _, ok := d.avg[dir][ts]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
resp.Partial = append(resp.Partial, findGaps(grid, has, p.from.Unix(), p.to.Unix())...)
|
|
if p.stitchedAt != nil {
|
|
resp.StitchedAt = p.stitchedAt
|
|
for _, w := range p.tiers {
|
|
resp.Tiers = append(resp.Tiers, GraphTier{From: w.from.UTC(), To: w.bound.UTC(), Tier: w.tier, StepSeconds: int64(w.step / time.Second)})
|
|
}
|
|
}
|
|
}
|