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
145 lines
4.9 KiB
Go
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] + `)`
|
|
}
|