Files
nmon/internal/api/overview.go
Vibe Coder f31fb3712a
Some checks failed
ci / lint (push) Successful in 4m19s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 4m55s
ci / test (push) Failing after 19m48s
Add settings forms, first-install wizard, webhook presets, help and poll tools
Make nmon easier to install and run without editing YAML by hand:

- Settings: point-and-click forms over config.yaml (GET/PUT
  /api/v1/settings/form); the YAML editor moves to /settings/yaml.
- First install: with no sign-in configured nmon starts in install
  mode, serves only the setup wizard (sign-in, basics, devices,
  alerting) and closes it for good once sign-in is saved. A
  self-signed certificate is generated when no TLS source is given.
  poller.interval becomes a config setting.
- Reverse proxies: opt-in --http-listen plain-HTTP listener beside
  HTTPS; sign-in stays required.
- Webhooks: Generic, Teams Adaptive Card, Slack and Discord presets,
  template preview and test send.
- Help: the operations, coverage and profile docs served at /help.
- Polling: per-device "Poll now", a dry-run test poll returning an
  SNMP trace, and time-limited in-memory SNMP debug recording, in the
  UI and as `nmon device poll`.
- Ansible: deploys into install mode when no sign-in is set, and no
  longer overwrites the config.yaml the wizard wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
2026-10-25 06:31:12 +00:00

145 lines
4.9 KiB
Go

package api
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/store"
)
var overviewRanges = map[string]time.Duration{"24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour}
func (s *server) registerOverview(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "get-device-overview", Method: http.MethodGet, Path: "/api/v1/devices/{slug}/overview", Tags: []string{"devices"},
Summary: "Data of the device Overview tab",
Description: "Aggregate traffic (the sum over the device's non-excluded, non-retired, reliable ports; one query per direction), " +
"port counts, availability over 24h, 7d, 30d and 1y from device_down alerts, ICMP round-trip time and the last 20 events. " +
"The step rules are those of /graph, limited to the hot store. If the metric store cannot answer, traffic and rtt are " +
"empty and partial says so.",
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity},
}, s.overview)
}
type overviewIn struct {
Slug string `path:"slug" doc:"Device slug."`
Range string `query:"range" enum:"24h,7d,30d" default:"24h" doc:"Window ending now."`
}
func (s *server) overview(ctx context.Context, in *overviewIn) (*struct{ Body OverviewResponse }, error) {
d, err := s.Store.GetDeviceBySlug(ctx, in.Slug)
if err != nil {
return nil, s.storeErr(err)
}
if d.PurgedAt != nil {
return nil, huma.Error404NotFound("not found")
}
now := s.Clock.Now()
ifs, err := s.Store.ListInterfaces(ctx, d.ID, false)
if err != nil {
return nil, s.storeErr(err)
}
resp := OverviewResponse{Range: in.Range, Ports: portCounts(ifs), Traffic: OverviewTraffic{In: []Point{}, Out: []Point{}}, RTT: []Point{}}
if err := s.overviewHistory(ctx, &resp, d, ifs, now); err != nil {
return nil, s.storeErr(err)
}
s.overviewMetrics(ctx, &resp, d, ifs, now)
return &struct{ Body OverviewResponse }{resp}, nil
}
// overviewHistory fills availability and events from the inventory.
func (s *server) overviewHistory(ctx context.Context, resp *OverviewResponse, d *store.Device, ifs []store.Interface, now time.Time) error {
rows, err := s.Store.ListDeviceDownAlerts(ctx, d.Slug, now.Add(-year))
if err != nil {
return err
}
resp.Availability = availabilityOf(rows, now, d.CreatedAt)
alerts, _, err := s.Store.ListAlerts(ctx, store.AlertFilter{Device: d.Slug}, store.Page{Limit: overviewEvents})
if err != nil {
return err
}
objects := []string{d.Slug}
for _, i := range ifs {
objects = append(objects, i.ID)
}
audit, err := s.Store.ListAuditByObjects(ctx, objects, overviewEvents)
if err != nil {
return err
}
resp.Events = mergeEvents(alerts, audit)
return nil
}
// overviewMetrics fills the traffic and rtt series; a metric store that cannot answer leaves them empty.
func (s *server) overviewMetrics(ctx context.Context, resp *OverviewResponse, d *store.Device, ifs []store.Interface, now time.Time) {
plan, err := s.metricPlan(&metricGraphIn{From: now.Add(-overviewRanges[resp.Range]), To: now})
if err != nil {
resp.Partial = append(resp.Partial, "range")
return
}
resp.StepSeconds = int64(plan.newest().step / time.Second)
grid := gridOf(plan)
step := plan.newest().step
hc, soft := countedIfaces(ifs, &resp.Traffic)
for _, dir := range []string{dirIn, dirOut} {
if len(hc)+len(soft) == 0 {
break
}
vals, err := s.fetchMetricSeries(ctx, trafficExpr(hc, soft, dir, step, s.pollEvery()), plan)
if err != nil {
resp.Partial = append(resp.Partial, "traffic")
resp.Traffic.In, resp.Traffic.Out = []Point{}, []Point{}
break
}
if dir == dirIn {
resp.Traffic.In = pointsOf(grid, vals)
} else {
resp.Traffic.Out = pointsOf(grid, vals)
}
}
rtt, err := s.fetchMetricSeries(ctx, `nmon_rtt_seconds{device=`+quote(d.Slug)+`}`, plan)
if err != nil {
resp.Partial = append(resp.Partial, "rtt")
return
}
if len(rtt) > 0 {
resp.RTT = pointsOf(grid, rtt)
}
}
// countedIfaces splits the ports that count toward traffic by counter width and counts the unreliable ones left out.
func countedIfaces(ifs []store.Interface, t *OverviewTraffic) (hc, soft []string) {
for _, i := range ifs {
switch {
case i.RetiredAt != nil || i.Excluded:
case i.Unreliable:
t.UnreliableExcluded++
case i.HasHCCounters:
hc = append(hc, i.ID)
default:
soft = append(soft, i.ID)
}
}
return hc, soft
}
// trafficExpr is the summed bits per second of dir; HC counters where a port has them, else the 32-bit ones.
func trafficExpr(hc, soft []string, dir string, step, every time.Duration) string {
names := map[string][2]string{dirIn: {"ifHCInOctets", "ifInOctets"}, dirOut: {"ifHCOutOctets", "ifOutOctets"}}[dir]
win := secs(max(step, rateWindowFor(every)))
var parts []string
for n, ids := range [][]string{hc, soft} {
if len(ids) > 0 {
parts = append(parts, `rate(`+names[n]+`{iface=~`+ifaceMatcher(ids)+`}[`+win+`])*8`)
}
}
switch len(parts) {
case 1:
return `sum(` + parts[0] + `)`
}
return `sum(` + parts[0] + ` or ` + parts[1] + `)`
}