17 Commits
m1 ... m3

Author SHA1 Message Date
f85c3ef781 M3: Go API per UI contract, embedded UI, openapi compat check, nmon-devtools, live gate + screenshots; M4 WP1 reachability and dependency suppression
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 20:34:14 +00:00
1ed97a540d Maintainability cleanup (collection paths): 63 findings to 0; fix flaky poller tests and a production timer race; M2 gate passed
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:25:08 +00:00
5283921d6d Maintainability cleanup (API, app, CLI, fixtures): 167 lint findings to 0; app coverage 25->90%, config 0->90%; fixturegen never emits not-accessible columns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:16:35 +00:00
e7c70ce466 M3 WP2: web UI (React 19, TanStack, uPlot TrafficGraph), contract-generated client, mock e2e + screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:13:27 +00:00
287fbb98bd Maintainability cleanup (core libraries): 223 lint findings to 0, oversized files split; fix apc-ups label columns and never poll text metrics
Migration files restructured with shared helpers; verified byte-identical SQLite schema vs previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:11:15 +00:00
cce37313f1 M3 contract: facets, unreliable_reason, tiers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:09:58 +00:00
2f6dcd5754 CI lint job; milestone definition of done includes clean lint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:55:27 +00:00
f7b394585b Maintainability is a hard rule: standard, golangci-lint config, lint in every gate
- docs/MAINTAINABILITY.md: limits and architecture rules
- .golangci.yml (pinned v2.14.0) + file-size check; make lint runs first in every gate
- web/go.mod stops the Go tool from walking web/node_modules
- layering: Target -> internal/snmptarget, TableKey -> profile.Metric.TableKey; poller no longer imports discovery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:54:32 +00:00
a037b406cb M2 integration: wiring, profile test, discover CLI/endpoint, per-profile fixtures and expect harness, gate-m2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:50:02 +00:00
61b3de28dd M3: API contract written ahead of the WPs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:38:03 +00:00
cc9043b014 M4 WP2: MetricsQL rule evaluator with persisted state, stale/flap/ack/maintenance semantics
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:37:36 +00:00
4252b342fc M2 WP5: SMI MIB parser, profile new/record, separate nmon-librenms-import tool
Parses all 4,865 bundled MIBs; snmptranslate cross-check 200/200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:25:40 +00:00
a71e3746f2 Docs: defer deployment playbook; screenshots per UI milestone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:24:39 +00:00
22c2a6438c M2 WP4: profile-driven poller, varbind adaptation, backoff/degraded, reboot detection, trap receiver
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:22:13 +00:00
76a599d4e1 M2 WP3: full discovery, interface identity matcher, RMA, reattach, discovery scheduler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:17:27 +00:00
b250f63608 M4 WP3: notification queue (webhook, SMTP, retry 1 h, incident IDs, redaction) and outbound allow-list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:12:57 +00:00
b32c74c29a M2 prep: per-device snmp_port, device_components table and store contract
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:01:29 +00:00
460 changed files with 67623 additions and 2357 deletions

View File

@@ -1,6 +1,17 @@
name: ci
on: [push, pull_request]
jobs:
lint:
# Hard rule (docs/MAINTAINABILITY.md): any finding fails the build.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: stable }
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: cd web && npm ci
- run: make lint
test:
strategy:
matrix:

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/nmon
/nmon-arm64
/nmon-devtools
/internal/vmsup/bin/*.tar.gz
/internal/vmsup/bin/victoria-metrics-*
web/node_modules/
@@ -8,3 +9,5 @@ web/dist/
*.test
coverage.out
/nmon-librenms-import
/internal/webui/dist/*
!/internal/webui/dist/.gitkeep

153
.golangci.yml Normal file
View File

@@ -0,0 +1,153 @@
# Enforces docs/MAINTAINABILITY.md. A hard rule: `make lint` must be clean for any gate to pass.
version: "2"
run:
timeout: 10m
tests: true
# Report every finding: funlen and gocognit flag the same line and must both show.
issues:
max-issues-per-linter: 0
max-same-issues: 0
uniq-by-line: false
linters:
default: none
enable:
- govet
- errcheck
- staticcheck
- unused
- ineffassign
- funlen
- cyclop
- gocognit
- nestif
- dupl
- lll
- errorlint
- usestdlibvars
- misspell
- revive
- depguard
- forbidigo
- nolintlint
- unconvert
- bodyclose
- copyloopvar
settings:
funlen:
lines: 80
statements: 50
ignore-comments: true
cyclop:
max-complexity: 15
gocognit:
min-complexity: 20
nestif:
min-complexity: 4
dupl:
threshold: 150
lll:
line-length: 140
misspell:
# MikroTik's OS name and a profile name, not a typo of "routers".
ignore-rules: [routeros]
nolintlint:
require-explanation: true
require-specific: true
revive:
rules:
- name: exported
arguments: ["disableStutteringCheck"]
- name: package-comments
- name: var-naming
- name: indent-error-flow
- name: error-strings
- name: receiver-naming
- name: unexported-return
forbidigo:
analyze-types: true
forbid:
- pattern: '^time\.(Now|Sleep|After|Tick|NewTicker|NewTimer)$'
msg: "use internal/clock so tests control time (docs/MAINTAINABILITY.md)"
depguard:
rules:
orm-only-in-store:
files: ["$all", "!**/internal/store/**"]
deny:
- pkg: github.com/uptrace/bun
desc: SQL and the ORM live only in internal/store
- pkg: database/sql
desc: SQL lives only in internal/store
app-only-from-cmd:
files: ["$all", "!**/cmd/**"]
deny:
- pkg: github.com/v10networks/nmon/internal/app
desc: only cmd/* wires the application
api-only-from-app:
files: ["$all", "!**/internal/app/**", "!**/internal/api/**", "!$test"]
deny:
- pkg: github.com/v10networks/nmon/internal/api
desc: only internal/app mounts the API
collection-paths-independent:
files:
- "**/internal/poller/**"
- "**/internal/discovery/**"
- "**/internal/traps/**"
- "**/internal/ingest/**"
- "**/internal/checks/**"
- "**/internal/rollup/**"
- "**/internal/alert/**"
deny:
- pkg: github.com/v10networks/nmon/internal/poller
desc: collection paths share code via a lower package, not each other
- pkg: github.com/v10networks/nmon/internal/discovery
desc: collection paths share code via a lower package, not each other
- pkg: github.com/v10networks/nmon/internal/traps
desc: collection paths share code via a lower package, not each other
devtools-not-in-nmon:
# profile_record.go (`nmon profile record`, M2) already uses the recording types and writer; nothing else may.
files: ["**/cmd/nmon/**", "!**/cmd/nmon/profile_record.go", "!$test"]
deny:
- pkg: github.com/v10networks/nmon/internal/simagent
desc: the simulator belongs to cmd/nmon-devtools and tests, never the shipped binary
- pkg: github.com/v10networks/nmon/internal/fixturegen
desc: the fixture generator belongs to cmd/nmon-devtools and tests, never the shipped binary
devtools-no-core:
files: ["**/cmd/nmon-devtools/**"]
deny:
- pkg: github.com/v10networks/nmon/internal/app
desc: nmon-devtools talks to a running nmon over HTTP
- pkg: github.com/v10networks/nmon/internal/api
desc: nmon-devtools talks to a running nmon over HTTP
importer-not-in-nmon:
files: ["**/cmd/nmon/**"]
deny:
- pkg: github.com/v10networks/nmon/internal/librenms
desc: the LibreNMS importer is a separate tool (DEVIATIONS 9)
exclusions:
paths:
- web/node_modules
rules:
# Table-driven tests are long by nature; every other linter still applies.
- path: _test\.go
linters: [funlen, cyclop, gocognit, nestif, dupl, lll, forbidigo]
# The clock package wraps time; cmd/ and the test-only simulator may use real time.
- path: internal/clock/
linters: [forbidigo]
- path: cmd/
linters: [forbidigo]
# A package is allowed to import itself's own test helpers and itself.
- path: internal/(poller|discovery|traps)/.*_test\.go
linters: [depguard]
formatters:
enable:
- gofmt
- goimports
exclusions:
paths:
- web/node_modules

View File

@@ -1,24 +1,33 @@
GO ?= go
TAGS ?= vmembed
LDFLAGS := -s -w
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
BUILD ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)-$(shell date -u +%Y%m%d)
LDFLAGS := -s -w -X github.com/v10networks/nmon/internal/buildinfo.Version=$(VERSION) -X github.com/v10networks/nmon/internal/buildinfo.Build=$(BUILD)
BIN := nmon
PG_DSN ?= postgres://nmon:nmon@localhost:55432/nmon?sslmode=disable
.PHONY: all build importer build-arm64 build-noembed fetch-vm web test test-integration test-pg test-v6only vet gate-m1 gate-m2 gate-m3 gate-m4 gate-m5 gate-m6 clean
.PHONY: devtools all lint lint-go lint-web build importer build-arm64 build-noembed fetch-vm web test test-integration test-pg test-v6only vet gate-m1 gate-m2 gate-m3 gate-m4 gate-m5 gate-m6 clean
all: build
fetch-vm:
./scripts/fetch-vm.sh
# The UI is built first when web/ exists so embed.FS picks it up.
# The UI is built first, then copied into internal/webui/dist for go:embed (git-ignored).
web:
@if [ -f web/package.json ]; then cd web && npm ci --no-audit --no-fund && npm run build; fi
@if [ -f web/package.json ]; then \
cd web && { [ -d node_modules ] || npm ci --no-audit --no-fund; } && npm run build && \
cd .. && rm -rf internal/webui/dist/* && cp -r web/dist/. internal/webui/dist/ && touch internal/webui/dist/.gitkeep; \
fi
build: fetch-vm
build: fetch-vm web
CGO_ENABLED=0 $(GO) build -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/nmon
build-arm64: fetch-vm
# Developer tools (backfill, seed-demo) are a separate binary that is never shipped.
devtools:
CGO_ENABLED=0 $(GO) build -o nmon-devtools ./cmd/nmon-devtools
build-arm64: fetch-vm web
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o $(BIN)-arm64 ./cmd/nmon
importer:
@@ -27,6 +36,18 @@ importer:
build-noembed:
CGO_ENABLED=0 $(GO) build -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/nmon
GOLANGCI := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0
# Hard rule (docs/MAINTAINABILITY.md): every gate runs this first and it must be clean.
lint: lint-go lint-web
lint-go:
$(GOLANGCI) run ./...
./scripts/lint-filesize.sh
lint-web:
@if [ -f web/package.json ]; then cd web && npm run lint; fi
vet:
$(GO) vet ./...
@@ -47,4 +68,4 @@ gate-m1 gate-m2 gate-m3 gate-m4 gate-m5 gate-m6: gate-m%:
./scripts/gate-m$*.sh
clean:
rm -f $(BIN) $(BIN)-arm64 nmon-librenms-import
rm -f $(BIN) $(BIN)-arm64 nmon-devtools nmon-librenms-import

View File

@@ -0,0 +1,162 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"strings"
"time"
"github.com/v10networks/nmon/internal/metrics"
)
// backfillConfig is what one `devtools backfill` run writes, for every interface in ifaces.
type backfillConfig struct {
hotURL string
device string
ifaces []string
days int
step time.Duration
pattern string
meanBps float64 // bits/s; 0 derives the mean from the live rate, else a stable per-interface value
seed uint64
hc *http.Client
logf func(format string, a ...any)
}
func backfillCmd(ctx context.Context, args []string) error {
cfg := backfillConfig{hc: &http.Client{Timeout: 2 * time.Minute}, logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) }}
fs := devFlagSet("backfill")
var ifaces stringList
var mbps float64
fs.StringVar(&cfg.hotURL, "hot-url", "", "base URL of the hot VictoriaMetrics (required; see \"metric stores up\" in the serve log)")
fs.StringVar(&cfg.device, "device", "", "device slug (the series' device label)")
fs.Var(&ifaces, "iface", "interface ULID; repeat the flag or separate with commas")
fs.IntVar(&cfg.days, "days", 31, "days of history to write, ending where the live data begins")
fs.DurationVar(&cfg.step, "step", 60*time.Second, "sample spacing")
fs.StringVar(&cfg.pattern, "pattern", patternDiurnal, "traffic shape: diurnal or flat")
fs.Float64Var(&mbps, "mean-mbps", 0, "mean rate in Mbit/s; 0 follows the live rate when there is one")
fs.Uint64Var(&cfg.seed, "seed", 1, "noise seed")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
cfg.ifaces, cfg.meanBps = ifaces, mbps*1e6
if err := cfg.validate(); err != nil {
return err
}
for _, id := range cfg.ifaces {
if err := cfg.backfillIface(ctx, id); err != nil {
return fmt.Errorf("iface %s: %w", id, err)
}
}
return nil
}
func (c *backfillConfig) validate() error {
switch {
case c.hotURL == "":
return errors.New("--hot-url is required")
case c.device == "" || len(c.ifaces) == 0:
return errors.New("--device and at least one --iface are required")
case c.days < 1 || c.step < time.Second:
return errors.New("--days must be at least 1 and --step at least 1s")
case c.pattern != patternDiurnal && c.pattern != patternFlat:
return fmt.Errorf("unknown --pattern %q (diurnal or flat)", c.pattern)
}
c.hotURL = strings.TrimRight(c.hotURL, "/")
return nil
}
// liveSeries is what the hot store already holds for one series: the first
// sample (where history must end and which value it must join) and the live rate.
type liveSeries struct {
ts time.Time
val float64
rate float64 // units per second between the first two samples; 0 when unknown
ok bool
}
// backfillIface writes in/out octet counters and the oper status for one interface.
func (c *backfillConfig) backfillIface(ctx context.Context, iface string) error {
n := c.days * int(24*time.Hour/c.step)
var samples []metrics.Sample
var end time.Time
for d, dir := range []struct {
metric string
salt uint64
}{{"ifHCInOctets", 0x1111}, {"ifHCOutOctets", 0x2222}} {
live, err := c.probe(ctx, dir.metric, iface)
if err != nil {
return err
}
if d == 0 {
end = c.endTime(live)
}
samples = append(samples, c.counter(dir.metric, iface, end, n, live, c.seed^dir.salt)...)
}
status := 1.0
if live, err := c.probe(ctx, "ifOperStatus", iface); err == nil && live.ok {
status = live.val
}
for i := 0; i < n; i++ {
ts := end.Add(-time.Duration(n-1-i) * c.step)
samples = append(samples, metrics.New("ifOperStatus", status, ts, metrics.LabelDevice, c.device, metrics.LabelIface, iface))
}
if err := c.send(ctx, samples); err != nil {
return err
}
c.logf("backfilled %s/%s: %d days at %s (%d samples)", c.device, iface, c.days, c.step, len(samples))
return nil
}
// endTime is one step before the first live sample, or one step ago when there is none.
func (c *backfillConfig) endTime(live liveSeries) time.Time {
if live.ok {
return live.ts.Add(-c.step).Truncate(time.Millisecond)
}
return time.Now().Add(-c.step).Truncate(time.Millisecond)
}
// counter builds one direction's history ending at end. When the live counter
// is known and large enough, the history is shifted so its last value is where
// the live counter was one step earlier and the join shows no reset.
func (c *backfillConfig) counter(metric, iface string, end time.Time, n int, live liveSeries, salt uint64) []metrics.Sample {
mean := c.meanBps / 8
if mean == 0 && live.rate > 0 {
mean = live.rate
}
if mean == 0 {
mean = defaultMeanBytesPerSec(iface, salt)
}
start := end.Add(-time.Duration(n-1) * c.step)
vals := counterSeries(c.pattern, start, n, c.step, mean, salt)
var offset float64
if live.ok {
offset = live.val - mean*c.step.Seconds() - vals[n-1]
if offset < 0 {
c.logf("warning: %s %s: live counter %.3g is below the %.3g bytes of history; the join will look like a counter reset",
c.device, metric, live.val, vals[n-1])
offset = 0
}
}
out := make([]metrics.Sample, n)
for i, v := range vals {
ts := start.Add(time.Duration(i) * c.step)
out[i] = metrics.New(metric, math.Floor(v+offset), ts, metrics.LabelDevice, c.device, metrics.LabelIface, iface)
}
return out
}
// probe reads the earliest samples of metric{iface=...} from the hot store.
func (c *backfillConfig) probe(ctx context.Context, metric, iface string) (liveSeries, error) {
q := url.Values{"match[]": {fmt.Sprintf(`%s{iface=%q}`, metric, iface)}}
var raw bytes.Buffer
if err := c.get(ctx, c.hotURL+"/api/v1/export?"+q.Encode(), &raw); err != nil {
return liveSeries{}, err
}
return parseExport(raw.Bytes())
}

130
cmd/nmon-devtools/fleet.go Normal file
View File

@@ -0,0 +1,130 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/gosnmp/gosnmp"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/simagent"
)
// demoDevice is one simulated device of `devtools seed-demo`.
type demoDevice struct {
name, site, role string
kind string // counters, big, router, ups, v1, down: what the gate and screenshots look for
rec *simagent.Recording
community string
version string // v2c unless set
hc bool // 64-bit counters, so history can be back-filled
}
// counterBase is added to every simulated 64-bit octet counter so that the
// back-filled history (about 3e13 bytes for 31 days at 100 Mbit/s) fits below
// the live counter and the join shows no reset.
const counterBase = 100_000_000_000_000
const (
oidHCIn = "1.3.6.1.2.1.31.1.1.1.6."
oidHCOut = "1.3.6.1.2.1.31.1.1.1.10."
oidUp = "1.3.6.1.2.1.1.3.0"
)
// animate returns a copy of rec whose 64-bit octet counters advance at a steady
// per-port rate from counterBase and whose uptime ticks, so successive polls see
// traffic. Counters that already advance keep their rate and only gain the base.
func animate(rec *simagent.Recording) *simagent.Recording {
recs := append([]simagent.Record(nil), rec.Records()...)
for i := range recs {
r := &recs[i]
switch {
case r.Type == gosnmp.Counter64 && (strings.HasPrefix(r.OID, oidHCIn) || strings.HasPrefix(r.OID, oidHCOut)):
recs[i] = animateCounter(*r)
case r.OID == oidUp && r.Dyn == nil:
if v, ok := r.Value.(uint32); ok {
r.Dyn, r.Value = &simagent.Dynamic{Rate: 100, Initial: uint64(v)}, nil
}
}
}
return simagent.NewRecording(recs)
}
func animateCounter(r simagent.Record) simagent.Record {
if r.Dyn != nil {
d := *r.Dyn
d.Initial += counterBase
r.Dyn = &d
return r
}
k, _ := strconv.Atoi(r.OID[strings.LastIndexByte(r.OID, '.')+1:])
dir := 1
if strings.HasPrefix(r.OID, oidHCOut) {
dir = 2
}
mbps := 1 + (k*(31+7*dir))%90
r.Dyn = &simagent.Dynamic{Rate: float64(mbps) * 125_000, Initial: counterBase + uint64(k)*1_000_000_000}
r.Value = nil
return r
}
// profileDevice builds a device that a shipped profile matches, with traffic.
func profileDevice(set *profile.Set, prof, name, site, role, kind string, ports int) (demoDevice, error) {
spec, ok := fixturegen.SpecFor(prof)
if !ok {
return demoDevice{}, fmt.Errorf("no fixture spec for profile %q", prof)
}
res, ok := set.Lookup(prof)
if !ok {
return demoDevice{}, fmt.Errorf("no profile %q", prof)
}
spec.SysName = name
if ports > 0 {
spec.Ports = ports
}
rec, err := fixturegen.FromProfile(res, spec)
if err != nil {
return demoDevice{}, fmt.Errorf("%s: %w", name, err)
}
return demoDevice{name: name, site: site, role: role, kind: kind, rec: animate(rec), community: "public", hc: true}, nil
}
// buildFleet is the small network used for live screenshots: a 48-port switch,
// routers, a firewall, a UPS, one 32-bit device, the counters fixture and one
// device whose agent is stopped after seeding, so it reads as down.
func buildFleet(set *profile.Set, countersRec string) ([]demoDevice, error) {
counters, err := simagent.LoadRecording(countersRec)
if err != nil {
return nil, err
}
fleet := []demoDevice{{
name: "yyz1-edge1", site: "yyz1", role: "edge", kind: "counters", rec: animate(counters), community: "public", hc: true,
}}
for _, p := range []struct {
prof, name, site, role, kind string
ports int
}{
{"cisco-ios", "yyz1-acc01", "yyz1", "access", "big", 48},
{"cisco-ios", "yyz1-acc02", "yyz1", "access", "router", 24},
{"cisco-ios", "ott1-acc02", "ott1", "access", "down", 24}, // its agent is stopped once seeding is done
{"juniper-junos", "yyz1-core1", "yyz1", "core", "router", 0},
{"arista-eos", "yul1-core1", "yul1", "core", "router", 0},
{"mikrotik-routeros", "ott1-gw1", "ott1", "gateway", "router", 0},
{"fortigate", "yyz1-fw1", "yyz1", "firewall", "router", 0},
{"apc-ups", "yyz1-ups1", "yyz1", "power", "ups", 0},
} {
d, err := profileDevice(set, p.prof, p.name, p.site, p.role, p.kind, p.ports)
if err != nil {
return nil, err
}
fleet = append(fleet, d)
}
// 32-bit counters on 1 Gbit/s ports: the API flags them unreliable and the UI never graphs them.
v1 := fixturegen.Generic(fixturegen.Options{Ports: 12, SysName: "yul1-acc03", Counters: true, NamePattern: "Fa0/%d"})
fleet = append(fleet, demoDevice{
name: "yul1-acc03", site: "yul1", role: "access", kind: "v1", rec: v1, community: "nmon-v1", version: "v1",
})
return fleet, nil
}

119
cmd/nmon-devtools/main.go Normal file
View File

@@ -0,0 +1,119 @@
// Command nmon-devtools holds developer helpers for the gates and for screenshots: backfill (synthetic
// history into the hot store) and seed-demo (a simulated network added to a running nmon). It talks to nmon
// only through its HTTP API and to the hot store through --hot-url, and is never shipped (docs/dev.md).
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
var errUsage = errors.New("usage: nmon-devtools backfill|seed-demo [flags] (see docs/dev.md)")
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "nmon-devtools:", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "backfill":
return backfillCmd(ctx, args[1:])
case "seed-demo":
return seedDemoCmd(ctx, args[1:])
default:
return fmt.Errorf("unknown command %q: %w", args[0], errUsage)
}
}
// flagErr turns flag.ErrHelp into a clean exit.
func flagErr(err error) error {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
return err
}
// stringList is a repeatable string flag.
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(v string) error {
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
*s = append(*s, p)
}
}
return nil
}
// devFlagSet is a FlagSet that prints its own usage to stderr.
func devFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet("nmon-devtools "+name, flag.ContinueOnError)
fs.SetOutput(os.Stderr)
return fs
}
// httpJSON sends an optional JSON body and decodes a 2xx JSON reply into out (nil to discard it).
func httpJSON(ctx context.Context, hc *http.Client, method, url string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = strings.NewReader(string(b))
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := hc.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }() // body read to the end below
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return err
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("%s %s: %s: %s", method, url, resp.Status, strings.TrimSpace(string(data[:min(len(data), 300)])))
}
if out == nil {
return nil
}
return json.Unmarshal(data, out)
}
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}

View File

@@ -0,0 +1,69 @@
package main
import (
"math"
"time"
)
// Synthetic traffic shapes for `nmon devtools backfill`. Everything here is a
// pure function of its inputs, so a back-fill is reproducible and testable.
const (
patternDiurnal = "diurnal"
patternFlat = "flat"
)
// splitmix64 is a tiny deterministic hash used for noise; math/rand would make
// two runs differ and a global source would be shared state.
func splitmix64(x uint64) uint64 {
x += 0x9e3779b97f4a7c15
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9
x = (x ^ (x >> 27)) * 0x94d049bb133111eb
return x ^ (x >> 31)
}
// unit maps a hash to [0, 1).
func unit(h uint64) float64 { return float64(h>>11) / (1 << 53) }
// rateFactor is the multiple of the mean rate at t: 1 for "flat"; for
// "diurnal" an evening peak (about 20:00 UTC), an early-morning trough, quieter
// weekends, per-sample noise and the odd short burst.
func rateFactor(pattern string, t time.Time, salt uint64) float64 {
if pattern == patternFlat {
return 1
}
h := float64(t.Hour()) + float64(t.Minute())/60
f := 1 + 0.6*math.Cos(2*math.Pi*(h-20)/24)
if wd := t.Weekday(); wd == time.Saturday || wd == time.Sunday {
f *= 0.8
}
slot := uint64(t.Unix() / 60)
f *= 0.88 + 0.24*unit(splitmix64(salt^slot))
if unit(splitmix64(salt^(slot/4)^0xb5ad4eceda1ce2a9)) < 0.01 { // ~1% of 4-minute slots burst
f *= 2.5
}
return math.Max(f, 0.05)
}
// counterSeries returns n cumulative octet-counter values, one per step from
// start, beginning at 0 and never decreasing. mean is bytes per second.
func counterSeries(pattern string, start time.Time, n int, step time.Duration, mean float64, salt uint64) []float64 {
out := make([]float64, n)
var total float64
for i := 1; i < n; i++ {
t := start.Add(time.Duration(i) * step)
total += mean * rateFactor(pattern, t, salt) * step.Seconds()
out[i] = math.Floor(total)
}
return out
}
// defaultMeanBytesPerSec is a stable per-interface mean (2 to 60 Mbit/s) for when
// the live rate is unknown.
func defaultMeanBytesPerSec(iface string, salt uint64) float64 {
h := salt
for _, c := range []byte(iface) {
h = splitmix64(h ^ uint64(c))
}
return (2 + 58*unit(h)*unit(h>>7)) * 125_000
}

View File

@@ -0,0 +1,56 @@
package main
import (
"testing"
"time"
)
func TestCounterSeriesIsMonotonicAndDeterministic(t *testing.T) {
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
a := counterSeries(patternDiurnal, start, 3*24*60, time.Minute, 1_000_000, 7)
b := counterSeries(patternDiurnal, start, 3*24*60, time.Minute, 1_000_000, 7)
for i := range a {
if a[i] != b[i] {
t.Fatalf("not deterministic at %d", i)
}
if i > 0 && a[i] < a[i-1] {
t.Fatalf("decreased at %d", i)
}
}
if a[0] != 0 {
t.Fatalf("starts at %v, want 0", a[0])
}
meanRate := a[len(a)-1] / (float64(len(a)-1) * 60)
if meanRate < 0.8e6 || meanRate > 1.4e6 {
t.Fatalf("mean rate %.0f B/s drifted from 1e6", meanRate)
}
}
func TestDiurnalEveningBusierThanNight(t *testing.T) {
// Monday, so the weekend factor does not interfere. Averaged over many days the noise washes out.
var night, evening float64
for d := 0; d < 28; d++ {
day := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC).AddDate(0, 0, d)
if wd := day.Weekday(); wd == time.Saturday || wd == time.Sunday {
continue
}
night += rateFactor(patternDiurnal, day.Add(5*time.Hour), 1)
evening += rateFactor(patternDiurnal, day.Add(20*time.Hour), 1)
}
if evening < 2*night {
t.Fatalf("evening %.2f is not clearly busier than night %.2f", evening, night)
}
}
func TestFlatPatternIsConstant(t *testing.T) {
if f := rateFactor(patternFlat, time.Now(), 3); f != 1 {
t.Fatalf("flat factor %v", f)
}
}
func TestDefaultMeanIsStableAndBounded(t *testing.T) {
m := defaultMeanBytesPerSec("01abc", 1)
if m != defaultMeanBytesPerSec("01abc", 1) || m < 2*125_000 || m > 60*125_000 {
t.Fatalf("mean %v", m)
}
}

242
cmd/nmon-devtools/seed.go Normal file
View File

@@ -0,0 +1,242 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/simagent"
)
// seedConfig is the command line of `devtools seed-demo`.
type seedConfig struct {
api, hotURL string
days int
step time.Duration
countersRec string
envOut string
profilesDir string
workers int
hc *http.Client
}
// seeded is one device after it was added to nmon.
type seeded struct {
demoDevice
slug string
agent *simagent.Agent
ip string
ifaces []seedIface
}
type seedIface struct {
ID string `json:"id"`
Name string `json:"ifname"`
HC bool `json:"has_hc_counters"`
Excluded bool `json:"excluded"`
Unreliable bool `json:"unreliable"`
}
func (i seedIface) graphable() bool { return i.HC && !i.Excluded && !i.Unreliable }
func seedDemoCmd(ctx context.Context, args []string) error {
cfg := seedConfig{hc: &http.Client{Timeout: 3 * time.Minute}}
fs := devFlagSet("seed-demo")
fs.StringVar(&cfg.api, "api", "http://127.0.0.1:8443", "base URL of the running nmon (started with --insecure-http)")
fs.StringVar(&cfg.hotURL, "hot-url", "", "base URL of the hot VictoriaMetrics, for the back-fill (required)")
fs.IntVar(&cfg.days, "days", 31, "days of history to back-fill")
fs.DurationVar(&cfg.step, "step", 60*time.Second, "back-fill sample spacing")
fs.StringVar(&cfg.countersRec, "counters-rec", "testdata/snmprec/counters.snmprec", "the counters fixture recording")
fs.StringVar(&cfg.envOut, "env-out", "", "write NMON_DEVICE=<counters slug> and NMON_DEVICE_48=<48-port slug> to this file when ready")
fs.StringVar(&cfg.profilesDir, "profiles-dir", "", "operator profiles directory (shipped profiles are always loaded)")
fs.IntVar(&cfg.workers, "workers", 6, "parallel back-fill workers")
if err := fs.Parse(args); err != nil {
return flagErr(err)
}
if cfg.hotURL == "" {
return errors.New("--hot-url is required")
}
cfg.api, cfg.hotURL = strings.TrimRight(cfg.api, "/"), strings.TrimRight(cfg.hotURL, "/")
return cfg.run(ctx)
}
func (c *seedConfig) run(ctx context.Context) error {
set, err := profile.Load(c.profilesDir)
if err != nil {
return err
}
fleet, err := buildFleet(set, c.countersRec)
if err != nil {
return err
}
var pool simagent.Pool
defer func() { _ = pool.Close() }() // process is exiting
added, err := c.addAll(ctx, &pool, fleet)
if err != nil {
return err
}
if err := c.backfillAll(ctx, added); err != nil {
return err
}
if err := c.writeEnv(added); err != nil {
return err
}
fmt.Println("seed-demo ready: agents keep answering until interrupted")
<-ctx.Done()
return nil
}
// addAll starts one agent per device on [::1] with its own port and adds the
// device through the API with that port.
func (c *seedConfig) addAll(ctx context.Context, pool *simagent.Pool, fleet []demoDevice) ([]seeded, error) {
var out []seeded
for i, d := range fleet {
cfg := simagent.Config{Addr: "[::1]:0", Recording: d.rec, Community: d.community}
if err := pool.Start(ctx, []simagent.Config{cfg}); err != nil {
return nil, fmt.Errorf("%s: %w", d.name, err)
}
agent := pool.Agents()[i]
s, err := c.addDevice(ctx, d, "::1", int(agent.Addr().Port()))
s.agent = agent
if err != nil {
return nil, fmt.Errorf("add %s: %w", d.name, err)
}
fmt.Printf("added %-12s %-10s %s (%d interfaces)\n", d.name, d.kind, s.slug, len(s.ifaces))
out = append(out, s)
}
return out, nil
}
func (c *seedConfig) addDevice(ctx context.Context, d demoDevice, ip string, port int) (seeded, error) {
version := d.version
if version == "" {
version = "v2c"
}
body := map[string]any{
"name": d.name, "addresses": []string{ip}, "site": d.site, "role": d.role,
"snmp": map[string]any{"version": version, "community": d.community, "port": port},
}
var created struct {
Slug string `json:"slug"`
}
if err := httpJSON(ctx, c.hc, http.MethodPost, c.api+"/api/v1/devices", body, &created); err != nil {
return seeded{}, err
}
s := seeded{demoDevice: d, slug: created.Slug, ip: ip}
var got struct {
Interfaces []seedIface `json:"interfaces"`
}
if err := httpJSON(ctx, c.hc, http.MethodGet, c.api+"/api/v1/devices/"+url.PathEscape(created.Slug), nil, &got); err != nil {
return seeded{}, err
}
if len(got.Interfaces) == 0 {
return seeded{}, errors.New("discovery found no interfaces")
}
s.ifaces = got.Interfaces
return s, nil
}
// backfillAll waits for each device's first live samples, then writes history
// for every graphable interface in parallel.
func (c *seedConfig) backfillAll(ctx context.Context, devs []seeded) error {
type job struct{ dev, iface string }
var jobs []job
probe := &backfillConfig{hotURL: c.hotURL, hc: c.hc}
for _, d := range devs {
for _, i := range d.ifaces {
if d.hc && i.graphable() {
jobs = append(jobs, job{d.slug, i.ID})
}
}
if err := c.waitLive(ctx, probe, d); err != nil {
return err
}
if d.kind == "down" {
_ = d.agent.Close() // goes silent now, so it reads as down by the time the screenshots are taken
}
}
ch := make(chan job)
errs := make(chan error, len(jobs))
var wg sync.WaitGroup
for w := 0; w < max(1, c.workers); w++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range ch {
cfg := &backfillConfig{hotURL: c.hotURL, device: j.dev, days: c.days, step: c.step, pattern: patternDiurnal,
seed: 1, hc: c.hc, logf: func(string, ...any) {}}
errs <- cfg.backfillIface(ctx, j.iface)
}
}()
}
for _, j := range jobs {
ch <- j
}
close(ch)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
return err
}
}
fmt.Printf("back-filled %d interfaces, %d days at %s\n", len(jobs), c.days, c.step)
return nil
}
// waitLive blocks until the hot store holds two live samples of the device's
// first graphable interface, so the back-fill can end where they begin.
func (c *seedConfig) waitLive(ctx context.Context, probe *backfillConfig, d seeded) error {
if !d.hc {
return nil
}
var first string
for _, i := range d.ifaces {
if i.graphable() {
first = i.ID
break
}
}
if first == "" {
return nil
}
deadline := time.Now().Add(3 * time.Minute)
for time.Now().Before(deadline) {
live, err := probe.probe(ctx, "ifHCInOctets", first)
if err == nil && live.ok && live.rate > 0 {
return nil
}
if !sleepCtx(ctx, 2*time.Second) {
return ctx.Err()
}
}
return fmt.Errorf("%s: no live samples in the hot store after 3 minutes (is nmon polling? --poll-interval)", d.slug)
}
func (c *seedConfig) writeEnv(devs []seeded) error {
var b bytes.Buffer
for _, d := range devs {
switch d.kind {
case "counters":
fmt.Fprintf(&b, "NMON_DEVICE=%s\n", d.slug)
case "big":
fmt.Fprintf(&b, "NMON_DEVICE_48=%s\n", d.slug)
case "v1":
fmt.Fprintf(&b, "NMON_DEVICE_32BIT=%s\n", d.slug)
case "down":
fmt.Fprintf(&b, "NMON_DEVICE_DOWN=%s\n", d.slug)
}
}
if c.envOut == "" {
return nil
}
return os.WriteFile(c.envOut, b.Bytes(), 0o600)
}

View File

@@ -0,0 +1,84 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/v10networks/nmon/internal/metrics"
)
// parseExport reads VictoriaMetrics' /api/v1/export (JSON lines) and returns
// the earliest sample of the first series.
func parseExport(raw []byte) (liveSeries, error) {
line, _, _ := bytes.Cut(bytes.TrimSpace(raw), []byte("\n"))
if len(line) == 0 {
return liveSeries{}, nil
}
var s struct {
Values []float64 `json:"values"`
Timestamps []int64 `json:"timestamps"`
}
if err := json.Unmarshal(line, &s); err != nil {
return liveSeries{}, fmt.Errorf("decode export: %w", err)
}
if len(s.Values) == 0 || len(s.Values) != len(s.Timestamps) {
return liveSeries{}, nil
}
out := liveSeries{ts: time.UnixMilli(s.Timestamps[0]).UTC(), val: s.Values[0], ok: true}
if len(s.Values) > 1 && s.Timestamps[1] > s.Timestamps[0] {
out.rate = (s.Values[1] - s.Values[0]) / (float64(s.Timestamps[1]-s.Timestamps[0]) / 1000)
}
return out, nil
}
func (c *backfillConfig) get(ctx context.Context, u string, dst io.Writer) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }() // read to the end below
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return fmt.Errorf("GET %s: %s: %s", u, resp.Status, b)
}
_, err = io.Copy(dst, resp.Body)
return err
}
// importBatch is how many samples go in one POST (about 5 MB of text).
const importBatch = 50_000
// send writes samples through /api/v1/import/prometheus in batches.
func (c *backfillConfig) send(ctx context.Context, samples []metrics.Sample) error {
for len(samples) > 0 {
n := min(len(samples), importBatch)
body := make([]byte, 0, n*110)
for _, s := range samples[:n] {
body = metrics.AppendPromText(body, s)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.hotURL+"/api/v1/import/prometheus", bytes.NewReader(body))
if err != nil {
return err
}
resp, err := c.hc.Do(req)
if err != nil {
return err
}
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
_ = resp.Body.Close() // reply already read
if resp.StatusCode/100 != 2 {
return fmt.Errorf("import: %s: %s", resp.Status, msg)
}
samples = samples[n:]
}
return nil
}

View File

@@ -0,0 +1,110 @@
// Command nmon-librenms-import turns LibreNMS OS definitions into draft nmon
// profiles. It runs offline, before nmon; nmon itself neither links nor exposes it.
//
// nmon-librenms-import --librenms <checkout> --out <dir> [--os junos,ios,...] [--mibs-dir <dir>]...
//
// The drafts hold only type: unverified metrics. Review them, copy the ones you
// want into <data-dir>/profiles/ and confirm them with `nmon profile test`.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"github.com/v10networks/nmon/internal/cliout"
"github.com/v10networks/nmon/internal/librenms"
)
type listFlag []string
func (l *listFlag) String() string { return strings.Join(*l, ",") }
func (l *listFlag) Set(v string) error {
for _, s := range strings.Split(v, ",") {
if s = strings.TrimSpace(s); s != "" {
*l = append(*l, s)
}
}
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "nmon-librenms-import:", err)
os.Exit(1)
}
}
func run(args []string) error {
fs := flag.NewFlagSet("nmon-librenms-import", flag.ContinueOnError)
var (
root, out string
oses listFlag
mibDirs listFlag
)
fs.StringVar(&root, "librenms", "", "path of a LibreNMS checkout (required)")
fs.StringVar(&out, "out", "", "directory to write <os>.yaml drafts into (required)")
fs.Var(&oses, "os", "comma-separated LibreNMS OS names, e.g. junos,ios (default: every OS)")
fs.Var(&mibDirs, "mibs-dir", "extra MIB directory searched before the checkout's mibs/; repeatable")
fs.Usage = func() {
// Usage text is best-effort; a failed write to the terminal has nowhere to go.
_, _ = fmt.Fprintln(fs.Output(), "usage: nmon-librenms-import --librenms DIR --out DIR [--os a,b] [--mibs-dir DIR]...")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if root == "" || out == "" || fs.NArg() > 0 {
fs.Usage()
return errors.New("--librenms and --out are required")
}
imp, err := librenms.New(librenms.Options{LibreNMS: root, MIBDirs: mibDirs})
if err != nil {
return err
}
if len(oses) == 0 {
if oses, err = imp.OSList(); err != nil {
return err
}
}
if err := os.MkdirAll(out, 0o755); err != nil {
return err
}
return importAll(imp, oses, out, os.Stderr)
}
// importAll imports each OS, writes its draft into out and prints a summary
// table to w. Output failures count as failures of the run.
func importAll(imp *librenms.Importer, oses []string, out string, w io.Writer) error {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
cw := cliout.New(tw)
cw.Println("OS\tPROFILE\tMAPPED\tLABELS\tSKIPPED")
failed := 0
for _, name := range oses {
d, err := imp.Import(name)
if err != nil {
cw.Printf("%s\tERROR\t\t\t%v\n", name, err)
failed++
continue
}
if err := os.WriteFile(filepath.Join(out, d.Name+".yaml"), d.YAML, 0o644); err != nil {
return err
}
cw.Printf("%s\t%s\t%d\t%d\t%d\n", name, d.Name, d.Mapped, d.Helpers, len(d.Skipped))
}
if err := errors.Join(tw.Flush(), cw.Err()); err != nil {
return fmt.Errorf("write summary: %w", err)
}
if failed > 0 {
return fmt.Errorf("%d of %d OS definitions could not be imported", failed, len(oses))
}
return nil
}

View File

@@ -0,0 +1,42 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/v10networks/nmon/internal/profile"
)
func TestRunWritesLoadableDrafts(t *testing.T) {
root := filepath.Join("..", "..", "internal", "librenms", "testdata", "lnms")
out := t.TempDir()
if err := run([]string{"--librenms", root, "--out", out, "--os", "acme"}); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(out, "acme.yaml"))
if err != nil {
t.Fatal(err)
}
if _, err := profile.Parse("acme.yaml", b); err != nil {
t.Fatalf("%v\n%s", err, b)
}
if !strings.Contains(string(b), "type: unverified") {
t.Error("draft metrics must be unverified")
}
}
func TestRunErrors(t *testing.T) {
root := filepath.Join("..", "..", "internal", "librenms", "testdata", "lnms")
for _, args := range [][]string{
{},
{"--librenms", root},
{"--librenms", t.TempDir(), "--out", t.TempDir()},
{"--librenms", root, "--out", t.TempDir(), "--os", "nosuchos"},
} {
if err := run(args); err == nil {
t.Errorf("%v: expected an error", args)
}
}
}

124
cmd/nmon/discover.go Normal file
View File

@@ -0,0 +1,124 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/v10networks/nmon/internal/cliout"
)
func init() {
register("discover", "run discovery for a device now (asks a running nmon)", discoverCmd)
}
// discoverReport mirrors the API's DiscoverView.
type discoverReport struct {
Profile string `json:"profile"`
ProfileMissing string `json:"profile_missing"`
Partial bool `json:"partial"`
Tables []struct {
Name, Status, Detail string
Rows int
} `json:"tables"`
Matched int `json:"interfaces_matched"`
New int `json:"interfaces_new"`
Retired int `json:"interfaces_retired"`
Split int `json:"interfaces_split"`
Unretired int `json:"interfaces_unretired"`
Reviews int `json:"identity_reviews_opened"`
HardwareReplaced bool `json:"hardware_replaced"`
Warnings []string `json:"warnings"`
}
func discoverCmd(ctx context.Context, args []string) error {
fs := profileFlagSet("discover", "discover <slug> [--api URL]")
fs.Usage = func() {
// Usage text is best-effort; a failed write to the terminal has nowhere to go.
_, _ = fmt.Fprintln(os.Stderr, "usage: nmon discover <slug> [--api URL] [--data-dir DIR]\n\n"+
"Asks the running nmon at --api to discover the device now and prints what changed.")
fs.PrintDefaults()
}
var api, dataDir string
var timeout time.Duration
fs.StringVar(&api, "api", "http://[::1]:8443", "base URL of the running nmon")
fs.StringVar(&dataDir, "data-dir", "", "accepted for symmetry with serve; the API of the running server is used")
fs.DurationVar(&timeout, "timeout", 60*time.Second, "how long to wait for the discovery")
pos, err := parseInterspersed(fs, args)
if err != nil {
return flagErr(err)
}
if len(pos) != 1 {
return fmt.Errorf("usage: nmon discover <slug> [--api URL]")
}
rep, err := requestDiscover(ctx, api, pos[0], timeout)
if err != nil {
return err
}
return rep.print(os.Stdout, pos[0])
}
func requestDiscover(ctx context.Context, api, slug string, timeout time.Duration) (*discoverReport, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
u := strings.TrimRight(api, "/") + "/api/v1/devices/" + url.PathEscape(slug) + "/discover"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(nil))
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot reach nmon at %s (is it running? use --api): %w", api, err)
}
defer func() { _ = resp.Body.Close() }() // body fully read below; a close error changes nothing
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode != http.StatusOK {
var p struct{ Title, Detail string }
_ = json.Unmarshal(body, &p)
msg := p.Detail
if msg == "" {
msg = p.Title
}
return nil, fmt.Errorf("%s: %d %s", slug, resp.StatusCode, msg)
}
var rep discoverReport
if err := json.Unmarshal(body, &rep); err != nil {
return nil, err
}
return &rep, nil
}
func (r *discoverReport) print(out io.Writer, slug string) error {
w := cliout.New(out)
w.Printf("discovered %s: profile %s\n", slug, r.Profile)
if r.ProfileMissing != "" {
w.Printf(" profile %q no longer exists; generic is in use\n", r.ProfileMissing)
}
w.Printf(" interfaces: %d matched, %d new, %d retired, %d unretired, %d split\n",
r.Matched, r.New, r.Retired, r.Unretired, r.Split)
if r.Reviews > 0 {
w.Printf(" %d identity review(s) opened: nothing was merged automatically\n", r.Reviews)
}
if r.HardwareReplaced {
w.Println(" hardware replaced: chassis serial changed, history continues (review opened)")
}
for _, t := range r.Tables {
if t.Status != "ok" {
w.Printf(" table %s: %s %s\n", t.Name, t.Status, t.Detail)
}
}
if r.Partial {
w.Println(" PARTIAL: a table overran its limit; its previous result was kept")
}
for _, x := range r.Warnings {
w.Printf(" warning: %s\n", x)
}
return w.Err()
}

View File

@@ -44,7 +44,7 @@ func migrate(ctx context.Context, args []string) error {
if err != nil {
return err
}
defer st.Close()
defer func() { _ = st.Close() }() // migrations are committed; a close error changes nothing
if action == "down" {
if err := st.Rollback(ctx); err != nil {
return err

25
cmd/nmon/openapi.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"context"
"fmt"
"os"
"github.com/v10networks/nmon/internal/app"
)
func init() {
register("openapi", "print the generated OpenAPI 3.1 document (nmon openapi > openapi.json)", openapiCmd)
}
func openapiCmd(_ context.Context, args []string) error {
if len(args) > 0 {
return fmt.Errorf("openapi takes no arguments, got %q", args[0])
}
spec, err := app.OpenAPISpec()
if err != nil {
return fmt.Errorf("generate spec: %w", err)
}
_, err = fmt.Fprintf(os.Stdout, "%s\n", spec)
return err
}

78
cmd/nmon/profile.go Normal file
View File

@@ -0,0 +1,78 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"strings"
)
func init() {
register("profile", "test, record and draft device profiles", profileCmd)
}
const profileUsage = `usage: nmon profile <subcommand> [flags]
subcommands:
new compile MIB objects into draft profile metrics (--mib MODULE)
record read-only SNMP walk of a device into a redacted .snmprec file
test discovery plus one poll with the matching profile
`
func profileCmd(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
fmt.Fprint(os.Stderr, profileUsage)
if len(args) == 0 {
os.Exit(2)
}
return nil
}
switch args[0] {
case "new":
return profileNew(ctx, args[1:], os.Stdout)
case "record":
return profileRecord(ctx, args[1:], os.Stdout)
case "test":
return profileTest(ctx, args[1:], os.Stdout)
}
fmt.Fprint(os.Stderr, profileUsage)
return fmt.Errorf("profile: unknown subcommand %q", args[0])
}
// listFlag is a repeatable string flag that also accepts comma-separated values.
type listFlag []string
func (l *listFlag) String() string { return strings.Join(*l, ",") }
func (l *listFlag) Set(v string) error {
for _, s := range strings.Split(v, ",") {
if s = strings.TrimSpace(s); s != "" {
*l = append(*l, s)
}
}
return nil
}
// parseInterspersed parses flags that may come before or after positional arguments.
func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) {
var pos []string
for {
if err := fs.Parse(args); err != nil {
return nil, err
}
args = fs.Args()
if len(args) == 0 {
return pos, nil
}
pos = append(pos, args[0])
args = args[1:]
}
}
// profileFlagSet returns a FlagSet that prints its usage to stderr.
func profileFlagSet(name, usage string) *flag.FlagSet {
fs := newFlagSet("profile "+name, usage)
fs.SetOutput(io.Writer(os.Stderr))
return fs
}

483
cmd/nmon/profile_new.go Normal file
View File

@@ -0,0 +1,483 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"github.com/v10networks/nmon/internal/mib"
)
const defaultSystemMIBs = "/usr/share/nmon/mibs"
// mibSearchPath returns the directories to read MIBs from: explicit --mibs-dir
// values, else <data-dir>/mibs followed by the system location.
func mibSearchPath(explicit []string, dataDir string) ([]string, error) {
if len(explicit) > 0 {
for _, d := range explicit {
if st, err := os.Stat(d); err != nil || !st.IsDir() {
return nil, fmt.Errorf("--mibs-dir %s: not a directory", d)
}
}
return explicit, nil
}
var out []string
var cands []string
if dataDir != "" {
cands = append(cands, filepath.Join(dataDir, "mibs"))
}
cands = append(cands, defaultSystemMIBs)
for _, d := range cands {
if st, err := os.Stat(d); err == nil && st.IsDir() {
out = append(out, d)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("no MIB directory found (looked in %s); pass --mibs-dir", strings.Join(cands, ", "))
}
return out, nil
}
func profileNew(_ context.Context, args []string, stdout io.Writer) error {
fs := profileFlagSet("new",
"profile new --mib MODULE [--table NAME] [--objects a,b] [--mibs-dir DIR]... [--data-dir DIR] [--out FILE]")
var (
module, tableName, out, dataDir string
objects, mibsDirs listFlag
)
fs.StringVar(&module, "mib", "", "MIB module to compile, e.g. IF-MIB (required)")
fs.StringVar(&tableName, "table", "", "compile every readable column of this table")
fs.Var(&objects, "objects", "comma-separated object names (NAME or MODULE::NAME); repeatable")
fs.Var(&mibsDirs, "mibs-dir",
"directory of MIB files, searched recursively; repeatable (default <data-dir>/mibs, then "+defaultSystemMIBs+")")
fs.StringVar(&dataDir, "data-dir", "", "nmon data folder; its mibs/ subfolder is searched")
fs.StringVar(&out, "out", "", "write the draft here instead of stdout")
rest, err := parseInterspersed(fs, args)
if err != nil {
return flagErr(err)
}
if len(rest) > 0 {
return fmt.Errorf("unexpected argument %q", rest[0])
}
if module == "" {
return fmt.Errorf("--mib is required")
}
path, err := mibSearchPath(mibsDirs, dataDir)
if err != nil {
return err
}
l := mib.NewLoader(path...)
draft, err := draftProfile(l, draftSpec{Module: module, Table: tableName, Objects: objects})
if err != nil {
return err
}
if out != "" {
return os.WriteFile(out, draft, 0o644)
}
_, err = stdout.Write(draft)
return err
}
type draftSpec struct {
Module string
Table string
Objects []string
}
// unitMap maps UNITS clauses (lower case) to the profile unit vocabulary, with
// the scale that converts the MIB's unit to it.
var unitMap = map[string]struct {
unit string
scale float64
}{
"seconds": {"seconds", 0}, "second": {"seconds", 0}, "secs": {"seconds", 0}, "sec": {"seconds", 0},
"minutes": {"minutes", 0}, "minute": {"minutes", 0},
"degrees celsius": {"celsius", 0}, "degrees centigrade": {"celsius", 0}, "celsius": {"celsius", 0},
"degrees c": {"celsius", 0}, "deg c": {"celsius", 0},
"volts": {"volts", 0}, "volt": {"volts", 0}, "millivolts": {"volts", 0.001}, "mv": {"volts", 0.001},
"amperes": {"amps", 0}, "amps": {"amps", 0}, "amp": {"amps", 0}, "milliamperes": {"amps", 0.001},
"milliamps": {"amps", 0.001}, "ma": {"amps", 0.001},
"watts": {"watts", 0}, "watt": {"watts", 0}, "milliwatts": {"watts", 0.001}, "mw": {"watts", 0.001},
"dbm": {"dbm", 0},
"rpm": {"rpm", 0}, "revolutions per minute": {"rpm", 0},
"percent": {"percent", 0}, "%": {"percent", 0}, "percentage": {"percent", 0},
"bytes": {"bytes", 0}, "octets": {"octets", 0}, "kilobytes": {"bytes", 1024}, "megabytes": {"bytes", 1048576},
"bits": {"bits", 0}, "bits per second": {"bps", 0}, "bps": {"bps", 0}, "kilobits per second": {"bps", 1000},
"packets": {"packets", 0}, "errors": {"errors", 0},
"hertz": {"hertz", 0}, "hz": {"hertz", 0},
}
var hintRE = regexp.MustCompile(`^d-([0-9]+)$`)
type draftMetric struct {
obj *mib.Object
label string // index_label column
labelAs string
comments []string
typ string
unit string
scale float64
enum map[int]string
}
// draftProfile compiles the selected objects into a complete profile document
// whose unit and interval are TODO so that profile.Parse refuses it until a human fills them in.
func draftProfile(l *mib.Loader, spec draftSpec) ([]byte, error) {
m, err := l.Load(spec.Module)
if err != nil {
return nil, err
}
objs, err := selectObjects(l, m, spec)
if err != nil {
return nil, err
}
return []byte(renderDraft(spec, m, draftMetrics(l, m, objs))), nil
}
// selectObjects picks the objects to compile: a table's readable columns, the
// named objects, or (when neither is given) every readable scalar and column.
// The result is sorted by OID.
func selectObjects(l *mib.Loader, m *mib.Module, spec draftSpec) ([]*mib.Object, error) {
chosen := map[string]*mib.Object{}
add := func(o *mib.Object) { chosen[o.Module+"::"+o.Name] = o }
if spec.Table != "" {
cols, err := tableColumns(m, spec)
if err != nil {
return nil, err
}
for _, o := range cols {
add(o)
}
}
named, err := namedObjects(l, spec)
if err != nil {
return nil, err
}
for _, o := range named {
add(o)
}
if spec.Table == "" && len(spec.Objects) == 0 {
for _, o := range allReadable(m) {
add(o)
}
}
if len(chosen) == 0 {
return nil, fmt.Errorf("%s: no readable scalar or column objects selected", spec.Module)
}
objs := make([]*mib.Object, 0, len(chosen))
for _, o := range chosen {
objs = append(objs, o)
}
sort.Slice(objs, func(i, j int) bool { return oidLess(objs[i].OID, objs[j].OID) })
return objs, nil
}
// allReadable lists every current, readable scalar and column of m.
func allReadable(m *mib.Module) []*mib.Object {
var out []*mib.Object
for _, o := range m.Objects {
if (o.Kind == mib.KindScalar || o.Kind == mib.KindColumn) && readable(o) && o.Status != "obsolete" && o.OID != "" {
out = append(out, o)
}
}
return out
}
// tableColumns lists the readable columns of spec.Table.
func tableColumns(m *mib.Module, spec draftSpec) ([]*mib.Object, error) {
tb := m.Lookup(spec.Table)
if tb == nil || tb.Kind != mib.KindTable {
return nil, fmt.Errorf("%s: no table %q (tables are %s)", spec.Module, spec.Table, strings.Join(tableNames(m), ", "))
}
var cols []*mib.Object
for _, o := range m.Objects {
if o.Kind == mib.KindColumn && o.Table == spec.Table && readable(o) {
cols = append(cols, o)
}
}
return cols, nil
}
// namedObjects resolves --objects entries; a bare NAME belongs to spec.Module.
func namedObjects(l *mib.Loader, spec draftSpec) ([]*mib.Object, error) {
var out []*mib.Object
for _, n := range spec.Objects {
q := n
if !strings.Contains(q, "::") {
q = spec.Module + "::" + n
}
o, err := l.Object(q)
if err != nil {
return nil, err
}
if o.Kind != mib.KindScalar && o.Kind != mib.KindColumn {
return nil, fmt.Errorf("%s is a %s, not a scalar or table column", q, o.Kind)
}
out = append(out, o)
}
return out, nil
}
// draftMetrics converts objs to draft metrics. Label columns used by
// index_label must exist as metrics of the profile, so the ones not already
// selected are appended, sorted by OID.
func draftMetrics(l *mib.Loader, m *mib.Module, objs []*mib.Object) []draftMetric {
labels := map[string]*mib.Object{}
var metrics []draftMetric
have := map[string]bool{}
for _, o := range objs {
have[o.Name] = true
}
for _, o := range objs {
dm := convert(o)
if o.Kind == mib.KindColumn {
if lo := setLabel(&dm, l, m, o); lo != nil && !have[lo.Name] {
labels[lo.Name] = lo
}
}
metrics = append(metrics, dm)
}
var extra []*mib.Object
for _, o := range labels {
extra = append(extra, o)
}
sort.Slice(extra, func(i, j int) bool { return oidLess(extra[i].OID, extra[j].OID) })
for _, o := range extra {
dm := convert(o)
dm.comments = append(dm.comments, "added as the index_label column of the metrics above")
metrics = append(metrics, dm)
}
return metrics
}
// setLabel fills dm's index_label from labelFor. It returns the label column
// when the profile must carry it as a metric of its own (a component label), else nil.
func setLabel(dm *draftMetric, l *mib.Loader, m *mib.Module, o *mib.Object) *mib.Object {
col, as, note := labelFor(l, m, o)
dm.label, dm.labelAs = col, as
if note != "" {
dm.comments = append(dm.comments, note)
}
if as != "component" {
return nil
}
return m.Lookup(col)
}
// renderDraft writes the profile YAML document.
func renderDraft(spec draftSpec, m *mib.Module, metrics []draftMetric) string {
var b strings.Builder
name := "draft-" + strings.ToLower(spec.Module)
fmt.Fprintf(&b, "# DRAFT compiled by `nmon profile new` from %s (%s).\n", spec.Module, filepath.Base(m.File))
fmt.Fprintf(&b, "# Save as %s.yaml. Replace every TODO (unit, interval, index_label) and add a match: section.\n", name)
b.WriteString("# The loader rejects TODO on purpose, so an unreviewed draft can never be polled.\n")
fmt.Fprintf(&b, "name: %s\nextends: generic\nprovenance: %q\nmetrics:\n", name,
"drafted by nmon profile new from "+spec.Module+"; review before use")
for _, dm := range metrics {
writeDraftMetric(&b, dm)
}
return b.String()
}
func tableNames(m *mib.Module) []string {
var out []string
for _, o := range m.Objects {
if o.Kind == mib.KindTable {
out = append(out, o.Name)
}
}
return out
}
func readable(o *mib.Object) bool {
switch o.Access {
case "read-only", "read-write", "read-create", "mandatory", "":
return o.Access != "" || o.Kind == mib.KindScalar
}
return false
}
func oidLess(a, b string) bool {
as, bs := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; i < len(as) && i < len(bs); i++ {
x, _ := strconv.Atoi(as[i])
y, _ := strconv.Atoi(bs[i])
if x != y {
return x < y
}
}
return len(as) < len(bs)
}
// convert maps one MIB object to a draft metric.
func convert(o *mib.Object) draftMetric {
dm := draftMetric{obj: o, unit: "TODO"}
if o.TC != "" || o.DisplayHint != "" {
dm.comments = append(dm.comments, conventionNote(o))
}
applyBaseType(&dm, o)
if o.Units != "" {
if u, ok := unitMap[strings.ToLower(o.Units)]; ok && dm.typ != "status" && dm.typ != "text" {
dm.unit, dm.scale = u.unit, u.scale
} else {
dm.comments = append(dm.comments, "UNITS "+strconv.Quote(o.Units)+" is not in the unit vocabulary")
}
}
return dm
}
// conventionNote describes the textual convention and DISPLAY-HINT, and
// suggests a scale when the hint implies decimal places ("d-2").
func conventionNote(o *mib.Object) string {
c := "textual convention " + o.TC
if o.TC == "" {
c = "no textual convention"
}
if o.DisplayHint != "" {
c += ", DISPLAY-HINT " + strconv.Quote(o.DisplayHint)
}
if mm := hintRE.FindStringSubmatch(o.DisplayHint); mm != nil {
n, _ := strconv.Atoi(mm[1])
s := "0." + strings.Repeat("0", n-1) + "1"
if n == 0 {
s = "1"
}
c += "; the value has " + mm[1] + " implied decimal place(s), suggest scale: " + s
}
return c
}
// applyBaseType sets the metric type (and unit where the type implies one)
// from the object's SMI base type.
func applyBaseType(dm *draftMetric, o *mib.Object) {
switch o.BaseType {
case mib.BaseCounter64:
dm.typ = "counter64"
case mib.BaseCounter32:
dm.typ = "counter32"
case mib.BaseGauge32, mib.BaseUnsigned32, mib.BaseInteger32, mib.BaseInteger:
if len(o.Enum) > 0 {
dm.typ, dm.unit, dm.enum = "status", "enum", o.Enum
} else {
dm.typ = "gauge"
}
case mib.BaseTimeTicks:
dm.typ, dm.unit = "gauge", "seconds"
dm.comments = append(dm.comments, "TimeTicks: the poller converts to seconds")
case mib.BaseOctets:
dm.typ, dm.unit = "text", "text"
case mib.BaseOID, mib.BaseIPAddress, mib.BaseOpaque, mib.BaseBits:
dm.typ, dm.unit = "text", "text"
dm.comments = append(dm.comments, o.BaseType+" is collected as text")
default:
dm.typ = "gauge"
dm.comments = append(dm.comments, fmt.Sprintf("base type of %q could not be determined; check the type", o.TC))
}
}
var labelNameRE = regexp.MustCompile(`(?i)(descr|name|label|alias)`)
// labelFor picks the index_label for a table column: ifIndex-indexed tables are
// labelled by interface; otherwise the first DisplayString-like column of the table.
func labelFor(l *mib.Loader, m *mib.Module, o *mib.Object) (col, as, note string) {
if indexedByIfIndex(l, m, o) {
return "ifIndex", "iface", ""
}
if best := bestLabelColumn(m, o); best != nil {
return best.Name, "component", ""
}
return "TODO", "component", "no DisplayString column found in the table: set index_label.column"
}
// indexedByIfIndex follows AUGMENTS (up to 4 rows deep) looking for ifIndex in the row's INDEX.
func indexedByIfIndex(l *mib.Loader, m *mib.Module, o *mib.Object) bool {
row := rowOf(m, o)
for depth := 0; row != nil && depth < 4; depth++ {
if slices.Contains(row.Index, "ifIndex") {
return true
}
if row.Augments == "" {
break
}
next := m.Lookup(row.Augments) // AUGMENTS: the row shares the augmented row's index
if next == nil {
next, _ = l.Object(row.Augments)
}
row = next
}
return false
}
// bestLabelColumn is the lowest-OID readable string column of o's table that
// looks like a name (DisplayString, a text DISPLAY-HINT or a descr/name/label/alias name).
func bestLabelColumn(m *mib.Module, o *mib.Object) *mib.Object {
var best *mib.Object
for _, c := range m.Objects {
if c.Kind != mib.KindColumn || c.Table != o.Table || !readable(c) || c.BaseType != mib.BaseOctets {
continue
}
if c.TC != "DisplayString" && !strings.HasSuffix(c.DisplayHint, "a") && !labelNameRE.MatchString(c.Name) {
continue
}
if best == nil || oidLess(c.OID, best.OID) {
best = c
}
}
return best
}
func rowOf(m *mib.Module, col *mib.Object) *mib.Object {
i := strings.LastIndexByte(col.OID, '.')
if i < 0 {
return nil
}
for _, o := range m.Objects {
if o.Kind == mib.KindRow && o.OID == col.OID[:i] {
return o
}
}
return nil
}
func writeDraftMetric(b *strings.Builder, dm draftMetric) {
o := dm.obj
oid := o.OID
if o.Kind == mib.KindScalar {
oid += ".0"
}
for _, c := range dm.comments {
fmt.Fprintf(b, " # %s\n", c)
}
if o.Description != "" {
d := o.Description
if len(d) > 100 {
d = d[:100] + "..."
}
fmt.Fprintf(b, " # %s\n", d)
}
fmt.Fprintf(b, " - name: %s\n oid: %s\n type: %s\n unit: %s\n interval: TODO\n", o.Name, oid, dm.typ, dm.unit)
if dm.scale != 0 {
fmt.Fprintf(b, " scale: %s\n", strconv.FormatFloat(dm.scale, 'f', -1, 64))
}
if dm.label != "" {
fmt.Fprintf(b, " index_label: { column: %s, as: %s }\n", dm.label, dm.labelAs)
}
if len(dm.enum) > 0 {
b.WriteString(" enum:\n")
keys := make([]int, 0, len(dm.enum))
for k := range dm.enum {
keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
fmt.Fprintf(b, " %d: %q\n", k, dm.enum[k])
}
}
}

97
cmd/nmon/profile_probe.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net/netip"
"time"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/profiletest"
"github.com/v10networks/nmon/internal/snmp"
)
// resolveCredential normalises --version (v1, v2c, v3 or bare; default by
// whether a v3 user was given) into cred.Version and checks the secrets that
// version needs.
func resolveCredential(version string, cred *snmp.Credential) error {
switch version {
case "":
version = "2c"
if cred.V3User != "" {
version = "3"
}
case "v1", "v2c", "v3":
version = version[1:]
}
cred.Version = version
switch {
case version != "1" && version != "2c" && version != "3":
return errors.New("--version must be v1, v2c or v3")
case version != "3" && cred.Community == "":
return fmt.Errorf("--community is required for SNMP v%s", version)
case version == "3" && cred.V3User == "":
return errors.New("--v3-user is required for SNMPv3")
}
return nil
}
// profileTest implements `nmon profile test`: discovery plus one poll of every
// interval group with the matching profile, printing each metric.
func profileTest(ctx context.Context, args []string, stdout io.Writer) error {
fs := profileFlagSet("test",
"profile test <ip> [--port N] [--version 1|2c|3] [--community C | --v3-user U ...] [--data-dir DIR]")
var (
port int
version string
cred snmp.Credential
dataDir string
timeout time.Duration
)
fs.IntVar(&port, "port", 161, "device UDP port")
fs.StringVar(&version, "version", "", "SNMP version: v1, v2c or v3 (default v3 when --v3-user is set, else v2c)")
fs.StringVar(&cred.Community, "community", "", "v1/v2c community")
fs.StringVar(&cred.V3User, "v3-user", "", "SNMPv3 user name")
fs.StringVar(&cred.V3AuthProto, "v3-auth-proto", "", "SNMPv3 auth protocol: MD5, SHA, SHA224, SHA256, SHA384, SHA512 (default SHA256)")
fs.StringVar(&cred.V3AuthPass, "v3-auth-pass", "", "SNMPv3 authentication passphrase")
fs.StringVar(&cred.V3PrivProto, "v3-priv-proto", "", "SNMPv3 privacy protocol: DES, AES, AES192, AES256 (default AES)")
fs.StringVar(&cred.V3PrivPass, "v3-priv-pass", "", "SNMPv3 privacy passphrase")
fs.StringVar(&cred.V3Context, "v3-context", "", "SNMPv3 context name")
fs.StringVar(&dataDir, "data-dir", "", "load operator profiles from <data-dir>/profiles over the shipped ones")
fs.DurationVar(&timeout, "timeout", 0, "per-request SNMP timeout (default 2s)")
pos, err := parseInterspersed(fs, args)
if err != nil {
return flagErr(err)
}
if len(pos) != 1 {
return fmt.Errorf("usage: nmon profile test <ip> [flags]")
}
ip, err := netip.ParseAddr(pos[0])
if err != nil {
return fmt.Errorf("bad address %q: %w", pos[0], err)
}
if err := resolveCredential(version, &cred); err != nil {
return err
}
dir := ""
if dataDir != "" {
dir = config.ProfilesDir(dataDir)
}
set, err := profile.Load(dir)
if err != nil {
return err
}
res, err := profiletest.Run(ctx, profiletest.Options{
Addr: netip.AddrPortFrom(ip, uint16(port)), Cred: cred, Profiles: set, Out: stdout, Timeout: timeout,
})
if err != nil {
return err
}
if res.Poll.Mismatches > 0 || res.Poll.WalkPDUs > 0 {
return fmt.Errorf("%d type mismatches, %d walk PDUs on the poll path", res.Poll.Mismatches, res.Poll.WalkPDUs)
}
return nil
}

179
cmd/nmon/profile_record.go Normal file
View File

@@ -0,0 +1,179 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/netip"
"os"
"time"
"github.com/gosnmp/gosnmp"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/snmp"
)
// defaultRecordSubtrees is what `profile record` walks when --subtree is not given:
// system, interfaces, ifMIB, host, entity, entitySensor, UPS, printer and enterprises.
var defaultRecordSubtrees = []string{
"1.3.6.1.2.1.1", // system
"1.3.6.1.2.1.2", // interfaces
"1.3.6.1.2.1.31", // ifMIB
"1.3.6.1.2.1.25", // host
"1.3.6.1.2.1.47", // entity
"1.3.6.1.2.1.99", // entitySensor
"1.3.6.1.2.1.33", // upsMIB
"1.3.6.1.2.1.43", // printmib
"1.3.6.1.4.1", // enterprises
}
const redacted = "REDACTED"
var redactedOIDs = map[string]bool{
"1.3.6.1.2.1.1.4.0": true, // sysContact
"1.3.6.1.2.1.1.6.0": true, // sysLocation
}
type recordOpts struct {
Target snmp.Target
Subtrees []string
MaxReps int
MaxRecords int
}
func profileRecord(ctx context.Context, args []string, stdout io.Writer) error {
fs := profileFlagSet("record",
"profile record <ip> [--port N] [--community C | --version 3 --v3-user U ...] [--out FILE.snmprec] [--subtree OID]...")
var (
port int
version string
cred snmp.Credential
out string
subtrees listFlag
timeout time.Duration
maxReps int
maxRecords int
)
fs.IntVar(&port, "port", 161, "device UDP port")
fs.StringVar(&version, "version", "", "SNMP version: 1, 2c or 3 (default 3 when --v3-user is set, else 2c)")
fs.StringVar(&cred.Community, "community", "", "v1/v2c community")
fs.StringVar(&cred.V3User, "v3-user", "", "SNMPv3 user name")
fs.StringVar(&cred.V3AuthProto, "v3-auth-proto", "", "SNMPv3 auth protocol: MD5, SHA, SHA224, SHA256, SHA384, SHA512 (default SHA256)")
fs.StringVar(&cred.V3AuthPass, "v3-auth-pass", "", "SNMPv3 authentication passphrase")
fs.StringVar(&cred.V3PrivProto, "v3-priv-proto", "", "SNMPv3 privacy protocol: DES, AES, AES192, AES256, AES192C, AES256C (default AES)")
fs.StringVar(&cred.V3PrivPass, "v3-priv-pass", "", "SNMPv3 privacy passphrase")
fs.StringVar(&cred.V3Context, "v3-context", "", "SNMPv3 context name")
fs.StringVar(&out, "out", "", "write the recording here instead of stdout")
fs.Var(&subtrees, "subtree",
"OID to walk; repeatable (default: system, interfaces, ifMIB, host, entity, entitySensor, ups, printer, enterprises)")
fs.DurationVar(&timeout, "timeout", 3*time.Second, "per-request timeout")
fs.IntVar(&maxReps, "max-repetitions", 25, "GETBULK max-repetitions")
fs.IntVar(&maxRecords, "max-records", 500000, "stop with an error after this many varbinds")
pos, err := parseInterspersed(fs, args)
if err != nil {
return flagErr(err)
}
if len(pos) != 1 {
return fmt.Errorf("usage: nmon profile record <ip> [flags]")
}
ip, err := netip.ParseAddr(pos[0])
if err != nil {
return fmt.Errorf("bad address %q: %w", pos[0], err)
}
switch {
case version != "":
case cred.V3User != "":
version = "3"
default:
version = "2c"
}
cred.Version = version
if version != "3" && cred.Community == "" {
return fmt.Errorf("--community is required for SNMP v%s", version)
}
if version == "3" && cred.V3User == "" {
return fmt.Errorf("--v3-user is required for SNMPv3")
}
if len(subtrees) == 0 {
subtrees = defaultRecordSubtrees
}
var buf bytes.Buffer
n, err := recordDevice(ctx, recordOpts{
Target: snmp.Target{Addr: netip.AddrPortFrom(ip, uint16(port)), Cred: cred, Timeout: timeout},
Subtrees: subtrees,
MaxReps: maxReps,
MaxRecords: maxRecords,
}, &buf)
if err != nil {
return err
}
if out != "" {
if err := os.WriteFile(out, buf.Bytes(), 0o644); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "recorded %d varbinds to %s\n", n, out)
return nil
}
_, err = stdout.Write(buf.Bytes())
return err
}
// recordDevice walks the subtrees read-only (GETBULK, or GETNEXT on v1) and writes
// an snmprec recording to w with the community, sysContact and sysLocation redacted.
// It returns the number of records written.
func recordDevice(ctx context.Context, o recordOpts, w io.Writer) (int, error) {
s, err := snmp.Dial(ctx, o.Target, "discovery")
if err != nil {
return 0, err
}
defer func() { _ = s.Close() }() // read-only session
var recs []simagent.Record
for _, root := range o.Subtrees {
err := s.Walk(ctx, root, o.MaxReps, func(vb snmp.Varbind) error {
if vb.Exception() {
return nil
}
if len(recs) >= o.MaxRecords {
return fmt.Errorf("more than %d varbinds; narrow the walk with --subtree", o.MaxRecords)
}
recs = append(recs, redact(vb, o.Target.Cred.Community))
return nil
})
if err != nil {
return 0, fmt.Errorf("walk %s: %w", root, err)
}
}
rec := simagent.NewRecording(recs)
if rec.Len() == 0 {
return 0, fmt.Errorf("the device returned nothing under %v", o.Subtrees)
}
if err := fixturegen.WriteSnmprec(w, rec); err != nil {
return 0, err
}
return rec.Len(), nil
}
// redact turns a varbind into a recording row, replacing secrets with REDACTED.
func redact(vb snmp.Varbind, community string) simagent.Record {
r := simagent.Record{OID: vb.OID, Type: vb.Type, Value: vb.Value}
if redactedOIDs[vb.OID] {
r.Type, r.Value = gosnmp.OctetString, []byte(redacted)
return r
}
if community != "" {
switch v := vb.Value.(type) {
case []byte:
if string(v) == community {
r.Value = []byte(redacted)
}
case string:
if vb.Type == gosnmp.OctetString && v == community {
r.Value = []byte(redacted)
}
}
}
return r
}

224
cmd/nmon/profile_test.go Normal file
View File

@@ -0,0 +1,224 @@
package main
import (
"bytes"
"context"
"net/netip"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/simagent"
)
func bundledMIBs(t *testing.T) string {
t.Helper()
d, _ := filepath.Abs(filepath.Join("..", "..", "mibs"))
if _, err := os.Stat(d); err != nil {
t.Skip("bundled MIBs not present")
}
return d
}
func fillTODOs(s string) string {
s = strings.ReplaceAll(s, "interval: TODO", "interval: 5m")
return strings.ReplaceAll(s, "unit: TODO", "unit: count")
}
func runNew(t *testing.T, args ...string) string {
t.Helper()
var out bytes.Buffer
if err := profileNew(context.Background(), args, &out); err != nil {
t.Fatal(err)
}
return out.String()
}
func TestProfileNewIFMIBTable(t *testing.T) {
d := runNew(t, "--mib", "IF-MIB", "--table", "ifXTable", "--mibs-dir", bundledMIBs(t))
if _, err := profile.Parse("draft-if-mib.yaml", []byte(d)); err == nil || !strings.Contains(err.Error(), "TODO") {
t.Fatalf("an unfilled draft must be rejected naming TODO, got %v", err)
}
p, err := profile.Parse("draft-if-mib.yaml", []byte(fillTODOs(d)))
if err != nil {
t.Fatalf("filled draft: %v\n%s", err, d)
}
byName := map[string]profile.Metric{}
for _, m := range p.Metrics {
byName[m.Name] = m
}
hc := byName["ifHCInOctets"]
if hc.OID != "1.3.6.1.2.1.31.1.1.1.6" || hc.Type != profile.Counter64 || hc.IndexLabel == nil || hc.IndexLabel.Column != "ifIndex" || hc.IndexLabel.As != "iface" {
t.Errorf("ifHCInOctets = %+v", hc)
}
if m := byName["ifLinkUpDownTrapEnable"]; m.Type != profile.Status || m.Enum[1] != "enabled" {
t.Errorf("ifLinkUpDownTrapEnable = %+v", m)
}
if m := byName["ifAlias"]; m.Type != profile.Text {
t.Errorf("ifAlias = %+v", m)
}
if !strings.Contains(d, "DISPLAY-HINT") {
t.Errorf("expected a DISPLAY-HINT comment:\n%s", d)
}
}
func TestProfileNewScalarsAndObjects(t *testing.T) {
d := runNew(t, "--mib", "SNMPv2-MIB", "--objects", "sysUpTime,sysName", "--mibs-dir", bundledMIBs(t))
p, err := profile.Parse("draft-snmpv2-mib.yaml", []byte(fillTODOs(d)))
if err != nil {
t.Fatalf("%v\n%s", err, d)
}
if len(p.Metrics) != 2 || p.Metrics[0].OID != "1.3.6.1.2.1.1.3.0" || p.Metrics[0].Unit != "seconds" {
t.Errorf("metrics = %+v", p.Metrics)
}
}
func TestProfileNewHintAndUnits(t *testing.T) {
d := runNew(t, "--mib", "TEST-MIB", "--mibs-dir", "../../internal/mib/testdata")
for _, want := range []string{"suggest scale: 0.1", "oid: 1.3.6.1.4.1.99999.1.1.0", "unit: celsius", "type: status", "2: \"on\"",
"index_label: { column: testName, as: component }"} {
if !strings.Contains(d, want) {
t.Errorf("missing %q in:\n%s", want, d)
}
}
if _, err := profile.Parse("draft-test-mib.yaml", []byte(fillTODOs(d))); err != nil {
t.Fatalf("%v\n%s", err, d)
}
}
func TestProfileNewErrors(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{
{},
{"--mib", "NO-SUCH-MIB", "--mibs-dir", "../../internal/mib/testdata"},
{"--mib", "TEST-MIB", "--table", "nope", "--mibs-dir", "../../internal/mib/testdata"},
{"--mib", "TEST-MIB", "--objects", "testTable", "--mibs-dir", "../../internal/mib/testdata"},
} {
if err := profileNew(context.Background(), args, &out); err == nil {
t.Errorf("%v: expected an error", args)
}
}
}
const recordSample = `1.3.6.1.2.1.1.1.0|4|Test device
1.3.6.1.2.1.1.2.0|6|1.3.6.1.4.1.8072.3.2.10
1.3.6.1.2.1.1.3.0|67|123456
1.3.6.1.2.1.1.4.0|4|admin@example.com
1.3.6.1.2.1.1.5.0|4|secret-comm
1.3.6.1.2.1.1.6.0|4|Rack 12, Building 3
1.3.6.1.2.1.2.1.0|2|1
1.3.6.1.2.1.2.2.1.1.1|2|1
1.3.6.1.2.1.2.2.1.10.1|65|42
1.3.6.1.2.1.31.1.1.1.6.1|70|18446744073709551615
1.3.6.1.4.1.9999.1.1.0|4|secret-comm
1.3.6.1.4.1.9999.1.2.0|4x|00ff
1.3.6.1.4.1.9999.1.3.0|64|10.1.2.3
`
func TestProfileRecordRedacts(t *testing.T) {
rec, err := simagent.ParseRecording(strings.NewReader(recordSample))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
ag, err := simagent.Start(ctx, simagent.Config{Recording: rec, Community: "secret-comm"})
if err != nil {
t.Fatal(err)
}
defer func() { _ = ag.Close() }() // test cleanup
addr := ag.Addr()
for _, ver := range []string{"2c", "1"} {
var out bytes.Buffer
err := profileRecordTo(ctx, &out, addr, ver)
if err != nil {
t.Fatalf("v%s: %v", ver, err)
}
s := out.String()
for _, secret := range []string{"secret-comm", "admin@example.com", "Rack 12"} {
if strings.Contains(s, secret) {
t.Errorf("v%s: recording leaks %q:\n%s", ver, secret, s)
}
}
if n := strings.Count(s, "REDACTED"); n != 4 {
t.Errorf("v%s: want 4 REDACTED values, got %d:\n%s", ver, n, s)
}
got, err := simagent.ParseRecording(strings.NewReader(s))
if err != nil {
t.Fatalf("v%s: output does not parse: %v", ver, err)
}
want := rec.Len()
if ver == "1" {
want-- // Counter64 does not exist in SNMPv1
}
if got.Len() != want {
t.Errorf("v%s: %d records, want %d\n%s", ver, got.Len(), want, s)
}
}
}
func profileRecordTo(ctx context.Context, out *bytes.Buffer, addr netip.AddrPort, ver string) error {
return profileRecord(ctx, []string{addr.Addr().String(), "--port", itoa(int(addr.Port())), "--community", "secret-comm",
"--version", ver, "--timeout", "1s", "--subtree", "1.3.6.1.2.1", "--subtree", "1.3.6.1.4.1"}, out)
}
func itoa(n int) string { return strconv.Itoa(n) }
func TestProfileRecordArgs(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{{}, {"10.0.0.1"}, {"nonsense", "--community", "x"}, {"10.0.0.1", "--version", "3"}} {
if err := profileRecord(context.Background(), args, &out); err == nil {
t.Errorf("%v: expected an error", args)
}
}
}
func TestProfileTestArgs(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{{}, {"10.0.0.1"}, {"nonsense", "--community", "x"}, {"10.0.0.1", "--version", "v3"}, {"10.0.0.1", "--version", "v9", "--community", "x"}} {
if err := profileTest(context.Background(), args, &out); err == nil {
t.Errorf("%v: expected an error", args)
}
}
}
func TestProfileTestPrintsMetrics(t *testing.T) {
rec, err := simagent.LoadRecording("../../testdata/snmprec/juniper-junos.snmprec")
if err != nil {
t.Fatal(err)
}
a, err := simagent.Start(context.Background(), simagent.Config{Recording: rec, Community: "lab"})
if err != nil {
t.Fatal(err)
}
defer func() { _ = a.Close() }() // test cleanup
var out bytes.Buffer
err = profileTest(context.Background(), []string{a.Addr().Addr().String(), "--port", itoa(int(a.Addr().Port())), "--community", "lab"}, &out)
if err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
for _, want := range []string{"profile juniper-junos", "ifHCInOctets", "jnxOperatingCPU", "PDUs per cycle", "0 type mismatches"} {
if !strings.Contains(out.String(), want) {
t.Errorf("output lacks %q:\n%s", want, out.String())
}
}
}
// The LibreNMS importer is a separate offline tool (DEVIATIONS #9): nmon must not link it,
// and neither may nmon pull in the code that reads LibreNMS definitions.
func TestNmonDoesNotImportLibreNMS(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").CombinedOutput()
if err != nil {
t.Skipf("go list unavailable: %v\n%s", err, out)
}
for _, pkg := range strings.Fields(string(out)) {
if strings.Contains(pkg, "internal/librenms") || strings.Contains(pkg, "nmon-librenms-import") {
t.Errorf("cmd/nmon depends on %s", pkg)
}
}
}

View File

@@ -2,12 +2,14 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"github.com/v10networks/nmon/internal/app"
"github.com/v10networks/nmon/internal/cliout"
"github.com/v10networks/nmon/internal/config"
)
@@ -23,7 +25,9 @@ var hidden = map[string]bool{"poll-interval": true, "snmp-port": true}
func newFlagSet(name, usage string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "usage: nmon %s\n\nflags:\n", usage)
// Usage text is best-effort; a failed write to the terminal has nowhere to go.
out := cliout.New(fs.Output())
out.Printf("usage: nmon %s\n\nflags:\n", usage)
fs.VisitAll(func(f *flag.Flag) {
if hidden[f.Name] {
return
@@ -32,7 +36,7 @@ func newFlagSet(name, usage string) *flag.FlagSet {
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
def = fmt.Sprintf(" (default %s)", f.DefValue)
}
fmt.Fprintf(fs.Output(), " --%s\n %s%s\n", f.Name, f.Usage, def)
out.Printf(" --%s\n %s%s\n", f.Name, f.Usage, def)
})
}
return fs
@@ -49,11 +53,15 @@ func serve(ctx context.Context, args []string) error {
var f config.Flags
fs := newFlagSet("serve", "serve --data-dir DIR --insecure-http [flags]")
storeFlags(fs, &f)
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile, "SNMP credential key file; created on first run; must be outside --data-dir")
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile,
"SNMP credential key file; created on first run; must be outside --data-dir")
fs.StringVar(&f.Listen, "listen", app.DefaultListen, "address for the API")
fs.BoolVar(&f.InsecureHTTP, "insecure-http", false, "serve plain HTTP with no authentication (required until TLS and auth arrive in M6)")
fs.StringVar(&f.VMHotURL, "vm-hot-url", "", "use this VictoriaMetrics instance as the hot store instead of the embedded one")
fs.StringVar(&f.VMLongURL, "vm-long-url", "", "use this VictoriaMetrics instance as the long store instead of the embedded one")
fs.StringVar(&f.TrapListen, "trap-listen", app.DefaultTrapListen,
"UDP address for SNMP traps; empty disables the receiver "+
"(port 162 needs CAP_NET_BIND_SERVICE; without it traps are off and polling continues)")
fs.IntVar(&f.MaxInflight, "max-inflight", 512, "most SNMP polls in flight at once")
fs.DurationVar(&f.PollInterval, "poll-interval", 0, "dev: SNMP poll interval (default 60s)")
fs.IntVar(&f.SNMPPort, "snmp-port", 0, "dev: UDP port of every device's agent (default 161)")
@@ -70,7 +78,7 @@ func serve(ctx context.Context, args []string) error {
// flagErr turns flag.ErrHelp into a clean exit.
func flagErr(err error) error {
if err == flag.ErrHelp {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
return err

View File

@@ -14,3 +14,10 @@ conscious, reviewed exception.
| 7 | `import-librenms` reads `includes/definitions/*.yaml` and `includes/definitions/discovery/*.yaml` | Reads `resources/definitions/os_detection/*.yaml` and `resources/definitions/os_discovery/*.yaml`, with the old paths as a fallback. | LibreNMS moved them; the spec's paths no longer exist upstream. | M2 |
| 8 | Real recordings replace synthetic fixtures when available | LibreNMS's `tests/snmpsim/*.snmprec` (2,054 real device recordings) are used by optional tests when `NMON_LIBRENMS_DIR` points at a checkout. They are never copied into this repo. | Real-device coverage without vendoring GPL test data into an Apache-2.0 repo. | M2 |
| 9 | `nmon profile import-librenms <repo-path>` is an nmon subcommand | A separate binary, `nmon-librenms-import` (`cmd/nmon-librenms-import`), run offline before nmon. It writes draft profiles that the operator reviews and copies into `<data-dir>/profiles/`. `nmon` neither links nor exposes it. | User decision (2026-10-22). It keeps the importer out of the product binary and makes "the converter never runs at serve time" structural. | M2 |
| 10 | `scripts/openapi-diff` fails on an incompatible change (M3 milestone) | The differ is the Go package `internal/openapidiff` (unit-tested, run with `go run ./internal/openapidiff/cmd/openapidiff`), driven by `scripts/openapi-check.sh`, which `make gate-m3` and the CI `openapi` job call. | A Go package is testable and linted like the rest of the code; the script only fetches the spec at the last `m*`/`v*` tag. No new dependency. | M3 |
| 11 | Built assets are embedded from `web/dist` into `internal/webui` | `make web` copies `web/dist` to `internal/webui/dist` (git-ignored, `.gitkeep` tracked); a committed `internal/webui/placeholder/index.html` ("UI not built") is served when `dist` has no `index.html`. | A tracked `dist/index.html` would be overwritten by every build and dirty the tree, and a built UI must never be committed. `go build` still works without node. | M3 |
| 12 | (not specified) | Developer tools (`backfill`, `seed-demo`) are a separate executable, `cmd/nmon-devtools` (`make devtools` builds `nmon-devtools`), never shipped. It talks to a running nmon over its HTTP API and to the hot store via `--hot-url`. `nmon` has no `devtools` command; a depguard rule keeps `cmd/nmon` from importing `internal/simagent` or `internal/fixturegen` beyond `profile_record.go`, which already used them for `nmon profile record` in M2. | The gate needs 30 days of history without waiting 30 days, and live screenshots need a simulated network. See `docs/dev.md`. | M3 |
| 13 | (not specified) `/docs` API reference | `/docs` serves a vendored, pinned swagger-ui-dist 5.17.14 (Apache-2.0, about 1.6 MB) through `embed.FS`. | huma's built-in renderers load their assets from a CDN, which an offline monitor cannot rely on. | M3 |
| 14 | M2 defined provisional `poller/health` and `identity-reviews` responses | Both were reshaped in M3 to the reviewed UI contract, before any `openapi.json` existed, so the route freeze starts at `m3`, the first tag with a checked-in spec. | The M2 shapes had no consumer. `scripts/openapi-check.sh` passes against `m2` with a note and diffs for real from `m3`. | M3 |
| 15 | A single-signal failure (ICMP only or SNMP only) is a warning | The warning is held as pending for 5 min before it notifies, and is closed silently if the device goes down meanwhile. | Otherwise a real outage whose second signal fails seconds later sends a spurious warning first. | M4 |
| 16 | An unsupported probe is unknown, never failed | When ICMP is unsupported (host can't open unprivileged ICMP, or per device), SNMP alone decides down, as with `icmp_blocked`, and a one-time `icmp_unsupported` device warning is raised. | Otherwise nmon could never declare a device down on such a host, and nobody would notice. | M4 |

47
docs/MAINTAINABILITY.md Normal file
View File

@@ -0,0 +1,47 @@
# Maintainability standard
**This is a hard rule** (owner decision, 2026-10-22). Code that breaks it does not merge, and a milestone with findings is not tagged. The machine enforces it: `make lint` runs golangci-lint with `.golangci.yml` (Go) and ESLint (web). Every `scripts/gate-m*.sh` runs `make lint` first, and so does CI.
## Limits (production code)
| What | Limit | Enforced by |
|---|---|---|
| Function length | 80 lines, 50 statements | `funlen` |
| Cyclomatic complexity | 15 per function | `cyclop` |
| Cognitive complexity | 20 per function | `gocognit` |
| Nesting | `if` depth 4 | `nestif` |
| Duplicated blocks | 150 tokens | `dupl` |
| Line length | 140 characters | `lll` |
| File length | 600 lines (split by concern before that) | `scripts/lint-filesize.sh` |
| Unused code, dead fields | none | `unused` |
| Deprecated APIs | none | `staticcheck` |
| Unchecked errors | none | `errcheck`, `errorlint` |
| Magic HTTP status numbers | none (use `http.Status*`) | `usestdlibvars` |
Tests (`_test.go`) are exempt from the length and complexity limits, because table-driven tests are long by nature. They must still pass every other linter.
## Architecture rules (enforced by `depguard` and `forbidigo`)
- **SQL and the ORM live only in `internal/store`.** No other package imports `github.com/uptrace/bun` or `database/sql`.
- **Layering:** `cmd → app → {api, poller, discovery, traps, alert, notify, …} → {store, snmp, profile, metrics, clock, …}`.
- Only `cmd/*` imports `internal/app`.
- Only `internal/app` (and tests) imports `internal/api`.
- Collection paths do not import each other: `poller`, `discovery`, `traps`, `ingest`, `checks`, `rollup` and `alert` share code through a lower package such as `internal/snmpx`, not through each other.
- `cmd/nmon` never imports `internal/librenms` (the importer is a separate tool).
- **Time comes from `internal/clock`.** `time.Now`, `time.Sleep`, `time.After`, `time.Tick` and `time.NewTicker` are forbidden outside `internal/clock`, `cmd/` and tests.
- **No global mutable state** except the `selfmon.Default` registry and package-level caches that are documented as such.
## Habits the linters can't check (reviewers enforce these)
- One concern per file. Name files after what they hold (`matcher.go`, not `util.go`). No `util`, `helpers` or `common` packages.
- Split a long function into named steps that read like the spec (`collect → match → apply → audit`), not into `doPart1/doPart2`.
- Errors are wrapped with context (`fmt.Errorf("discovery %s: %w", slug, err)`) and handled once.
- Every exported identifier has a doc comment that says *why* or *what contract*, not a restatement of the name.
- Comments explain decisions, not mechanics. A decision that departs from the spec goes in `docs/DEVIATIONS.md`.
- Tests are named for the behaviour they lock in (`TestStackSwapOpensOneReviewAndNeverMerges`).
- `//nolint` is allowed only with the linter named and a reason (`//nolint:funlen // generated table`). `nolintlint` enforces this.
- New dependencies need a line in `docs/DEVIATIONS.md` row 3 saying why the standard library isn't enough.
## Web (`web/`)
ESLint with typescript-eslint (strict), `max-lines-per-function` 80, `complexity` 15, `max-depth` 4, `max-lines` 400 per file, no `any`, react-hooks rules. `TrafficGraph.tsx` is the only file that imports uPlot. API types are only generated, never hand-written.

50
docs/dev.md Normal file
View File

@@ -0,0 +1,50 @@
# Developer notes
## Building the UI into the binary
`make build` runs `make web` first: `npm run build` in `web/`, then `web/dist` is copied to
`internal/webui/dist`, which `//go:embed` packs into the binary. `internal/webui/dist` is git-ignored
(only `.gitkeep` is tracked), so a build never dirties the tree. A plain `go build` without `make web`
embeds a one-line "UI not built" page from `internal/webui/placeholder/`.
nmon serves the UI at `/`. Unknown paths fall back to `index.html` for the client-side router, except
`/api/`, `/docs`, `/openapi*`, `/metrics` and `/schemas`, which belong to the API. Files under `/assets/`
are content-hashed and sent `immutable`; `index.html` is `no-cache`; text assets are served gzipped.
## OpenAPI compatibility
`scripts/openapi-check.sh` compares the current spec (`nmon openapi`, else `openapi.json`) with
`openapi.json` at the latest `m*`/`v*` tag and fails on a removed route or method, a removed response
field, a changed field type, a new required parameter or request field, or a removed enum value. A tag
without `openapi.json` passes with a note. `OPENAPI_NEW=file.json` compares that file instead and
`OPENAPI_BASE_TAG=m3` picks the base. The comparison is `internal/openapidiff`.
## `nmon-devtools` (separate dev binary)
Developer helpers in their own executable, `make devtools` -> `nmon-devtools`; the shipped `nmon` does not contain them and never links the simulator. They need a running `nmon serve --insecure-http`.
**backfill** writes synthetic history straight into the hot store so graphs have 30 days of data without
waiting 30 days:
nmon-devtools backfill --hot-url http://127.0.0.1:PORT --device SLUG --iface ULID [--iface ULID...] \
[--days 31] [--step 60s] [--pattern diurnal|flat] [--mean-mbps N]
Per interface it writes `ifHCInOctets`, `ifHCOutOctets` (monotonic, diurnal rate with noise and bursts) and
`ifOperStatus`, ending one step before the first live sample. When live samples exist the history is shifted
so its last value joins the live counter with no reset (this needs a live counter larger than the history,
about 3e13 bytes for 31 days at 100 Mbit/s; otherwise a warning says the join will look like a reset).
The hot URL is in the serve log (`metric stores up hot=...`). Samples older than hot retention are dropped
by the store.
**seed-demo** builds a small simulated network and back-fills it, for the gate and for screenshots:
nmon serve --data-dir D --insecure-http --poll-interval 10s ...
nmon-devtools seed-demo --api http://127.0.0.1:8443 --hot-url http://127.0.0.1:PORT \
[--env-out env] [--counters-rec testdata/snmprec/counters.snmprec]
It starts the simulated SNMP agents in-process on `[::1]`, each on its own port (passed to the API as `snmp.port`), adds the devices through the API (a 48-port switch, access switches, routers, a firewall, a UPS, the counters fixture, a v1 device with 32-bit counters, and one whose agent is stopped after seeding so it reads as down), waits for live polls, back-fills 31 days, writes `NMON_DEVICE`, `NMON_DEVICE_48`, `NMON_DEVICE_32BIT` and `NMON_DEVICE_DOWN` to `--env-out`, and keeps the agents answering until interrupted. The simulated 64-bit counters start at 1e14 so history fits below them.
## Gates
`make gate-m3` runs everything above end to end and writes live screenshots to `docs/screenshots/M3/`.
It needs Playwright's chromium: `cd web && npx playwright install chromium`.

View File

@@ -61,4 +61,25 @@ Adversarial: stack-member swap; RMA; GRE tunnel on Juniper; 32-bit wrap at 10G m
## Gate log
_(filled in by the supervisor when the gate passes)_
**Passed 2026-10-22**, run by the supervisor (Opus) on linux/amd64.
| Check | Result |
|---|---|
| `make lint` (golangci-lint v2.14.0 + file sizes + ESLint) | **0 findings** (the maintainability hard rule took effect during M2: about 450 findings cleared, 3 justified `nolint`s for long OpenAPI doc tags) |
| `go test -race ./...` and `-tags 'integration vmembed'` | all ok |
| `./scripts/test-pg.sh` | ok (Postgres 16) |
| Flaky-test proof | `go test -race -count=20 -run 'Concurrency\|Degraded\|Slow\|GlobalInflight' ./internal/poller/` ok |
| `make gate-m1` | PASS (lint included) |
| `make gate-m2` | PASS: 25 fixtures over IPv6 and IPv4 (13 profiles + behaviour fixtures), exact PDU budgets, 0 walk PDUs on the poll path; v1 (GETNEXT) and v3 authPriv via `nmon profile test` |
| Real devices (`NMON_LIBRENMS_DIR`) | 12 LibreNMS recordings: every one matched the right profile, 0 type mismatches |
**Notable decisions in M2**
- MIBs are a sidecar, not embedded (DEVIATIONS 6). The LibreNMS importer is a separate offline tool (DEVIATIONS 9).
- Identity: ambiguous matches open a review and freeze the candidates until an admin resolves it. Clean matches are audited only when something changed.
- Polling: backoff and degraded start after 3 consecutive failed cycles. Lower interval groups are skipped while the hot group fails.
- Text metrics are never polled; they are discovery label sources only.
- A production timing race in the poller (a timer registered after the clock moved woke a full interval late) was found by the flaky-test investigation and fixed (`timerUntil`).
**Bugs found by real-device testing and fixed:** apc-ups UPS-MIB label columns were not-accessible, and text metrics were polled as scalars. The fixture generator now refuses to emit not-accessible columns.
**Carried forward:** identity-review resolve/merge endpoint (M3 API); `profile` `max_varbinds` key (deferred; poller option exists); a facets endpoint for the UI (M3).

View File

@@ -33,4 +33,36 @@ API and UI: openapi.json generated with the TS client and the incompatible-diff
## Gate log
_(filled in by the supervisor when the gate passes)_
**Passed 2026-10-22**, run by the supervisor (Opus).
| Check | Result |
|---|---|
| `make lint` (Go + web) | 0 findings |
| `go test -race ./...`, `-tags 'integration vmembed'`, `test-pg.sh` | all ok |
| `make gate-m1`, `make gate-m2` (with LibreNMS recordings) | PASS |
| `make gate-m3` | PASS: UI embedded in the static binary; SPA fallback and cache headers; 10 simulated devices (`nmon-devtools seed-demo`) with 31 days of back-filled history; port graph renders in and out at 1 h, 24 h, 30 d; 48-port page < 2 s; openapi-check; web client regenerated from the live spec type-checks |
| UI ↔ API contract | `TestGeneratedSpecMatchesUIContract`: the generated `openapi.json` is compatible with the contract the UI was built against |
| Screenshots | `docs/screenshots/M3/` (real data, 18 files) and `docs/screenshots/M3-mock/` (mock data); reviewed by the supervisor and sent to the owner |
**Decisions in M3:** dev tooling is a separate `nmon-devtools` binary (owner's call), never shipped. `/docs` uses vendored Swagger UI. The two M2 provisional routes were reshaped before the route freeze, which starts at `m3`, the first tag with `openapi.json`. Graph stitching joins at now − hot retention; p95 is computed live until M5 materialises it.
**Carried forward:** a demo device that is genuinely Down needs an unreachable address; that comes with M4's reachability work (`device-unresponsive.png` shows Warning/degraded for now, which is correct per policy because ICMP still answers). `nmon profile record` still links the simulator package; to be moved to a shared `internal/snmprec` format package.
## API contract for M3 (written before the WPs so the UI and API are built in parallel)
All JSON. Timestamps are RFC 3339 UTC. Lists use `?page=1&per_page=100` (max 1,000) and return `{items: [...], page, per_page, total}`. Errors are RFC 9457 `application/problem+json`. Existing M1/M2 shapes (`InterfaceView`, `DeviceView`, the `SNMPBody` input) are extended, never renamed.
| Route | Response |
|---|---|
| `GET /api/v1/devices?site=&role=&kind=&q=&state=&page=&per_page=` | `items: DeviceSummary[]`, where `DeviceSummary{slug, name, kind, site, role, mgmt_ip, profile, reachability: up\|down\|warning\|unknown\|maintenance, poll_state: ok\|degraded\|backoff, last_seen, interfaces_total, interfaces_down, warnings[]}` |
| `GET /api/v1/devices/{slug}` | `DeviceView` (existing) plus `parents[{slug,name}]`, `groups[{id,name,kind}]`, `sys_descr`, `sys_object_id`, `hw_serial`, `uptime_seconds`, `reachability`, `poll_state`, `warnings[]`, `interfaces: InterfaceView[]` with added `unreliable`, `retired_at`, and live fields `in_bps`, `out_bps`, `in_errors_ps`, `out_errors_ps` (5 min rate, null when unknown or unreliable) |
| `PUT /api/v1/devices/{slug}` | body: `{name?, site?, role?, enabled?, parents?: [slug], snmp?: SNMPBody}`; returns `DeviceView` |
| `GET /api/v1/interfaces?q=&page=` | `items: InterfaceHit[]`, where `InterfaceHit{id, device{slug,name}, ifname, ifalias, ifdescr, oper_status, admin_status, speed_bps, in_bps, out_bps}` (q matches ifname and ifalias, case-insensitive) |
| `GET /api/v1/graph?iface=&from=&to=&step=` | `{iface{id,ifname,ifalias,speed_bps,device{slug,name}}, step_seconds, tier: raw\|1h, stitched_at?: ts, partial: [{from,to}], unreliable: bool, series: {in: [[unix_s, bps\|null]...], out: [...], in_max?: [...], out_max?: [...]}, p95?: {in_bps, out_bps, source: materialised\|live, coverage}}`. Both directions come in one call. `dir=` is optional and filters to one direction. An `unreliable` interface returns empty series and `unreliable_reason` (e.g. `counter_32bit_fast_link`). A stitched response also carries `tiers: [{from, to, tier, step_seconds}]`; the top-level `step_seconds` is the step of the newest tier. |
| `GET /api/v1/poller/health` | `{devices: [{slug, name, state, varbinds, interval_s, base_interval_s, last_cycle_s, missed_deadlines, consecutive_failures, last_error, next_cycle}], totals: {pdus_per_min, timeouts_per_min, deadline_misses_per_min, write_queue_depth, write_queue_capacity, devices_degraded}}`. Sortable client-side. |
| `GET /api/v1/identity-reviews?page=` and `POST /api/v1/identity-reviews/{id}/resolve` | review list with candidates and evidence; resolve body `{action: merge\|keep_both, keep?: iface_id}` |
| `GET /api/v1/query`, `GET /api/v1/query_range` | (existing) |
| `GET /api/v1/devices/facets` | `{sites: string[], roles: string[], kinds: string[]}`: distinct values over non-retired devices, for the filter dropdowns |
| `GET /api/v1/meta` | `{version, build, retention{hot,fiveminute,long} (Go duration strings, e.g. "720h0m0s"), now}` for the UI footer and the range picker |
**Graph tiers:** for a range ≤ hot retention, raw data from hot with step = max(60 s, range/1000). For longer ranges, hot raw data where it exists stitched with `nmon_if_rate_bps_avg_1h`/`max_1h` from long before that point; `stitched_at` is the join timestamp. Until M5's rollup exists, long has no data, so the response carries `partial` for the uncovered span. That is honest, not an error.

View File

@@ -4,6 +4,7 @@ The build follows the six gated milestones in [`docs/spec/spec.md`](../spec/spec
Each milestone has its own document here. It is written before any code for that milestone and
its **Gate log** is filled in when the gate passes. A milestone is done only when:
0. `make lint` is clean: maintainability is a hard rule ([`docs/MAINTAINABILITY.md`](../MAINTAINABILITY.md)), and every gate script runs lint first,
1. every acceptance line it closes (from [`build-detail.md`](../spec/build-detail.md#acceptance-criteria)) has a test that passes,
2. `make test` (`go test -race ./...`) and `make test-integration` pass,
3. its gate command (`make gate-mN`) passes when run by the supervisor, and
@@ -12,9 +13,9 @@ its **Gate log** is filled in when the gate passes. A milestone is done only whe
| # | Milestone | Gate | Doc | Status |
|---|---|---|---|---|
| M1 | Skeleton and fixtures | `nmon serve` polls the counters fixture and `/api/v1/query` returns rising ifHCInOctets | [M1](M1-skeleton-and-fixtures.md) | **passed**, tagged `m1` |
| M2 | Discovery and profiles | every fixture's expect.yaml passes, including v1 and v3, and the PDU budget holds | [M2](M2-discovery-and-profiles.md) | in progress (WP1 done) |
| M3 | Graphs and UI | a port's in/out graph renders at 1 h, 24 h and 30 d from the counters fixture | [M3](M3-graphs-and-ui.md) | planned |
| M4 | Alerting | stopping a parent fixture sends one notification naming it and its children | [M4](M4-alerting.md) | planned |
| M2 | Discovery and profiles | every fixture's expect.yaml passes, including v1 and v3, and the PDU budget holds | [M2](M2-discovery-and-profiles.md) | **passed**, tagged `m2` |
| M3 | Graphs and UI | a port's in/out graph renders at 1 h, 24 h and 30 d from the counters fixture | [M3](M3-graphs-and-ui.md) | **passed**, tagged `m3` |
| M4 | Alerting | stopping a parent fixture sends one notification naming it and its children | [M4](M4-alerting.md) | in progress (WP1 reach and deps, WP2 rules, WP3 notify done) |
| M5 | Push ingest, rollup, checks | a replayed Proxmox sample shows under its node and a 2 y query hits the 1 h tier | [M5](M5-ingest-rollup-checks.md) | planned |
| M6 | Auth and ops | a viewer gets 403 on PUT and the 2,000-device loadtest cycle finishes under 40 s | [M6](M6-auth-and-ops.md) | planned |
| M7 | gNMI streaming | after v1; not built in this run | — | out of scope |
@@ -31,6 +32,8 @@ Departures from the spec are recorded in [`docs/DEVIATIONS.md`](../DEVIATIONS.md
## Rules every WP follows (from "Decisions that don't move")
- **Maintainability is a hard rule.** Follow [`docs/MAINTAINABILITY.md`](../MAINTAINABILITY.md): zero golangci-lint and ESLint findings in your packages, the function, complexity and file-size limits, and the layering rules. Every WP brief cites it.
- One static binary, `CGO_ENABLED=0`. No raw SQL outside `internal/store`.
- The 60 s poll path talks only to devices (SNMP, ICMP) and the local hot store.
- Metric labels on SNMP series: `device=<slug>`, plus `iface=<ULID>` on interface series. Nothing else.
@@ -39,3 +42,11 @@ Departures from the spec are recorded in [`docs/DEVIATIONS.md`](../DEVIATIONS.md
- IPv6 first: every network path works on v6 and v4; tests default to `::1`.
- Time comes from `internal/clock`. No `time.Now()`/`time.Sleep` in logic that a test must control.
- Tests never need real hardware. Fixtures are `.snmprec` files served by `internal/simagent`.
## Deferred (revisit after v1 milestones)
- **Deployment playbook**: install layout (binary, `/usr/share/nmon/mibs` sidecar, systemd unit granting port 162, key file location), TLS/ACME or proxy setup, IdP configuration, backups, and upgrades. Raised by the user on 2026-10-22; deliberately postponed.
## Review artifacts
- From M3 on, each UI milestone's gate captures Playwright screenshots of every page into `docs/screenshots/<milestone>/` for the user to review.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

92
internal/alert/deliver.go Normal file
View File

@@ -0,0 +1,92 @@
package alert
import (
"context"
"time"
"github.com/v10networks/nmon/internal/store"
)
// deliver sends the row's pending notification if it is still valid and not
// suppressed. Suppressed notifications are held and sent when the suppression
// ends, if still valid; acked rows drop everything except a resolution.
func (ev *Evaluator) deliver(ctx context.Context, now time.Time, a *store.Alert, ent Entity) {
if ev.held(ctx, a, ent) {
return
}
p := a.NotifyPending
if p == "" {
return
}
acked := a.AckUntil != nil && now.Before(*a.AckUntil)
if !pendingValid(a, p) || (acked && p != NotifyResolved) {
a.NotifyPending = ""
ev.save(ctx, a)
return
}
if err := ev.o.Notifier.Notify(ctx, transitionOf(a, p)); err != nil {
ev.reg.Counter("nmon_alert_notify_errors_total", "rule", a.Rule).Inc()
ev.log.Warn("notify failed, will retry", "rule", a.Rule, "key", a.SeriesKey, "state", p, "err", err)
return
}
a.NotifyPending = ""
if p != NotifyStale {
a.NotifiedState = p
}
ev.save(ctx, a)
}
// held records the suppression reason on an active row and reports whether the
// pending notification must wait for the suppression to end.
func (ev *Evaluator) held(ctx context.Context, a *store.Alert, ent Entity) bool {
sup, why := ev.suppressed(ent)
if !isActive(a.State) {
return sup && a.NotifyPending != ""
}
reason := ""
if sup {
reason = why
if reason == "" {
reason = "suppressed"
}
}
if reason != a.SuppressedBy {
a.SuppressedBy = reason
ev.save(ctx, a)
}
return reason != "" && a.NotifyPending != ""
}
// pendingValid reports whether notification p still describes the row.
func pendingValid(a *store.Alert, p string) bool {
switch p {
case NotifyFiring:
return a.BaseState == store.AlertFiring && a.State != store.AlertFlapping
case NotifyResolved:
return a.State == store.AlertResolved && (a.NotifiedState == NotifyFiring || a.NotifiedState == NotifyFlapping)
case NotifyStale:
return a.State == store.AlertStale && a.PriorState == store.AlertFiring && a.NotifiedState == NotifyFiring
case NotifyFlapping:
return a.State == store.AlertFlapping
}
return false
}
func transitionOf(a *store.Alert, state string) Transition {
t := Transition{
AlertID: a.ID, Rule: a.Rule, State: state, Severity: a.Severity, Device: a.DeviceSlug, Iface: a.Iface,
Component: a.Component, Value: a.Value, Threshold: a.Threshold, ClearedAt: a.ClearedAt,
Summary: a.Summary, Description: a.Description,
}
if a.FiredAt != nil {
t.FiredAt = *a.FiredAt
}
return t
}
func (ev *Evaluator) suppressed(ent Entity) (bool, string) {
if ev.o.Suppressed == nil {
return false, ""
}
return ev.o.Suppressed(ent)
}

265
internal/alert/evaluator.go Normal file
View File

@@ -0,0 +1,265 @@
package alert
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"sync"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
)
// Timing of the state machine.
const (
// DefaultInterval is the evaluation period.
DefaultInterval = 30 * time.Second
// StaleAfter is how many consecutive evaluations without a series make it stale.
StaleAfter = 2
// FlapWindow and FlapMax: more than FlapMax state changes within
// FlapWindow put the entity into flapping for FlapHold.
FlapWindow = 10 * time.Minute
FlapMax = 5
FlapHold = 15 * time.Minute
queryTimeout = 25 * time.Second
// StoreHot is the name of the store rules run against.
StoreHot = "hot"
)
// Notification states handed to the Notifier.
const (
NotifyFiring = "firing"
NotifyResolved = "resolved"
NotifyStale = "stale"
NotifyFlapping = "flapping"
)
// Entity identifies one alerting series: a rule on a device, optionally an
// interface or a component of it.
type Entity struct {
Rule, Device, Iface, Component string
}
// Key is the persisted series key.
func (e Entity) Key() string { return e.Rule + "|" + e.Device + "|" + e.Iface + "|" + e.Component }
// Transition is one notification the evaluator wants delivered. The incident ID
// is "<AlertID>-<FiredAt unix>" (notify.IncidentID); a new firing after a
// resolution has a new AlertID.
type Transition struct {
AlertID int64
Rule, State, Severity string
Device, Iface, Component string
Value, Threshold float64
FiredAt time.Time
ClearedAt *time.Time
Summary, Description string
}
// Notifier receives transitions. A non-nil error means "not delivered"; the
// evaluator retries on its next cycle. The app adapts it to notify.Queue.
type Notifier interface {
Notify(ctx context.Context, t Transition) error
}
// Querier is the part of vmsup.Querier the evaluator uses.
type Querier interface {
Query(ctx context.Context, store, q string, ts time.Time) ([]byte, error)
}
// Options configures an Evaluator. Store, Querier, Notifier, Clock and Rules
// are required.
type Options struct {
Store store.Alerts
Querier Querier
Notifier Notifier
Clock clock.Clock
Rules []Rule
// Groups answers group membership for Rule.Overrides. Optional.
Groups GroupLookup
// ThresholdFor supplies a per-device (per-profile) threshold for a rule;
// ok false falls through to the rule default. Group overrides beat it.
ThresholdFor func(device, rule string) (threshold float64, ok bool)
// Unreliable reports an interface whose counters cannot be trusted
// (32-bit counters on fast links); rules with SkipUnreliable ignore it.
Unreliable func(iface string) bool
// Active reports whether an entity still exists (device and interface not
// retired). Inactive entities are closed as retired, silently.
Active func(Entity) bool
// Suppressed reports a maintenance window or dependency suppression for
// an entity. A suppressed alert still changes state but is not notified.
Suppressed func(Entity) (bool, string)
// StoreHealth is told after every query whether the store answered.
StoreHealth func(ok bool, err error)
Interval time.Duration // default DefaultInterval
StoreName string // default StoreHot
Metrics *selfmon.Registry
Logger *slog.Logger
}
// Evaluator runs the rules and owns the persisted state machine.
type Evaluator struct {
o Options
rules []*compiledRule
log *slog.Logger
reg *selfmon.Registry
mu sync.Mutex
}
// New validates the rules and returns an Evaluator.
func New(o Options) (*Evaluator, error) {
if o.Store == nil || o.Querier == nil || o.Notifier == nil || o.Clock == nil {
return nil, errors.New("alert: Store, Querier, Notifier and Clock are required")
}
if o.Interval <= 0 {
o.Interval = DefaultInterval
}
if o.StoreName == "" {
o.StoreName = StoreHot
}
if o.Logger == nil {
o.Logger = slog.Default()
}
if o.Metrics == nil {
o.Metrics = selfmon.Default
}
ev := &Evaluator{o: o, log: o.Logger.With("component", "alert"), reg: o.Metrics}
seen := map[string]bool{}
for _, r := range o.Rules {
if seen[r.Name] {
return nil, fmt.Errorf("alert: duplicate rule %q", r.Name)
}
seen[r.Name] = true
if r.Disabled {
continue
}
c, err := compile(r)
if err != nil {
return nil, err
}
ev.rules = append(ev.rules, c)
ev.reg.Counter("nmon_alert_eval_errors_total", "rule", r.Name) // visible at 0
}
return ev, nil
}
// Run evaluates once now and then every Interval until ctx ends.
func (ev *Evaluator) Run(ctx context.Context) error {
t := ev.o.Clock.NewTicker(ev.o.Interval)
defer t.Stop()
for {
ev.EvalOnce(ctx)
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C():
}
}
}
type reading struct {
ent Entity
value float64
}
// EvalOnce runs one evaluation cycle at the clock's current time.
func (ev *Evaluator) EvalOnce(ctx context.Context) {
ev.mu.Lock()
defer ev.mu.Unlock()
now := ev.o.Clock.Now().UTC()
rows, err := ev.o.Store.ListActiveAlertStates(ctx)
if err != nil {
ev.log.Error("load alert state", "err", err)
return // without state nothing can be decided; do not touch anything
}
active := ev.sweepRows(ctx, now, rows)
for _, r := range ev.rules {
ev.evalRule(ctx, now, r, active[r.Name])
}
}
// sweepRows settles rows that need no query (closed rows still owing a
// notification, rows of vanished entities or rules) and groups the rest by rule.
func (ev *Evaluator) sweepRows(ctx context.Context, now time.Time, rows []store.Alert) map[string][]*store.Alert {
ruleByName := map[string]*compiledRule{}
for _, r := range ev.rules {
ruleByName[r.Name] = r
}
active := map[string][]*store.Alert{} // rule -> active rows
for i := range rows {
a := &rows[i]
ent := entityOf(a)
if !isActive(a.State) {
ev.deliver(ctx, now, a, ent) // closed row with an undelivered notification
continue
}
if ruleByName[a.Rule] == nil || (ev.o.Active != nil && !ev.o.Active(ent)) {
ev.retire(ctx, now, a)
continue
}
active[a.Rule] = append(active[a.Rule], a)
}
return active
}
// evalRule queries one rule and applies each reading to its entity's row;
// rows whose series vanished get the no-data treatment.
func (ev *Evaluator) evalRule(ctx context.Context, now time.Time, r *compiledRule, rows []*store.Alert) {
readings, qerr := ev.query(ctx, r, now)
if ev.o.StoreHealth != nil {
ev.o.StoreHealth(qerr == nil, qerr)
}
if qerr != nil {
// State untouched: not below, not no-data, and never a resolution.
ev.reg.Counter("nmon_alert_eval_errors_total", "rule", r.Name).Inc()
ev.log.Warn("rule evaluation failed", "rule", r.Name, "err", qerr)
return
}
byKey := map[string]*store.Alert{}
for _, a := range rows {
byKey[a.SeriesKey] = a
}
seen := map[string]bool{}
for _, rd := range readings {
key := rd.ent.Key()
seen[key] = true
thr := r.resolveThreshold(rd.ent.Device, ev.o.Groups, ev.o.ThresholdFor)
ev.apply(ctx, now, r, rd, thr, byKey[key])
}
for _, a := range rows {
if !seen[a.SeriesKey] {
ev.nodata(ctx, now, r, a)
}
}
}
func isActive(state string) bool {
switch state {
case store.AlertPending, store.AlertFiring, store.AlertStale, store.AlertFlapping:
return true
}
return false
}
func entityOf(a *store.Alert) Entity {
return Entity{Rule: a.Rule, Device: a.DeviceSlug, Iface: a.Iface, Component: a.Component}
}
// SortedRuleNames lists the enabled rules, for diagnostics.
func (ev *Evaluator) SortedRuleNames() []string {
var n []string
for _, r := range ev.rules {
n = append(n, r.Name)
}
sort.Strings(n)
return n
}

View File

@@ -0,0 +1,660 @@
package alert
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
)
type fakeQ struct {
mu sync.Mutex
series map[string][]string // rule expr -> "device|component|value"
err map[string]error
calls int
}
func (q *fakeQ) Query(_ context.Context, _ string, expr string, _ time.Time) ([]byte, error) {
q.mu.Lock()
defer q.mu.Unlock()
q.calls++
if err := q.err[expr]; err != nil {
return nil, err
}
var res []map[string]any
for _, s := range q.series[expr] {
p := strings.Split(s, "|")
m := map[string]string{"device": p[0]}
if p[1] != "" {
m["component"] = p[1]
}
res = append(res, map[string]any{"metric": m, "value": []any{1, p[2]}})
}
return json.Marshal(map[string]any{"status": "success", "data": map[string]any{"resultType": "vector", "result": res}})
}
func (q *fakeQ) set(expr string, series ...string) {
q.mu.Lock()
defer q.mu.Unlock()
q.series[expr] = series
q.err = map[string]error{}
}
type fakeN struct {
mu sync.Mutex
got []Transition
fail error
}
func (n *fakeN) Notify(_ context.Context, t Transition) error {
n.mu.Lock()
defer n.mu.Unlock()
if n.fail != nil {
return n.fail
}
n.got = append(n.got, t)
return nil
}
func (n *fakeN) states() string {
n.mu.Lock()
defer n.mu.Unlock()
var s []string
for _, t := range n.got {
s = append(s, t.State)
}
return strings.Join(s, ",")
}
func (n *fakeN) reset() { n.mu.Lock(); n.got = nil; n.mu.Unlock() }
const testExpr = "test_expr"
type harness struct {
t *testing.T
st store.Store
clk *clock.Fake
q *fakeQ
n *fakeN
reg *selfmon.Registry
opts Options
ev *Evaluator
}
func testRule(f time.Duration) Rule {
return Rule{Name: "disk_full", Severity: SevCritical, Expr: testExpr, Op: OpGreater, Threshold: 90, For: f,
Labels: []string{"device", "component"}, Summary: "{{.Device}} {{.Component}} {{.Value}}", Description: "d"}
}
func newHarness(t *testing.T, rules ...Rule) *harness {
t.Helper()
if len(rules) == 0 {
rules = []Rule{testRule(0)}
}
dir := t.TempDir()
clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC))
ctx := context.Background()
st, err := store.Open(ctx, store.DriverSQLite, filepath.Join(dir, "inv.db"), nil, clk)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
h := &harness{t: t, st: st, clk: clk, q: &fakeQ{series: map[string][]string{}, err: map[string]error{}}, n: &fakeN{}, reg: selfmon.New()}
h.opts = Options{Store: st, Querier: h.q, Notifier: h.n, Clock: clk, Rules: rules, Metrics: h.reg}
h.restart()
return h
}
func (h *harness) restart() {
ev, err := New(h.opts)
if err != nil {
h.t.Fatal(err)
}
h.ev = ev
}
func (h *harness) step(d time.Duration) {
h.clk.Advance(d)
h.ev.EvalOnce(context.Background())
}
func (h *harness) alerts() []store.Alert {
as, _, err := h.st.ListAlerts(context.Background(), store.AlertFilter{}, store.Page{Limit: 1000})
if err != nil {
h.t.Fatal(err)
}
return as
}
func (h *harness) only() store.Alert {
h.t.Helper()
as := h.alerts()
if len(as) != 1 {
h.t.Fatalf("want 1 alert row, got %d", len(as))
}
return as[0]
}
func (h *harness) want(notifs, state string) {
h.t.Helper()
if got := h.n.states(); got != notifs {
h.t.Fatalf("notifications = %q, want %q", got, notifs)
}
if state != "" {
if got := h.alerts()[0].State; got != state { // newest row
h.t.Fatalf("state = %q, want %q", got, state)
}
}
}
const hi, lo = "d1|/var|95", "d1|/var|50"
func TestFiringAndResolveOnlyOnBelow(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(30 * time.Second)
h.want("firing", "firing")
h.step(30 * time.Second)
h.want("firing", "firing") // not re-notified
h.q.set(testExpr) // series gone: no-data, not below
h.step(30 * time.Second)
h.step(30 * time.Second)
h.want("firing,stale", "stale")
h.q.set(testExpr, lo)
h.step(30 * time.Second)
h.want("firing,stale,resolved", "resolved")
a := h.only()
if a.ClearedAt == nil || a.FiredAt == nil {
t.Fatal("fired/cleared not recorded")
}
}
func TestForDuration(t *testing.T) {
h := newHarness(t, testRule(2*time.Minute))
h.q.set(testExpr, hi)
h.step(30 * time.Second)
h.want("", "pending")
h.step(time.Minute)
h.want("", "pending")
h.step(time.Minute) // 2 min since pending
h.want("firing", "firing")
// A pending that drops below never fires and leaves no row.
h2 := newHarness(t, testRule(2*time.Minute))
h2.q.set(testExpr, hi)
h2.step(30 * time.Second)
h2.q.set(testExpr, lo)
h2.step(30 * time.Second)
h2.want("", "")
if n := len(h2.alerts()); n != 0 {
t.Fatalf("pending row kept: %d", n)
}
}
func TestStaleAfterTwoNoDataThenStillFiringOnReading(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(time.Second)
h.q.set(testExpr)
h.step(30 * time.Second)
h.want("firing", "firing") // one miss is nothing
h.step(30 * time.Second)
h.want("firing,stale", "stale")
h.step(30 * time.Second)
h.want("firing,stale", "stale") // exactly once
// The row remembers it was firing, and keeps the incident.
a := h.only()
if a.PriorState != store.AlertFiring || a.FiredAt == nil {
t.Fatalf("prior=%q fired=%v", a.PriorState, a.FiredAt)
}
h.q.set(testExpr, hi)
h.step(30 * time.Second)
h.want("firing,stale", "firing") // back to firing without a new notification
if h.only().NodataCount != 0 {
t.Fatal("nodata not reset")
}
}
func TestQueryErrorsLeaveFiringAndCount(t *testing.T) {
h := newHarness(t)
var health []bool
h.opts.StoreHealth = func(ok bool, _ error) { health = append(health, ok) }
h.restart()
h.q.set(testExpr, hi)
h.step(time.Second)
id := h.only().ID
h.q.err[testExpr] = errors.New("store down")
for i := 0; i < 10; i++ { // far past two evaluations
h.step(30 * time.Second)
}
a := h.only()
if a.State != store.AlertFiring || a.NodataCount != 0 || a.ID != id {
t.Fatalf("state %q nodata %d", a.State, a.NodataCount)
}
h.want("firing", "firing")
if got := h.reg.Counter("nmon_alert_eval_errors_total", "rule", "disk_full").Value(); got != 10 {
t.Fatalf("errors = %v", got)
}
if health[len(health)-1] || !health[0] {
t.Fatalf("health = %v", health)
}
// Recovers and keeps firing without a notification.
h.q.err = map[string]error{}
h.step(30 * time.Second)
h.want("firing", "firing")
}
func TestBadResponseIsAnError(t *testing.T) {
for _, raw := range []string{`{"status":"error","error":"boom"}`, `not json`, `{"status":"success","data":{"resultType":"matrix","result":[]}}`} {
if _, err := parseVector([]byte(raw)); err == nil {
t.Errorf("%s: want error", raw)
}
}
v, err := parseVector([]byte(`{"status":"success","data":{"resultType":"vector","result":[{"metric":{"device":"a"},"value":[1,"NaN"]},{"metric":{"device":"b"},"value":[1,"+Inf"]}]}}`))
if err != nil || len(v) != 1 || v[0].labels["device"] != "b" {
t.Fatalf("%v %v", v, err)
}
}
func TestRetireClosesSilently(t *testing.T) {
h := newHarness(t)
active := true
h.opts.Active = func(e Entity) bool { return active || e.Device != "d1" }
h.restart()
h.q.set(testExpr, hi)
h.step(time.Second)
h.q.set(testExpr)
h.step(30 * time.Second)
h.step(30 * time.Second)
h.want("firing,stale", "stale")
active = false
h.step(30 * time.Second)
h.want("firing,stale", "retired") // no "resolved"
if a := h.only(); a.ClearedAt == nil {
t.Fatal("retired row not closed")
}
// Series still in the store for a retired device are ignored.
h.q.set(testExpr, hi)
h.step(30 * time.Second)
h.want("firing,stale", "retired")
// A firing (not stale) alert retires the same way.
active = true
h.step(30 * time.Second)
h.n.reset()
active = false
h.q.set(testExpr)
h.step(30 * time.Second)
}
func TestRestartDoesNotRenotifyOrForget(t *testing.T) {
h := newHarness(t, testRule(time.Minute))
h.q.set(testExpr, hi)
h.step(time.Second)
h.restart() // pending survives
h.step(time.Minute)
h.want("firing", "firing")
h.restart()
h.step(30 * time.Second)
h.restart()
h.step(30 * time.Second)
h.want("firing", "firing")
if n := len(h.alerts()); n != 1 {
t.Fatalf("%d rows", n)
}
// And a restart does not resolve either, though the series is missing.
h.q.set(testExpr)
h.restart()
h.step(30 * time.Second)
h.want("firing", "firing")
}
func TestNewIncidentAfterResolveGetsNewID(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(time.Second)
first := h.n.got[0]
h.q.set(testExpr, lo)
h.step(time.Minute)
h.q.set(testExpr, hi)
h.step(time.Minute)
h.want("firing,resolved,firing", "")
second := h.n.got[2]
if first.AlertID == second.AlertID || first.FiredAt.Equal(second.FiredAt) {
t.Fatalf("incident reused: %+v %+v", first, second)
}
if h.n.got[1].AlertID != first.AlertID || h.n.got[1].ClearedAt == nil {
t.Fatal("resolved should carry the first incident")
}
if n := len(h.alerts()); n != 2 {
t.Fatalf("history rows = %d", n)
}
}
func TestFlapping(t *testing.T) {
h := newHarness(t)
// Six changes: firing, resolved, firing, resolved, firing, resolved.
for i := 0; i < 6; i++ {
if i%2 == 0 {
h.q.set(testExpr, hi)
} else {
h.q.set(testExpr, lo)
}
h.step(time.Minute)
}
// The sixth change (a resolution) became the one flapping notification.
h.want("firing,resolved,firing,resolved,firing,flapping", "flapping")
h.n.reset()
// Further flipping is silent.
for i := 0; i < 9; i++ {
if i%2 == 0 {
h.q.set(testExpr, hi)
} else {
h.q.set(testExpr, lo)
}
h.step(time.Minute)
}
h.want("", "flapping")
h.q.set(testExpr, hi)
h.step(6 * time.Minute) // 15 min after entry: exits with the then-current state
h.want("firing", "firing")
h.step(time.Minute)
h.want("firing", "firing")
if n := len(h.alerts()); n != 3 {
t.Fatalf("rows = %d (3 incidents: the flapping one hosts the rest)", n)
}
}
func TestFlappingExitResolved(t *testing.T) {
h := newHarness(t)
for i := 0; i < 6; i++ {
h.q.set(testExpr, []string{hi, lo}[i%2])
h.step(time.Minute)
}
h.n.reset()
h.q.set(testExpr, lo)
h.step(16 * time.Minute)
h.want("resolved", "")
for _, a := range h.alerts() {
if a.State == store.AlertFlapping {
t.Fatal("still flapping")
}
}
h.step(time.Minute)
h.want("resolved", "")
}
func TestFlapPersistsAcrossRestart(t *testing.T) {
h := newHarness(t)
for i := 0; i < 6; i++ {
h.q.set(testExpr, []string{hi, lo}[i%2])
h.step(time.Minute)
if i == 4 {
h.restart()
}
}
h.want("firing,resolved,firing,resolved,firing,flapping", "flapping")
h.restart()
h.q.set(testExpr, hi)
h.step(time.Minute)
h.n.reset()
h.q.set(testExpr, lo)
h.step(time.Minute)
h.want("", "flapping")
}
func TestMaintenanceSuppressesNotificationNotState(t *testing.T) {
h := newHarness(t)
sup := true
h.opts.Suppressed = func(Entity) (bool, string) { return sup, "maintenance" }
h.restart()
h.q.set(testExpr, hi)
h.step(time.Second)
h.want("", "firing")
if h.only().SuppressedBy != "maintenance" {
t.Fatal("reason not recorded")
}
h.step(time.Minute)
h.want("", "firing")
// Fired and resolved wholly inside the window: never announced.
h.q.set(testExpr, lo)
h.step(time.Minute)
sup = false
h.step(time.Minute)
h.want("", "resolved")
// Still firing when the window ends: announced then.
h2 := newHarness(t)
s2 := true
h2.opts.Suppressed = func(Entity) (bool, string) { return s2, "maintenance" }
h2.restart()
h2.q.set(testExpr, hi)
h2.step(time.Second)
s2 = false
h2.step(time.Minute)
h2.want("firing", "firing")
if h2.only().SuppressedBy != "" {
t.Fatal("reason not cleared")
}
}
func TestAckSuppressesUntilExpiry(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(time.Second)
id := h.only().ID
ctx := context.Background()
if err := h.st.AckAlert(ctx, id, "ann", "known", h.clk.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
// Goes stale and comes back while acked: stale is not announced.
h.q.set(testExpr)
h.step(30 * time.Second)
h.step(30 * time.Second)
h.want("firing", "stale")
h.q.set(testExpr, lo)
h.step(30 * time.Second)
// A resolution is still announced.
h.want("firing,resolved", "resolved")
// Unacked, an equivalent stale does notify; an expired ack does not suppress.
h2 := newHarness(t)
h2.q.set(testExpr, hi)
h2.step(time.Second)
if err := h2.st.AckAlert(ctx, h2.only().ID, "ann", "x", h2.clk.Now().Add(time.Minute)); err != nil {
t.Fatal(err)
}
h2.q.set(testExpr)
h2.step(2 * time.Minute)
h2.step(30 * time.Second)
h2.want("firing,stale", "stale")
a := h2.only()
if a.AckBy != "ann" || a.AckReason != "x" || a.AckUntil == nil {
t.Fatalf("ack lost on update: %+v", a)
}
if err := h2.st.AckAlert(ctx, a.ID+99, "a", "b", h2.clk.Now().Add(time.Hour)); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("got %v", err)
}
}
func TestNotifyFailureRetries(t *testing.T) {
h := newHarness(t)
h.n.fail = errors.New("queue full")
h.q.set(testExpr, hi)
h.step(time.Second)
h.want("", "firing")
h.step(30 * time.Second)
h.n.fail = nil
h.step(30 * time.Second)
h.want("firing", "firing")
h.step(30 * time.Second)
h.want("firing", "firing")
}
func TestGroupOverrideMostSpecificWins(t *testing.T) {
r := testRule(0)
r.Overrides = []Override{{Group: "all", Threshold: 80}, {Group: "dc1", Threshold: 95}, {Group: "db", Threshold: 99}}
g := fakeGroups{"d1": {{"all", 100}, {"dc1", 10}}, "d2": {{"all", 100}}, "d3": {{"db", 2}, {"dc1", 10}}}
if got := r.resolveThreshold("d1", g, nil); got != 95 {
t.Errorf("d1 = %v", got)
}
if got := r.resolveThreshold("d2", g, nil); got != 80 {
t.Errorf("d2 = %v", got)
}
if got := r.resolveThreshold("d3", g, nil); got != 99 {
t.Errorf("d3 = %v", got)
}
if got := r.resolveThreshold("d4", g, func(d, rule string) (float64, bool) { return 77, true }); got != 77 {
t.Errorf("d4 = %v", got)
}
if got := r.resolveThreshold("d1", g, func(d, rule string) (float64, bool) { return 77, true }); got != 95 {
t.Errorf("group beats profile: %v", got)
}
if got := r.resolveThreshold("d4", g, nil); got != 90 {
t.Errorf("default = %v", got)
}
h := newHarness(t, r)
h.opts.Groups = g
h.restart()
h.q.set(testExpr, "d1|/|92", "d2|/|92")
h.step(time.Second)
if h.n.states() != "firing" || h.n.got[0].Device != "d2" {
t.Fatalf("only d2 (threshold 80) should fire: %+v", h.n.got)
}
}
type fakeGroups map[string][]GroupRef
func (f fakeGroups) GroupsOf(d string) []GroupRef { return f[d] }
func TestUnreliableInterfaceSkipped(t *testing.T) {
r := Rule{Name: "interface_errors", Severity: SevWarning, Expr: testExpr, Op: OpGreater, Threshold: 0.1,
Labels: []string{"device", "component"}, SkipUnreliable: true, Summary: "s", Description: "d"}
h := newHarness(t, r)
h.opts.Unreliable = func(i string) bool { return i == "bad" }
h.restart()
h.q.set(testExpr, "d1|bad|5", "d1|good|5")
// The fake puts the third field in "component", Iface is empty, so use device label instead.
h2 := newHarness(t, func() Rule { r.Labels = []string{"device"}; return r }())
h2.opts.Unreliable = func(string) bool { return true }
h2.restart()
h2.q.set(testExpr, "d1||5")
h2.step(time.Second)
h2.want("firing", "") // no iface label: filter has nothing to match
}
func TestRetiredRuleClosesRows(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(time.Second)
h.opts.Rules = nil
h.restart()
h.step(time.Second)
if got := h.only().State; got != store.AlertRetired {
t.Fatalf("state %q", got)
}
h.want("firing", "")
}
func TestPruneAndList(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
h.q.set(testExpr, hi, "d2|/|95")
h.step(time.Second)
h.q.set(testExpr, lo, "d2|/|95")
h.step(time.Minute)
all, n, err := h.st.ListAlerts(ctx, store.AlertFilter{}, store.Page{Limit: 1})
if err != nil || n != 2 || len(all) != 1 {
t.Fatalf("%v %d %d", err, n, len(all))
}
act, n, _ := h.st.ListAlerts(ctx, store.AlertFilter{Active: true}, store.Page{})
if n != 1 || act[0].DeviceSlug != "d2" {
t.Fatalf("active %+v", act)
}
res, n, _ := h.st.ListAlerts(ctx, store.AlertFilter{States: []string{store.AlertResolved}, Device: "d1"}, store.Page{})
if n != 1 || len(res) != 1 {
t.Fatal("resolved filter")
}
if pruned, err := h.st.PruneAlerts(ctx, h.clk.Now().Add(-time.Hour)); err != nil || pruned != 0 {
t.Fatalf("%d %v", pruned, err)
}
h.clk.Advance(400 * 24 * time.Hour)
if pruned, err := h.st.PruneAlerts(ctx, h.clk.Now().Add(-365*24*time.Hour)); err != nil || pruned != 1 {
t.Fatalf("%d %v", pruned, err)
}
if _, n, _ := h.st.ListAlerts(ctx, store.AlertFilter{}, store.Page{}); n != 1 {
t.Fatalf("active row pruned: %d", n)
}
}
func TestDefaultRulesValid(t *testing.T) {
seen := map[string]bool{}
for _, r := range DefaultRules() {
if err := r.Validate(); err != nil {
t.Errorf("%v", err)
}
if _, err := compile(r); err != nil {
t.Errorf("%v", err)
}
seen[r.Name] = true
// Every default renders with real data.
c, _ := compile(r)
out := c.render(c.summary, TemplateData{Rule: r.Name, Device: "d", Iface: "i", Component: "c", Value: 1.5, Threshold: 2})
if strings.Contains(out, "template error") || out == "" {
t.Errorf("%s summary %q", r.Name, out)
}
}
for _, n := range []string{"interface_down", "interface_errors", "cpu_high", "memory_high", "disk_full", "ups_on_battery", "ups_runtime_low", "printer_supply_low", "temp_high"} {
if !seen[n] {
t.Errorf("missing default %s", n)
}
}
for _, bad := range []string{"", "up{", "sum(x))", `x{a="b}`} {
if checkExprShape(bad) == nil {
t.Errorf("%q passed shape check", bad)
}
}
r := DefaultRules()[0]
r.Op = "="
if r.Validate() == nil {
t.Error("bad op accepted")
}
}
func TestMergeRules(t *testing.T) {
got, err := MergeRules(DefaultRules(), []Rule{
{Name: "cpu_high", Threshold: 95, For: 5 * time.Minute, Overrides: []Override{{Group: "g", Threshold: 99}}},
{Name: "temp_high", Disabled: true},
{Name: "custom", Severity: SevInfo, Expr: "up == 0", Op: OpLess, Threshold: 1, Labels: []string{"device"}, Summary: "x", Description: "y"},
})
if err != nil {
t.Fatal(err)
}
byName := map[string]Rule{}
for _, r := range got {
byName[r.Name] = r
}
c := byName["cpu_high"]
if c.Threshold != 95 || c.For != 5*time.Minute || c.Expr == "" || c.Severity != SevWarning || len(c.Overrides) != 1 {
t.Errorf("cpu_high %+v", c)
}
if !byName["temp_high"].Disabled || byName["custom"].Expr == "" {
t.Error("merge")
}
if _, err := MergeRules(nil, []Rule{{Name: "x"}}); err == nil {
t.Error("incomplete rule accepted")
}
_ = fmt.Sprint
}

View File

@@ -0,0 +1,150 @@
//go:build integration && linux
package alert
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/metrics"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/vmsup"
)
// TestIntegrationDefaultRulesAgainstRealStore runs every default rule through
// the real hot store with synthetic samples: each query must be accepted, and
// the ones with data must fire.
func TestIntegrationDefaultRulesAgainstRealStore(t *testing.T) {
bin, err := filepath.Abs(filepath.Join("..", "vmsup", "bin", "victoria-metrics-prod-linux-"+runtime.GOARCH))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(bin); err != nil {
t.Skipf("store binary not found (%v); run scripts/fetch-vm.sh", err)
}
t.Setenv("NMON_VM_BINARY", bin)
t.Setenv("NMON_VM_BINARY_UNVERIFIED", "")
sup, err := vmsup.New(vmsup.Options{DataDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if err := sup.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sup.Stop(context.Background()) })
// Samples every minute for 7 h, ending 2 min ago (the store hides the
// newest seconds from instant queries).
end := time.Now().Add(-2 * time.Minute).Truncate(time.Minute)
var ss []metrics.Sample
for i := 0; i <= 7*60; i++ {
ts := end.Add(-time.Duration(7*60-i) * time.Minute)
frac := float64(i) / (7 * 60)
add := func(name string, v float64, kv ...string) { ss = append(ss, metrics.New(name, v, ts, kv...)) }
add("hrProcessorLoad", 96, "device", "cpu1", "component", "1")
add("hrProcessorLoad", 94, "device", "cpu1", "component", "2")
// /data grows 100 -> 800 of 1000: 80% now, but full within 24 h at that slope.
add("hrStorageUsed", 100+700*frac, "device", "disk1", "component", "/data")
add("hrStorageSize", 1000, "device", "disk1", "component", "/data")
// / is steady and fine; "Physical memory" is full but is not a disk.
add("hrStorageUsed", 400, "device", "disk1", "component", "/")
add("hrStorageSize", 1000, "device", "disk1", "component", "/")
add("hrStorageUsed", 970, "device", "mem1", "component", "Physical memory")
add("hrStorageSize", 1000, "device", "mem1", "component", "Physical memory")
add("ifOperStatus", 2, "device", "sw1", "iface", "if1")
add("ifAdminStatus", 1, "device", "sw1", "iface", "if1")
add("ifOperStatus", 2, "device", "sw1", "iface", "if2") // admin down: not an alert
add("ifAdminStatus", 2, "device", "sw1", "iface", "if2")
add("upsOutputSource", 5, "device", "ups1")
add("upsAdvBatteryRunTimeRemaining", 300, "device", "ups1")
add("prtMarkerSuppliesLevel", 5, "device", "prn1", "component", "Black Toner")
add("prtMarkerSuppliesMaxCapacity", 100, "device", "prn1", "component", "Black Toner")
add("prtMarkerSuppliesLevel", -2, "device", "prn1", "component", "Cyan Toner")
add("prtMarkerSuppliesMaxCapacity", 100, "device", "prn1", "component", "Cyan Toner")
add("mtxrHlTemperature", 80, "device", "rb1")
add("ifInErrors", float64(i)*60, "device", "sw1", "iface", "if3")
add("ifOutErrors", 0, "device", "sw1", "iface", "if3")
add("ifHCInOctets", float64(i)*60*150000, "device", "sw1", "iface", "if3") // 2500 pkt/s at 1500 B... errors 1/s
add("ifHCOutOctets", 0, "device", "sw1", "iface", "if3")
add("memAvailReal", 50, "device", "lin1")
add("memTotalReal", 1000, "device", "lin1")
}
q := vmsup.NewQueue(vmsup.StoreHot, sup, vmsup.QueueOptions{MaxAge: 72 * time.Hour})
if err := q.Write(ctx, ss); err != nil {
t.Fatal(err)
}
if err := q.Flush(ctx); err != nil {
t.Fatal(err)
}
db := filepath.Join(t.TempDir(), "inv.db")
clk := clock.NewFake(time.Now())
st, err := store.Open(ctx, store.DriverSQLite, db, nil, clk)
if err != nil {
t.Fatal(err)
}
defer func() { _ = st.Close() }()
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
rules := DefaultRules()
for i := range rules {
rules[i].For = 0
}
n := &fakeN{}
reg := selfmon.New()
ev, err := New(Options{Store: st, Querier: vmsup.NewQuerier(sup), Notifier: n, Clock: clk, Rules: rules, Metrics: reg})
if err != nil {
t.Fatal(err)
}
// The store ingests asynchronously; retry until the data is searchable.
deadline := time.Now().Add(40 * time.Second)
for {
ev.EvalOnce(ctx)
if len(n.got) >= 10 || time.Now().After(deadline) {
break
}
time.Sleep(2 * time.Second)
clk.Advance(2 * time.Second)
}
for _, r := range rules {
if e := reg.Counter("nmon_alert_eval_errors_total", "rule", r.Name).Value(); e != 0 {
t.Errorf("rule %s: %v query errors", r.Name, e)
}
}
fired := map[string]bool{}
for _, tr := range n.got {
if tr.State == NotifyFiring {
fired[tr.Rule+"/"+tr.Device+"/"+tr.Iface+"/"+tr.Component] = true
}
}
for _, want := range []string{
"cpu_high/cpu1//",
"disk_full/disk1///data", // predicted full only
"memory_high/mem1//",
"memory_high/lin1//", // UCD
"interface_down/sw1/if1/",
"ups_on_battery/ups1//",
"ups_runtime_low/ups1//",
"printer_supply_low/prn1//Black Toner",
"temp_high/rb1//mtxrHlTemperature",
"interface_errors/sw1/if3/",
} {
if !fired[want] {
t.Errorf("did not fire: %s (fired: %v)", want, fired)
}
}
for _, not := range []string{"disk_full/disk1///", "disk_full/mem1//Physical memory", "interface_down/sw1/if2/", "printer_supply_low/prn1//Cyan Toner"} {
if fired[not] {
t.Errorf("fired but should not: %s", not)
}
}
}

128
internal/alert/query.go Normal file
View File

@@ -0,0 +1,128 @@
package alert
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"time"
)
// query runs a rule and returns one reading per active, trusted entity.
func (ev *Evaluator) query(ctx context.Context, r *compiledRule, now time.Time) ([]reading, error) {
qctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
raw, err := ev.o.Querier.Query(qctx, ev.o.StoreName, r.Expr, now)
if err != nil {
return nil, err
}
series, err := parseVector(raw)
if err != nil {
return nil, err
}
best := map[string]reading{}
var order []string
for _, s := range series {
ent, ok := ev.entityOfSeries(r, s)
if !ok {
continue
}
k := ent.Key()
cur, seen := best[k]
if !seen {
order = append(order, k)
}
// Duplicate series for one entity: keep the one nearer to alerting.
if !seen || r.nearer(s.value, cur.value) {
best[k] = reading{ent, s.value}
}
}
out := make([]reading, 0, len(order))
for _, k := range order {
out = append(out, best[k])
}
return out, nil
}
// nearer reports whether a is closer to breaching than b.
func (r *compiledRule) nearer(a, b float64) bool {
if r.Op == OpLess {
return a < b
}
return a > b
}
// entityOfSeries maps a series to its entity. It reports false for series that
// lack a label the rule needs, are on unreliable interfaces, or are inactive.
func (ev *Evaluator) entityOfSeries(r *compiledRule, s sample) (Entity, bool) {
ent := Entity{Rule: r.Name}
for _, l := range r.Labels {
v := s.labels[l]
if v == "" {
ev.log.Debug("series without label", "rule", r.Name, "label", l)
return Entity{}, false
}
switch l {
case "device":
ent.Device = v
case "iface":
ent.Iface = v
case "component":
ent.Component = v
}
}
if r.SkipUnreliable && ent.Iface != "" && ev.o.Unreliable != nil && ev.o.Unreliable(ent.Iface) {
return Entity{}, false
}
if ev.o.Active != nil && !ev.o.Active(ent) {
return Entity{}, false
}
return ent, true
}
type sample struct {
labels map[string]string
value float64
}
// parseVector reads an instant-query response. NaN samples are dropped (no data).
func parseVector(raw []byte) ([]sample, error) {
var resp struct {
Status string `json:"status"`
ErrorType string `json:"errorType"`
Error string `json:"error"`
Data struct {
ResultType string `json:"resultType"`
Result []struct {
Metric map[string]string `json:"metric"`
Value [2]json.RawMessage `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("alert: bad query response: %w", err)
}
if resp.Status != "success" {
return nil, fmt.Errorf("alert: query status %q: %s %s", resp.Status, resp.ErrorType, resp.Error)
}
if resp.Data.ResultType != "vector" {
return nil, fmt.Errorf("alert: query returned %q, want vector", resp.Data.ResultType)
}
var out []sample
for _, r := range resp.Data.Result {
var str string
if err := json.Unmarshal(r.Value[1], &str); err != nil {
return nil, fmt.Errorf("alert: bad sample value %s", r.Value[1])
}
v, err := strconv.ParseFloat(str, 64)
if err != nil {
return nil, fmt.Errorf("alert: bad sample value %q", str)
}
if math.IsNaN(v) {
continue
}
out = append(out, sample{r.Metric, v})
}
return out, nil
}

426
internal/alert/rules.go Normal file
View File

@@ -0,0 +1,426 @@
// Package alert evaluates metric alert rules against the hot store every 30 s
// and keeps a persisted per-series state machine (pending, firing, resolved,
// stale, flapping). Reachability alerts are not here; they never need the
// metric store.
package alert
import (
"bytes"
"errors"
"fmt"
"strings"
"text/template"
"time"
)
// Severities.
const (
SevCritical = "critical"
SevWarning = "warning"
SevInfo = "info"
)
// Comparison operators.
const (
OpGreater = ">"
OpLess = "<"
)
// Rule is one metric alert rule. Expr is an instant MetricsQL query that
// returns one series per entity whose value is the measured quantity; the
// evaluator compares it with the threshold (so thresholds can differ per group
// or per device without rewriting the query). A series that is absent from a
// result is "no data", never "below".
type Rule struct {
Name string `yaml:"name" json:"name"`
Severity string `yaml:"severity" json:"severity"`
Expr string `yaml:"expr" json:"expr"`
Op string `yaml:"op" json:"op"` // ">" or "<"
// Threshold is the default; see Overrides and Evaluator Options.ThresholdFor.
Threshold float64 `yaml:"threshold" json:"threshold"`
For time.Duration `yaml:"for" json:"for"`
// Labels names the series labels that identify the entity, any of device,
// iface, component. A series missing one of them is skipped.
Labels []string `yaml:"labels" json:"labels"`
// Summary and Description are text/template strings over TemplateData.
Summary string `yaml:"summary" json:"summary"`
Description string `yaml:"description" json:"description"`
// Overrides are per-group thresholds; the most specific group wins.
Overrides []Override `yaml:"overrides" json:"overrides"`
// SkipUnreliable drops series whose interface the Unreliable filter flags
// (32-bit counters on fast links wrap too quickly to trust).
SkipUnreliable bool `yaml:"skip_unreliable" json:"skip_unreliable"`
Disabled bool `yaml:"disabled" json:"disabled"`
}
// Override replaces a rule's threshold for devices in a group.
type Override struct {
Group string `yaml:"group" json:"group"`
Threshold float64 `yaml:"threshold" json:"threshold"`
}
// TemplateData is what Summary and Description templates see.
type TemplateData struct {
Rule, Severity, Device, Iface, Component string
Value, Threshold float64
Op string
For time.Duration
}
var entityLabels = map[string]bool{"device": true, "iface": true, "component": true}
// Validate checks a rule is usable: names, operator, label set, parseable
// templates and a structurally sound expression.
func (r Rule) Validate() error {
var errs []error
add := func(f string, a ...any) {
errs = append(errs, fmt.Errorf("rule %q: "+f, append([]any{r.Name}, a...)...))
}
if strings.TrimSpace(r.Name) == "" || strings.ContainsAny(r.Name, "| \t\n") {
add("name must be non-empty without spaces or '|'")
}
switch r.Severity {
case SevCritical, SevWarning, SevInfo:
default:
add("severity %q is not critical, warning or info", r.Severity)
}
if r.Op != OpGreater && r.Op != OpLess {
add("op %q is not > or <", r.Op)
}
if r.For < 0 {
add("for is negative")
}
r.validateLabels(add)
if err := checkExprShape(r.Expr); err != nil {
add("expr: %v", err)
}
r.validateOverridesAndTemplates(add)
return errors.Join(errs...)
}
// validateLabels checks the rule's label set names only entity labels.
func (r Rule) validateLabels(add func(string, ...any)) {
if len(r.Labels) == 0 {
add("labels is empty")
}
for _, l := range r.Labels {
if !entityLabels[l] {
add("label %q is not device, iface or component", l)
}
}
}
// validateOverridesAndTemplates checks overrides name a group and the
// summary and description templates parse.
func (r Rule) validateOverridesAndTemplates(add func(string, ...any)) {
for _, o := range r.Overrides {
if o.Group == "" {
add("override without a group")
}
}
for n, t := range map[string]string{"summary": r.Summary, "description": r.Description} {
if _, err := template.New(n).Option("missingkey=error").Parse(t); err != nil {
add("%s template: %v", n, err)
}
}
}
// checkExprShape is a cheap structural check (non-empty, balanced brackets and
// quotes). The real parser is the store; the integration test runs every
// default rule against it.
func checkExprShape(s string) error {
if strings.TrimSpace(s) == "" {
return errors.New("empty")
}
var stack []byte
var quote byte
for i := 0; i < len(s); i++ {
c := s[i]
if quote != 0 {
switch {
case c == '\\' && quote != '`':
i++
case c == quote:
quote = 0
}
continue
}
switch c {
case '"', '\'', '`':
quote = c
case '(', '[', '{':
stack = append(stack, c)
case ')', ']', '}':
want := map[byte]byte{')': '(', ']': '[', '}': '{'}[c]
if len(stack) == 0 || stack[len(stack)-1] != want {
return fmt.Errorf("unbalanced %q at offset %d", c, i)
}
stack = stack[:len(stack)-1]
}
}
if quote != 0 {
return errors.New("unterminated string")
}
if len(stack) != 0 {
return fmt.Errorf("unclosed %q", stack[len(stack)-1])
}
return nil
}
type compiledRule struct {
Rule
summary, desc *template.Template
}
func compile(r Rule) (*compiledRule, error) {
if err := r.Validate(); err != nil {
return nil, err
}
c := &compiledRule{Rule: r}
var err error
if c.summary, err = template.New("summary").Option("missingkey=error").Parse(r.Summary); err != nil {
return nil, err
}
if c.desc, err = template.New("description").Option("missingkey=error").Parse(r.Description); err != nil {
return nil, err
}
return c, nil
}
func (c *compiledRule) render(t *template.Template, d TemplateData) string {
var b bytes.Buffer
if err := t.Execute(&b, d); err != nil {
return t.Name() + " template error: " + err.Error()
}
return b.String()
}
// breach reports whether v is on the alerting side of threshold.
func (r Rule) breach(v, threshold float64) bool {
if r.Op == OpLess {
return v < threshold
}
return v > threshold
}
// GroupRef is a group a device belongs to. Size is the member count; a smaller
// group is more specific.
type GroupRef struct {
Name string
Size int
}
// GroupLookup answers group membership for threshold overrides.
type GroupLookup interface {
GroupsOf(device string) []GroupRef
}
// resolveThreshold picks the threshold for a device: the override of the most
// specific (smallest) group the device is in, ties going to the earlier
// override in the rule; else thresholdFor (per-profile) if it has one; else the
// rule default.
func (r Rule) resolveThreshold(device string, g GroupLookup, thresholdFor func(device, rule string) (float64, bool)) float64 {
if g != nil && len(r.Overrides) > 0 {
size := map[string]int{}
for _, gr := range g.GroupsOf(device) {
size[gr.Name] = gr.Size
}
best, bestSize, found := 0.0, 0, false
for _, o := range r.Overrides {
sz, ok := size[o.Group]
if ok && (!found || sz < bestSize) {
best, bestSize, found = o.Threshold, sz, true
}
}
if found {
return best
}
}
if thresholdFor != nil {
if v, ok := thresholdFor(device, r.Name); ok {
return v
}
}
return r.Threshold
}
// patchRule overlays the non-zero tunables of c onto d.
func patchRule(d, c Rule) Rule {
if c.Severity != "" {
d.Severity = c.Severity
}
if c.Op != "" {
d.Op = c.Op
}
if c.Threshold != 0 {
d.Threshold = c.Threshold
}
if c.For != 0 {
d.For = c.For
}
if c.Overrides != nil {
d.Overrides = c.Overrides
}
d.Disabled = c.Disabled
return d
}
// MergeRules applies configured rules over defaults. A configured rule with
// the name of a default and no Expr patches that default's severity, op,
// threshold, for, overrides and disabled flag (zero values keep the default;
// set Disabled to switch a rule off). A rule with an Expr replaces the default
// wholesale; an unknown name needs a full definition. Order: defaults first,
// then new rules in config order.
func MergeRules(defaults, configured []Rule) ([]Rule, error) {
out := append([]Rule(nil), defaults...)
idx := map[string]int{}
for i, r := range out {
idx[r.Name] = i
}
for _, c := range configured {
i, known := idx[c.Name]
switch {
case known && c.Expr == "":
out[i] = patchRule(out[i], c)
case known:
out[i] = c
default:
idx[c.Name] = len(out)
out = append(out, c)
}
}
for _, r := range out {
if err := r.Validate(); err != nil {
return nil, err
}
}
return out, nil
}
const (
ramRE = `(?i).*(physical memory|(^|[^a-z])ram([^a-z]|$)).*`
notDiskE = `(?i).*(memory|swap|buffers|cached|(^|[^a-z])ram([^a-z]|$)).*`
)
// DefaultRules returns the shipped rules. Notes on the less obvious queries:
//
// - Every gauge is wrapped in last_over_time over a window of at least three
// poll intervals, so a 5 min poll does not fall out of the store's default
// 5 min lookback and read as "no data".
// - interface_errors: error ratio in percent. Packets are rate of
// ifHCIn/OutUcastPkts when the device exports them, else
// (ifHCInOctets+ifHCOutOctets)/1500 (the shipped profiles carry no packet
// counters, so the approximation is the usual path and understates packets
// for small-packet traffic, which only makes the alert earlier). The
// denominator is floored at 10 pkt/s so an idle port with a single error
// does not read as 100% (and a zero denominator never yields NaN).
// - memory_high: UCD memAvailReal/memTotalReal when present, else the
// hrStorage rows whose component (hrStorageDescr) matches /physical memory|ram/i.
// hrStorageType is not a label, so the description is all there is. UCD
// "available" excludes cache, so it reads high on Linux; that is the
// conventional reading.
// - disk_full: every hrStorage row that is not memory, swap, buffers or cache.
// The value is max(used%, 100 if predict_linear over 6 h reaches the size
// within 24 h; tagged kind=predicted so union keeps both), so one threshold covers both conditions; a threshold of 100
// or more disables the prediction half in effect.
// - printer_supply_low: negative levels (-1 other, -2 unknown, -3 "some
// remaining") are filtered out; the series then goes stale rather than low.
// - temp_high: the temperature metrics of the shipped profiles, each tagged
// with its metric name as component where the profile has no component
// label. entPhySensorValue is excluded, since it mixes units. Per-profile
// thresholds arrive through Options.ThresholdFor; the default is 70.
func DefaultRules() []Rule {
lab := func(l ...string) []string { return l }
return append(hostRules(lab), deviceRules(lab)...)
}
// hostRules are the interface, CPU, memory and disk rules of DefaultRules.
func hostRules(lab func(...string) []string) []Rule {
return []Rule{
{
Name: "interface_down", Severity: SevWarning, Op: OpGreater, Threshold: 0, For: 2 * time.Minute,
Labels: lab("device", "iface"),
Expr: `(ifOperStatus == bool 2) * on(device, iface) (ifAdminStatus == bool 1)`,
Summary: `{{.Device}} interface {{.Iface}} is down`,
Description: `Interface {{.Iface}} on {{.Device}} is operationally down while administratively up (for {{.For}}).`,
},
{
Name: "interface_errors", Severity: SevWarning, Op: OpGreater, Threshold: 0.1, For: 0,
Labels: lab("device", "iface"), SkipUnreliable: true,
Expr: `100 * (rate(ifInErrors[5m]) + rate(ifOutErrors[5m])) / clamp_min(` +
`(rate(ifHCInUcastPkts[5m]) + rate(ifHCOutUcastPkts[5m])) or ` +
`((rate(ifHCInOctets[5m]) + rate(ifHCOutOctets[5m])) / 1500), 10)`,
Summary: `{{.Device}} interface {{.Iface}} error ratio {{printf "%.2f" .Value}}%`,
Description: `Errors are {{printf "%.2f" .Value}}% of packets over 5 minutes on {{.Iface}} of {{.Device}} ` +
`(threshold {{printf "%.2f" .Threshold}}%).`,
},
{
Name: "cpu_high", Severity: SevWarning, Op: OpGreater, Threshold: 90, For: 10 * time.Minute,
Labels: lab("device"),
Expr: `avg by (device) (last_over_time(hrProcessorLoad[15m]))`,
Summary: `{{.Device}} CPU at {{printf "%.0f" .Value}}%`,
Description: `Average processor load on {{.Device}} is {{printf "%.1f" .Value}}% (threshold {{printf "%.0f" .Threshold}}%).`,
},
{
Name: "memory_high", Severity: SevWarning, Op: OpGreater, Threshold: 90, For: 10 * time.Minute,
Labels: lab("device"),
Expr: `(100 * (1 - last_over_time(memAvailReal[15m]) / (last_over_time(memTotalReal[15m]) > 0))) or on(device) ` +
`(max by (device) (100 * last_over_time(hrStorageUsed{component=~"` + ramRE + `"}[15m]) / ` +
`(last_over_time(hrStorageSize{component=~"` + ramRE + `"}[15m]) > 0)))`,
Summary: `{{.Device}} memory at {{printf "%.0f" .Value}}%`,
Description: `Memory use on {{.Device}} is {{printf "%.1f" .Value}}% (threshold {{printf "%.0f" .Threshold}}%).`,
},
{
Name: "disk_full", Severity: SevCritical, Op: OpGreater, Threshold: 90, For: 0,
Labels: lab("device", "component"),
Expr: `max by (device, component) (union(` +
`100 * last_over_time(hrStorageUsed{component!~"` + notDiskE + `"}[15m]) / ` +
`(last_over_time(hrStorageSize{component!~"` + notDiskE + `"}[15m]) > 0), ` +
`label_set(100 * (predict_linear(hrStorageUsed{component!~"` + notDiskE + `"}[6h], 86400) > bool ` +
`last_over_time(hrStorageSize{component!~"` + notDiskE + `"}[15m])), "kind", "predicted")))`,
Summary: `{{.Device}} {{.Component}} is {{printf "%.0f" .Value}}% full or filling`,
Description: `Storage {{.Component}} on {{.Device}} is above {{printf "%.0f" .Threshold}}% used or ` +
`predicted to fill within 24 hours (value {{printf "%.1f" .Value}}).`,
},
}
}
// deviceRules are the UPS, printer and temperature rules of DefaultRules.
func deviceRules(lab func(...string) []string) []Rule {
return []Rule{
{
Name: "ups_on_battery", Severity: SevCritical, Op: OpGreater, Threshold: 0, For: 0,
Labels: lab("device"),
Expr: `max by (device) (last_over_time(upsOutputSource[15m]) == bool 5)`,
Summary: `{{.Device}} UPS is on battery`,
Description: `The UPS {{.Device}} reports its output source as battery.`,
},
{
Name: "ups_runtime_low", Severity: SevCritical, Op: OpLess, Threshold: 10, For: time.Minute,
Labels: lab("device"),
Expr: `min by (device) (last_over_time(upsEstimatedMinutesRemaining[15m])) or on(device) ` +
`min by (device) (last_over_time(upsAdvBatteryRunTimeRemaining[15m]) / 60)`,
Summary: `{{.Device}} UPS runtime {{printf "%.0f" .Value}} min`,
Description: `The UPS {{.Device}} estimates {{printf "%.1f" .Value}} minutes of runtime (threshold {{printf "%.0f" .Threshold}}).`,
},
{
Name: "printer_supply_low", Severity: SevWarning, Op: OpLess, Threshold: 10, For: 0,
Labels: lab("device", "component"),
Expr: `100 * (last_over_time(prtMarkerSuppliesLevel[1h]) >= 0) / ` +
`(last_over_time(prtMarkerSuppliesMaxCapacity[1h]) > 0)`,
Summary: `{{.Device}} {{.Component}} at {{printf "%.0f" .Value}}%`,
Description: `Supply {{.Component}} on {{.Device}} is at {{printf "%.0f" .Value}}% (threshold {{printf "%.0f" .Threshold}}%).`,
},
{
Name: "temp_high", Severity: SevWarning, Op: OpGreater, Threshold: 70, For: 5 * time.Minute,
Labels: lab("device", "component"),
Expr: `union(` +
`label_set(last_over_time(mtxrHlTemperature[15m]), "component", "mtxrHlTemperature"), ` +
`label_set(last_over_time(mtxrHlProcessorTemperature[15m]), "component", "mtxrHlProcessorTemperature"), ` +
`label_set(last_over_time(upsAdvBatteryTemperature[15m]), "component", "upsAdvBatteryTemperature"), ` +
`last_over_time(jnxOperatingTemp[15m]))`,
Summary: `{{.Device}} {{.Component}} at {{printf "%.0f" .Value}} C`,
Description: `Temperature {{.Component}} on {{.Device}} is {{printf "%.1f" .Value}} C (threshold {{printf "%.0f" .Threshold}}).`,
},
}
}

241
internal/alert/state.go Normal file
View File

@@ -0,0 +1,241 @@
package alert
import (
"context"
"encoding/json"
"time"
"github.com/v10networks/nmon/internal/store"
)
func (ev *Evaluator) save(ctx context.Context, a *store.Alert) bool {
if err := ev.o.Store.UpsertAlertState(ctx, a); err != nil {
ev.log.Error("persist alert state", "rule", a.Rule, "key", a.SeriesKey, "err", err)
return false
}
return true
}
func tptr(t time.Time) *time.Time { return &t }
// apply handles one reading (below or above) for an entity; a is its active row or nil.
func (ev *Evaluator) apply(ctx context.Context, now time.Time, r *compiledRule, rd reading, thr float64, a *store.Alert) {
ent := rd.ent
breach := r.breach(rd.value, thr)
if a != nil && ev.flapExpired(now, a) {
ev.exitFlap(now, a)
if a.State == store.AlertResolved { // closed by the flap exit; this reading starts afresh
ev.finish(ctx, now, r, a, ent, rd.value, thr)
a = nil
}
}
if a == nil {
if !breach {
return
}
a = ev.newPending(ctx, now, r, ent)
}
a.NodataCount = 0
ev.clearStale(now, a)
if a.PendingSince == nil {
a.PendingSince = tptr(now)
}
if !ev.advance(ctx, now, r, a, breach) {
return
}
ev.finish(ctx, now, r, a, ent, rd.value, thr)
}
// newPending starts an incident row for ent, inheriting its flap history.
func (ev *Evaluator) newPending(ctx context.Context, now time.Time, r *compiledRule, ent Entity) *store.Alert {
a := &store.Alert{
Rule: r.Name, SeriesKey: ent.Key(), DeviceSlug: ent.Device, Iface: ent.Iface, Component: ent.Component,
Severity: r.Severity, State: store.AlertPending, BaseState: store.AlertPending, PendingSince: tptr(now),
FlapChanges: "[]",
}
// Flap history survives across incident rows.
if prev, err := ev.o.Store.LatestAlertBySeries(ctx, a.SeriesKey); err == nil {
a.FlapChanges = prev.FlapChanges
}
return a
}
// clearStale lets a real reading take a stale row back to its prior state.
func (ev *Evaluator) clearStale(now time.Time, a *store.Alert) {
if a.BaseState != store.AlertStale {
return
}
a.BaseState = a.PriorState
a.PriorState = ""
if a.BaseState == store.AlertPending {
a.PendingSince = tptr(now) // the old timer spans a gap we cannot vouch for
}
if !ev.inFlap(now, a) {
a.State = a.BaseState
}
}
// advance moves the row's base state according to the reading. It reports
// false when the row was dropped and there is nothing to persist.
func (ev *Evaluator) advance(ctx context.Context, now time.Time, r *compiledRule, a *store.Alert, breach bool) bool {
switch a.BaseState {
case store.AlertPending:
return ev.advancePending(ctx, now, r, a, breach)
case store.AlertFiring:
if !breach {
ev.resolve(now, a)
}
case store.AlertResolved: // only inside a flapping period
if breach {
a.BaseState, a.PendingSince, a.ClearedAt = store.AlertPending, tptr(now), nil
if r.For <= 0 {
ev.fire(now, a)
}
}
}
return true
}
func (ev *Evaluator) advancePending(ctx context.Context, now time.Time, r *compiledRule, a *store.Alert, breach bool) bool {
switch {
case !breach && !ev.inFlap(now, a):
if a.ID != 0 {
if err := ev.o.Store.DeletePendingAlert(ctx, a.ID); err != nil {
ev.log.Error("drop pending alert", "err", err)
}
}
return false // never fired: nothing to keep
case !breach:
a.BaseState = store.AlertResolved
a.ClearedAt = tptr(now)
case now.Sub(*a.PendingSince) >= r.For:
ev.fire(now, a)
}
return true
}
// finish refreshes the descriptive columns, persists and delivers.
func (ev *Evaluator) finish(ctx context.Context, now time.Time, r *compiledRule, a *store.Alert, ent Entity, v, thr float64) {
a.Value, a.Threshold, a.LastEvalAt = v, thr, tptr(now)
data := TemplateData{Rule: r.Name, Severity: r.Severity, Device: ent.Device, Iface: ent.Iface, Component: ent.Component,
Value: v, Threshold: thr, Op: r.Op, For: r.For}
a.Summary, a.Description = r.render(r.summary, data), r.render(r.desc, data)
ev.persistAndDeliver(ctx, now, a, ent)
}
func (ev *Evaluator) persistAndDeliver(ctx context.Context, now time.Time, a *store.Alert, ent Entity) {
if !ev.save(ctx, a) {
return
}
ev.deliver(ctx, now, a, ent)
}
func (ev *Evaluator) fire(now time.Time, a *store.Alert) {
a.BaseState = store.AlertFiring
a.ClearedAt = nil
if a.FiredAt == nil {
a.FiredAt = tptr(now)
}
ev.changed(now, a, NotifyFiring)
}
func (ev *Evaluator) resolve(now time.Time, a *store.Alert) {
a.BaseState = store.AlertResolved
a.ClearedAt = tptr(now)
ev.changed(now, a, NotifyResolved)
}
// changed records a firing/resolved change: it sets the visible state, counts
// it for flap damping and chooses the notification (flapping replaces the
// change that tipped the count over).
func (ev *Evaluator) changed(now time.Time, a *store.Alert, notify string) {
if ev.inFlap(now, a) {
a.State = store.AlertFlapping
return // silent while flapping
}
times := parseChanges(a.FlapChanges)
cut := now.Add(-FlapWindow).UnixMilli()
kept := times[:0]
for _, t := range times {
if t > cut {
kept = append(kept, t)
}
}
kept = append(kept, now.UnixMilli())
if len(kept) > FlapMax {
a.State = store.AlertFlapping
a.FlappingUntil = tptr(now.Add(FlapHold))
a.NotifyPending = NotifyFlapping
} else {
a.State = a.BaseState
a.NotifyPending = notify
}
a.FlapChanges = encodeChanges(kept)
}
func parseChanges(s string) []int64 {
var t []int64
_ = json.Unmarshal([]byte(s), &t)
return t
}
func encodeChanges(t []int64) string {
b, _ := json.Marshal(t)
return string(b)
}
func (ev *Evaluator) inFlap(now time.Time, a *store.Alert) bool {
return a.FlappingUntil != nil && now.Before(*a.FlappingUntil)
}
func (ev *Evaluator) flapExpired(now time.Time, a *store.Alert) bool {
return a.FlappingUntil != nil && !now.Before(*a.FlappingUntil)
}
// exitFlap ends a flapping period: the row takes its underlying state and one
// notification with that state goes out (a pending or resolved base reads as
// resolved, since nothing is firing).
func (ev *Evaluator) exitFlap(now time.Time, a *store.Alert) {
a.FlappingUntil = nil
a.FlapChanges = "[]"
switch a.BaseState {
case store.AlertFiring:
a.State = store.AlertFiring
a.NotifyPending = NotifyFiring
default:
a.BaseState, a.State = store.AlertResolved, store.AlertResolved
if a.ClearedAt == nil {
a.ClearedAt = tptr(now)
}
a.PendingSince = nil
a.NotifyPending = NotifyResolved
}
}
// nodata handles an active row whose series was absent from a successful query.
func (ev *Evaluator) nodata(ctx context.Context, now time.Time, r *compiledRule, a *store.Alert) {
ent := entityOf(a)
if ev.flapExpired(now, a) {
ev.exitFlap(now, a)
ev.persistAndDeliver(ctx, now, a, ent)
if !isActive(a.State) {
return
}
}
a.NodataCount++
a.LastEvalAt = tptr(now)
if a.NodataCount >= StaleAfter && a.BaseState != store.AlertStale && a.State != store.AlertFlapping {
a.PriorState = a.BaseState
a.BaseState, a.State = store.AlertStale, store.AlertStale
a.NotifyPending = NotifyStale
}
ev.persistAndDeliver(ctx, now, a, ent)
}
// retire closes a row for a device, interface or rule that no longer exists.
// It is not a resolution and sends nothing.
func (ev *Evaluator) retire(ctx context.Context, now time.Time, a *store.Alert) {
a.State, a.BaseState, a.PriorState = store.AlertRetired, store.AlertRetired, ""
a.ClearedAt, a.NotifyPending, a.FlappingUntil = tptr(now), "", nil
ev.save(ctx, a)
}

99
internal/api/admit.go Normal file
View File

@@ -0,0 +1,99 @@
package api
import (
"context"
"net"
"sync"
"time"
"github.com/danielgtaylor/huma/v2"
)
// admission keeps metric-store query load bounded: a per-client cap on
// free-form queries (so one dashboard cannot starve the rest) and a global
// semaphore on every store query (so the UI cannot crowd out the poll path).
// A request over its client cap is refused at once; one waiting for a global
// slot waits up to wait, then is refused. Both are 429 problems.
type admission struct {
global chan struct{}
perClient int
wait time.Duration
mu sync.Mutex
active map[string]int
}
func newAdmission(perClient, global int, wait time.Duration) *admission {
return &admission{global: make(chan struct{}, global), perClient: perClient, wait: wait, active: map[string]int{}}
}
// clientKey carries the caller's identity from the middleware to the handlers.
// Until authentication exists (M6) it is the remote address without the port.
type clientKey struct{}
func clientKeyMiddleware(ctx huma.Context, next func(huma.Context)) {
host, _, err := net.SplitHostPort(ctx.RemoteAddr())
if err != nil {
host = ctx.RemoteAddr()
}
next(huma.WithValue(ctx, clientKey{}, host))
}
func clientOf(ctx context.Context) string {
c, _ := ctx.Value(clientKey{}).(string)
return c
}
func tooMany(msg string) error {
return huma.Error429TooManyRequests(msg)
}
// acquire admits one query. freeForm queries also count against the client's cap.
// The returned release must be called exactly once.
func (a *admission) acquire(ctx context.Context, freeForm bool) (release func(), err error) {
client := clientOf(ctx)
if freeForm && !a.takeClient(client) {
return nil, tooMany("too many concurrent queries from " + client + "; the limit is " + itoa(a.perClient))
}
wctx, cancel := context.WithTimeout(ctx, a.wait)
defer cancel()
select {
case a.global <- struct{}{}:
case <-wctx.Done():
if freeForm {
a.dropClient(client)
}
return nil, tooMany("the metric store is busy; try again shortly")
}
return func() {
<-a.global
if freeForm {
a.dropClient(client)
}
}, nil
}
func (a *admission) takeClient(client string) bool {
a.mu.Lock()
defer a.mu.Unlock()
if a.active[client] >= a.perClient {
return false
}
a.active[client]++
return true
}
func (a *admission) dropClient(client string) {
a.mu.Lock()
defer a.mu.Unlock()
if a.active[client] <= 1 {
delete(a.active, client)
return
}
a.active[client]--
}
// admit takes a query slot for the request in ctx.
func (s *server) admit(ctx context.Context, freeForm bool) (func(), error) {
return s.adm.acquire(ctx, freeForm)
}

View File

@@ -3,6 +3,7 @@
package api
import (
"cmp"
"context"
"encoding/json"
"errors"
@@ -16,6 +17,8 @@ import (
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
)
@@ -30,6 +33,8 @@ const (
MaxSeries = 100_000
// DiscoveryTimeout bounds the seed discovery run by POST /devices.
DiscoveryTimeout = 30 * time.Second
// Version is the API's OpenAPI info.version; it changes only with the route contract, never with a build.
Version = "1.0.0"
)
// Querier runs read-only MetricsQL against the hot store. *vmsup.Querier implements it.
@@ -50,17 +55,43 @@ type Deps struct {
Registry *selfmon.Registry // served at /metrics; default selfmon.Default
Logger *slog.Logger // default slog.Default()
SNMPPort int // dev/test override of UDP 161; 0 = 161
// Profiles select a device's profile at seed discovery. Nil: no profile matching.
Profiles *profile.Set
// PollerStatus feeds GET /poller/health. *poller.Poller's Status fits.
PollerStatus func() []poller.DeviceStatus
// AfterDiscover is called after POST /devices/{slug}/discover succeeded, e.g. to make the poller reload that device.
AfterDiscover func(deviceID int64)
// DiscoveryTimeout overrides the 30 s seed discovery limit (tests).
DiscoveryTimeout time.Duration
// Reachability decides up/down/warning per device. Default: what is persisted now
// (poll state, last_seen, the ICMP and SNMP up series). M4 swaps in the alerter's state.
Reachability ReachabilitySource
// PollInterval is the normal poll cadence; a device unheard of for three of them is down. Default 60 s.
PollInterval time.Duration
// HotRetention, FiveMinuteRetention and LongRetention are the stores' retention periods. They decide where a
// graph stitches and what GET /meta reports. Defaults: 30 d, 120 d, 2 y.
HotRetention, FiveMinuteRetention, LongRetention time.Duration
// WriteQueueCapacity is the hot write queue's size in samples, for GET /poller/health. Default 2,000,000.
WriteQueueCapacity int
// MaxClientQueries and MaxGlobalQueries bound concurrent free-form queries per client and metric-store
// queries overall (defaults 4 and 16). AdmissionWait is how long a request waits for a global slot (default 5 s).
MaxClientQueries, MaxGlobalQueries int
AdmissionWait time.Duration
}
type server struct {
Deps
adm *admission
rate *counterRates
}
// New returns the API handler with every route registered.
func New(d Deps) http.Handler {
// withDefaults fills every unset optional dependency.
func (d Deps) withDefaults() Deps {
if d.Clock == nil {
d.Clock = clock.Real()
}
@@ -70,24 +101,72 @@ func New(d Deps) http.Handler {
if d.Logger == nil {
d.Logger = slog.Default()
}
if d.DiscoveryTimeout <= 0 {
d.DiscoveryTimeout = DiscoveryTimeout
d.DiscoveryTimeout = cmp.Or(d.DiscoveryTimeout, DiscoveryTimeout)
d.PollInterval = cmp.Or(d.PollInterval, time.Minute)
d.HotRetention = cmp.Or(d.HotRetention, 30*day)
d.FiveMinuteRetention = cmp.Or(d.FiveMinuteRetention, 120*day)
d.LongRetention = cmp.Or(d.LongRetention, 2*365*day)
d.WriteQueueCapacity = cmp.Or(d.WriteQueueCapacity, 2_000_000)
d.MaxClientQueries = cmp.Or(d.MaxClientQueries, 4)
d.MaxGlobalQueries = cmp.Or(d.MaxGlobalQueries, 16)
d.AdmissionWait = cmp.Or(d.AdmissionWait, 5*time.Second)
if d.Reachability == nil {
d.Reachability = &persistedReachability{q: d.Querier, clk: d.Clock, interval: d.PollInterval}
}
s := &server{d}
return d
}
const day = 24 * time.Hour
// New returns the API handler with every route registered.
func New(d Deps) http.Handler {
d = d.withDefaults()
s := &server{Deps: d, adm: newAdmission(d.MaxClientQueries, d.MaxGlobalQueries, d.AdmissionWait), rate: &counterRates{}}
s.rate.perMinute(s.readCounters()) // baseline, so the first poller/health call has a window to measure
mux := http.NewServeMux()
cfg := huma.DefaultConfig("nmon", "1.0.0")
api := s.newHuma(mux)
registerDocs(mux)
s.register(api)
return problemFallback(mux)
}
// Spec returns the OpenAPI 3.1 document of the API as indented JSON. It is the
// same document served at /openapi.json, generated without any dependencies:
// `nmon openapi > openapi.json` checks it in.
func Spec() ([]byte, error) {
s := &server{Deps: Deps{}.withDefaults()}
api := s.newHuma(http.NewServeMux())
s.register(api)
return json.MarshalIndent(api.OpenAPI(), "", " ")
}
// register adds every operation to api.
func (s *server) register(api huma.API) {
s.registerDevices(api)
s.registerDeviceList(api)
s.registerDeviceViews(api)
s.registerInterfaces(api)
s.registerGraph(api)
s.registerOps(api)
s.registerHealth(api)
s.registerReviews(api)
s.registerQuery(api)
s.registerMeta(api)
s.registerMetrics(api)
}
// newHuma builds the huma API on mux. /docs is served by registerDocs from embedded assets, not huma's CDN page.
func (s *server) newHuma(mux *http.ServeMux) huma.API {
configureHuma()
cfg := huma.DefaultConfig("nmon", Version)
cfg.Info.Description = "nmon network monitor API."
cfg.OpenAPIPath = "/openapi"
cfg.DocsPath = "/docs"
cfg.DocsRenderer = huma.DocsRendererSwaggerUI
cfg.DocsPath = ""
cfg.CreateHooks = nil // no $schema fields or Link headers in responses
cfg.OnAddOperation = append(cfg.OnAddOperation, addDefaultProblem)
api := humago.New(mux, cfg)
s.registerDevices(api)
s.registerQuery(api)
s.registerMetrics(api)
return problemFallback(mux)
api.UseMiddleware(clientKeyMiddleware)
return api
}
// storeErr maps store sentinels to problem responses; anything else is a 500

View File

@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -16,6 +17,7 @@ import (
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/store"
@@ -24,25 +26,57 @@ import (
"github.com/v10networks/nmon/internal/vmsup"
)
// querier is the fake metric store. fn, when set, answers every query;
// otherwise body does. Every query is recorded.
type querier struct {
mu sync.Mutex
body []byte
err error
fn func(store, expr string, from, to time.Time, step time.Duration) ([]byte, error)
q string
ts, from, to time.Time
step time.Duration
instant, rangeCnt int
calls []call
}
type call struct {
store, expr string
from, to time.Time
step time.Duration
}
func (q *querier) Query(_ context.Context, st, expr string, ts time.Time) ([]byte, error) {
q.mu.Lock()
q.instant++
q.q, q.ts = expr, ts
return q.body, q.err
q.calls = append(q.calls, call{store: st, expr: expr, from: ts, to: ts})
fn, body, err := q.fn, q.body, q.err
q.mu.Unlock()
if fn != nil {
return fn(st, expr, ts, ts, 0)
}
return body, err
}
func (q *querier) QueryRange(_ context.Context, st, expr string, from, to time.Time, step time.Duration) ([]byte, error) {
q.mu.Lock()
q.rangeCnt++
q.q, q.from, q.to, q.step = expr, from, to, step
return q.body, q.err
q.calls = append(q.calls, call{store: st, expr: expr, from: from, to: to, step: step})
fn, body, err := q.fn, q.body, q.err
q.mu.Unlock()
if fn != nil {
return fn(st, expr, from, to, step)
}
return body, err
}
// queries returns a copy of the recorded queries.
func (q *querier) queries() []call {
q.mu.Lock()
defer q.mu.Unlock()
return append([]call(nil), q.calls...)
}
type refresher struct{ n atomic.Int32 }
@@ -50,19 +84,32 @@ type refresher struct{ n atomic.Int32 }
func (r *refresher) Refresh() { r.n.Add(1) }
type env struct {
clk *clock.Fake
h http.Handler
st store.Store
q *querier
ref *refresher
reg *selfmon.Registry
afterID atomic.Int64
status []poller.DeviceStatus
}
func newEnv(t *testing.T, port int) *env {
func newEnv(t *testing.T, port int) *env { return newEnvDeps(t, port, nil) }
// newEnvDeps is newEnv with a chance to change the API's dependencies.
func newEnvDeps(t *testing.T, port int, mutate func(*api.Deps)) *env {
clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC))
e := &env{st: testutil.OpenStore(t, clk), q: &querier{body: []byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`)},
e := &env{clk: clk, st: testutil.OpenStore(t, clk), q: &querier{body: []byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`)},
ref: &refresher{}, reg: selfmon.New()}
e.h = api.New(api.Deps{Store: e.st, Querier: e.q, Poller: e.ref, Clock: clk, Registry: e.reg, SNMPPort: port,
DiscoveryTimeout: 3 * time.Second})
d := api.Deps{Store: e.st, Querier: e.q, Poller: e.ref, Clock: clk, Registry: e.reg, SNMPPort: port,
DiscoveryTimeout: 3 * time.Second,
PollerStatus: func() []poller.DeviceStatus { return e.status },
AfterDiscover: func(id int64) { e.afterID.Store(id) }}
if mutate != nil {
mutate(&d)
}
e.h = api.New(d)
return e
}
@@ -270,3 +317,24 @@ func TestQueryProxy(t *testing.T) {
e.q.err = errors.New("connection reset")
isProblem(t, e.do("GET", "/api/v1/query?q=x", ""), 502)
}
func TestDocsAreSelfHosted(t *testing.T) {
e := newEnv(t, 0)
page := e.do("GET", "/docs", "")
if page.Code != 200 || strings.Contains(page.Body.String(), "http") && strings.Contains(page.Body.String(), "unpkg") ||
!strings.Contains(page.Body.String(), "/docs/assets/swagger-ui-bundle.js") {
t.Fatalf("/docs: %d %s", page.Code, page.Body)
}
for path, ct := range map[string]string{"/docs/assets/swagger-ui-bundle.js": "javascript", "/docs/assets/swagger-ui.css": "css", "/docs/assets/init.js": "javascript"} {
rec := e.do("GET", path, "")
if rec.Code != 200 || rec.Body.Len() < 50 || !strings.Contains(rec.Header().Get("Content-Type"), ct) {
t.Errorf("%s: %d %s %d bytes", path, rec.Code, rec.Header().Get("Content-Type"), rec.Body.Len())
}
}
if !strings.Contains(e.do("GET", "/docs/assets/init.js", "").Body.String(), "/openapi.json") {
t.Error("the page must load the spec from this server")
}
if rec := e.do("GET", "/openapi.json", ""); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"openapi":"3.1.0"`) {
t.Errorf("openapi.json: %d", rec.Code)
}
}

View File

@@ -0,0 +1,435 @@
package api_test
import (
"encoding/json"
"fmt"
"os"
"slices"
"sort"
"strings"
"testing"
"github.com/v10networks/nmon/internal/api"
)
// The UI is built against web/openapi.contract.json. These tests hold the
// generated OpenAPI document to it: every path, method, parameter, request
// body and response field of the contract must exist in the generated spec
// with a compatible type. Additions in the generated spec are fine.
type obj = map[string]any
// knownDivergences are places where the generated spec deliberately differs
// from the hand-written contract. Each has a reason; anything else is a failure.
var knownDivergences = map[string]string{
// M1/M2 shipped these two as strings that also accept Unix seconds and "30s"; the routes are frozen once shipped
// and the UI does not call them.
"param GET /api/v1/query_range step: type": "frozen since M1: accepts 30s and plain seconds",
"param GET /api/v1/query_range from: format": "frozen since M1: accepts RFC 3339 and Unix seconds",
"param GET /api/v1/query_range to: format": "frozen since M1: accepts RFC 3339 and Unix seconds",
"param GET /api/v1/query time: format": "frozen since M1: accepts RFC 3339 and Unix seconds",
// The contract left q optional; an empty expression is meaningless, so the API requires it.
"param GET /api/v1/query q: required": "q is required by the frozen M1 route",
"param GET /api/v1/query_range q: required": "q is required by the frozen M1 route",
"param GET /api/v1/query_range step: required": "step is required by the frozen M1 route",
}
type checker struct {
t *testing.T
gen obj
con obj
failed []string
}
func (c *checker) fail(key, format string, a ...any) {
if _, ok := knownDivergences[key]; ok {
return
}
c.failed = append(c.failed, fmt.Sprintf("%s: %s", key, fmt.Sprintf(format, a...)))
}
func load(t *testing.T) *checker {
t.Helper()
raw, err := os.ReadFile("../../web/openapi.contract.json")
if err != nil {
t.Fatal(err)
}
var con obj
if err := json.Unmarshal(raw, &con); err != nil {
t.Fatal(err)
}
spec, err := api.Spec()
if err != nil {
t.Fatal(err)
}
var gen obj
if err := json.Unmarshal(spec, &gen); err != nil {
t.Fatal(err)
}
return &checker{t: t, gen: gen, con: con}
}
func get(m any, keys ...string) any {
for _, k := range keys {
mm, ok := m.(obj)
if !ok {
return nil
}
m = mm[k]
}
return m
}
// resolve follows $ref within doc.
func resolve(doc obj, s any) obj {
m, _ := s.(obj)
for m != nil {
ref, ok := m["$ref"].(string)
if !ok {
return m
}
m, _ = get(doc, strings.Split(strings.TrimPrefix(ref, "#/"), "/")...).(obj)
}
return nil
}
// shape is a schema reduced to what the comparison needs.
type shape struct {
typ string
nullable bool
s obj
}
func shapeOf(doc obj, raw any) shape {
s := resolve(doc, raw)
sh := shape{s: s}
switch t := s["type"].(type) {
case string:
sh.typ = t
case []any:
for _, x := range t {
if x == "null" {
sh.nullable = true
} else {
sh.typ, _ = x.(string)
}
}
}
if alts, ok := s["anyOf"].([]any); ok {
for _, a := range alts {
am := resolve(doc, a)
if am["type"] == "null" {
sh.nullable = true
continue
}
sub := shapeOf(doc, am)
sh.typ, sh.s = sub.typ, sub.s
}
}
return sh
}
// compat checks that gen can stand where con is expected. request flips the
// direction of the required/nullable rules: a client sends what the contract
// allows, and receives what the contract promises.
func (c *checker) compat(key string, con, gen any, request bool) {
cs, gs := shapeOf(c.con, con), shapeOf(c.gen, gen)
if cs.typ != gs.typ && (cs.typ != "number" || gs.typ != "integer") {
c.fail(key+": type", "contract %q, generated %q", cs.typ, gs.typ)
return
}
if !request && cs.nullable && !gs.nullable {
c.fail(key+": nullable", "contract allows null, generated never sends it (fine) -- but UI types expect null; declare nullable")
}
if !request && gs.nullable && !cs.nullable {
c.fail(key+": nullable", "generated may send null, contract does not allow it")
}
for _, k := range []string{"format", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"} {
if cv, ok := cs.s[k]; ok && fmt.Sprint(cv) != fmt.Sprint(gs.s[k]) {
c.fail(key+": "+k, "contract %v, generated %v", cv, gs.s[k])
}
}
c.enums(key, cs.s, gs.s, request)
switch cs.typ {
case "object":
c.object(key, cs.s, gs.s, request)
case "array":
c.array(key, cs.s, gs.s, request)
}
}
func (c *checker) enums(key string, con, gen obj, request bool) {
ce, _ := con["enum"].([]any)
if len(ce) == 0 {
return
}
ge, _ := gen["enum"].([]any)
have := func(list []any, v any) bool { return slices.Contains(list, v) }
for _, v := range ge {
if !request && !have(ce, v) {
c.fail(key+": enum", "generated may send %v, not in the contract", v)
}
}
for _, v := range ce {
if (request || len(ge) == 0) && !have(ge, v) {
c.fail(key+": enum", "contract value %v missing from generated", v)
}
}
}
func (c *checker) object(key string, con, gen obj, request bool) {
cp, _ := con["properties"].(obj)
gp, _ := gen["properties"].(obj)
names := make([]string, 0, len(cp))
for n := range cp {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
if _, ok := gp[n]; !ok {
c.fail(key+"."+n, "missing from generated")
continue
}
c.compat(key+"."+n, cp[n], gp[n], request)
}
creq, greq := strset(con["required"]), strset(gen["required"])
if request {
for n := range greq {
if !creq[n] {
c.fail(key+"."+n+": required", "generated requires a field the contract made optional")
}
}
return
}
for n := range creq {
if !greq[n] {
c.fail(key+"."+n+": required", "contract promises it, generated may omit it")
}
}
}
func strset(v any) map[string]bool {
out := map[string]bool{}
l, _ := v.([]any)
for _, x := range l {
out[fmt.Sprint(x)] = true
}
return out
}
func (c *checker) array(key string, con, gen obj, request bool) {
if pc, ok := con["prefixItems"].([]any); ok {
pg, _ := gen["prefixItems"].([]any)
if len(pg) != len(pc) {
c.fail(key+": prefixItems", "contract has %d, generated %d", len(pc), len(pg))
return
}
for i := range pc {
c.compat(fmt.Sprintf("%s[%d]", key, i), pc[i], pg[i], request)
}
return
}
if con["items"] != nil {
if gen["items"] == nil {
c.fail(key+": items", "generated array has no items schema")
return
}
c.compat(key+"[]", con["items"], gen["items"], request)
}
}
// operations lists "METHOD path" for every operation of doc, with path-level parameters merged in.
func operations(doc obj) map[string]obj {
out := map[string]obj{}
paths, _ := doc["paths"].(obj)
for p, item := range paths {
im, _ := item.(obj)
for m, op := range im {
om, ok := op.(obj)
if !ok || m == "parameters" {
continue
}
merged := obj{}
for k, v := range om {
merged[k] = v
}
params, _ := im["parameters"].([]any)
merged["parameters"] = append(slices.Clone(params), asList(om["parameters"])...)
out[strings.ToUpper(m)+" "+p] = merged
}
}
return out
}
func asList(v any) []any { l, _ := v.([]any); return l }
func (c *checker) params(opKey string, con, gen obj) {
genByName := map[string]obj{}
for _, p := range asList(gen["parameters"]) {
pm := resolve(c.gen, p)
genByName[fmt.Sprint(pm["in"])+" "+fmt.Sprint(pm["name"])] = pm
}
conByName := map[string]bool{}
for _, p := range asList(con["parameters"]) {
pm := resolve(c.con, p)
id := fmt.Sprint(pm["in"]) + " " + fmt.Sprint(pm["name"])
conByName[id] = true
key := fmt.Sprintf("param %s %s", opKey, pm["name"])
g, ok := genByName[id]
if !ok {
c.fail(key, "missing from generated")
continue
}
if pm["required"] == true && g["required"] != true {
c.fail(key+": required", "contract requires it, generated does not")
}
if pm["required"] != true && g["required"] == true {
c.fail(key+": required", "generated requires a parameter the contract made optional")
}
c.compat(key, pm["schema"], g["schema"], true)
}
for id, g := range genByName {
if g["required"] == true && !conByName[id] {
c.fail(fmt.Sprintf("param %s %s", opKey, g["name"]), "new required parameter not in the contract")
}
}
}
func (c *checker) body(opKey string, con, gen obj) {
cb := resolve(c.con, get(con, "requestBody"))
if cb == nil {
return
}
gb := resolve(c.gen, get(gen, "requestBody"))
cs, gs := get(cb, "content", "application/json", "schema"), get(gb, "content", "application/json", "schema")
if gs == nil {
c.fail("body "+opKey, "generated has no JSON request body")
return
}
c.compat("body "+opKey, cs, gs, true)
}
func (c *checker) responses(opKey string, con, gen obj) {
cr, _ := con["responses"].(obj)
gr, _ := gen["responses"].(obj)
for code, r := range cr {
g, ok := gr[code]
if !ok {
c.fail("response "+opKey+" "+code, "missing from generated")
continue
}
cm, _ := get(resolve(c.con, r), "content").(obj)
for ct, media := range cm {
gm := get(resolve(c.gen, g), "content", ct, "schema")
if gm == nil {
c.fail("response "+opKey+" "+code, "no %s content in generated", ct)
continue
}
c.compat(fmt.Sprintf("response %s %s", opKey, code), get(media, "schema"), gm, false)
}
}
}
func TestGeneratedSpecMatchesUIContract(t *testing.T) {
c := load(t)
if c.gen["openapi"] != "3.1.0" {
t.Fatalf("generated openapi version %v, want 3.1.0", c.gen["openapi"])
}
conOps, genOps := operations(c.con), operations(c.gen)
for key, con := range conOps {
gen, ok := genOps[key]
if !ok {
c.fail("operation "+key, "missing from generated spec")
continue
}
c.params(key, con, gen)
c.body(key, con, gen)
c.responses(key, con, gen)
}
if len(c.failed) > 0 {
sort.Strings(c.failed)
t.Fatalf("generated spec breaks the UI contract (%d):\n %s", len(c.failed), strings.Join(c.failed, "\n "))
}
}
// TestContractDivergencesStayNeeded fails when a divergence is no longer needed, so the list shrinks.
func TestContractDivergencesStayNeeded(t *testing.T) {
c := load(t)
saved := knownDivergences
defer func() { knownDivergences = saved }()
knownDivergences = map[string]string{}
conOps, genOps := operations(c.con), operations(c.gen)
for key, con := range conOps {
if gen, ok := genOps[key]; ok {
c.params(key, con, gen)
}
}
hit := strings.Join(c.failed, "\n")
for k := range saved {
if !strings.Contains(hit, k) {
t.Errorf("divergence %q no longer happens; remove it", k)
}
}
}
// TestResponseSchemasExposeNoIDsOrCredentials walks every schema reachable from a
// response in the generated spec: no integer id, and no credential-like property.
func TestResponseSchemasExposeNoIDsOrCredentials(t *testing.T) {
c := load(t)
seen := map[string]bool{}
var walk func(path string, raw any)
walk = func(path string, raw any) {
m, _ := raw.(obj)
if ref, ok := m["$ref"].(string); ok {
if seen[ref] {
return
}
seen[ref] = true
walk(path+"→"+ref, resolve(c.gen, m))
return
}
props, _ := m["properties"].(obj)
for name, p := range props {
lower := strings.ToLower(name)
if (lower == "id" || strings.HasSuffix(lower, "_id")) && shapeOf(c.gen, p).typ != "string" {
t.Errorf("%s.%s is not a string id", path, name)
}
for _, bad := range []string{"community", "password", "pass", "hash", "secret", "token", "credential", "key"} {
if strings.Contains(lower, bad) {
t.Errorf("%s.%s looks like a credential", path, name)
}
}
walk(path+"."+name, p)
}
if items := m["items"]; items != nil {
walk(path+"[]", items)
}
for _, k := range []string{"anyOf", "oneOf", "allOf"} {
for _, a := range asList(m[k]) {
walk(path, a)
}
}
}
for key, op := range operations(c.gen) {
responses, _ := op["responses"].(obj)
for code, r := range responses {
walk(key+" "+code, get(resolve(c.gen, r), "content", "application/json", "schema"))
}
}
}
// TestCheckedInOpenAPIIsCurrent keeps openapi.json (the file the UI client is
// generated from) equal to what the handlers produce.
func TestCheckedInOpenAPIIsCurrent(t *testing.T) {
want, err := api.Spec()
if err != nil {
t.Fatal(err)
}
got, err := os.ReadFile("../../openapi.json")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(got)) != strings.TrimSpace(string(want)) {
t.Fatal("openapi.json is stale: run `go run ./cmd/nmon openapi > openapi.json`")
}
}

226
internal/api/detail_test.go Normal file
View File

@@ -0,0 +1,226 @@
package api_test
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/store"
)
func TestDeviceDetailLiveRates(t *testing.T) {
e := newEnv(t, 0)
core := e.device(t, "Core", "yyz", "core", func(d *store.Device) { d.HWSerial, d.SysDescr = "SN1", "JunOS" })
other := e.device(t, "Other", "", "")
i1 := e.iface(t, core, 1, "xe-0/0/1", "up")
i2 := e.iface(t, core, 2, "xe-0/0/2", "32bit", func(i *store.Interface) { i.HasHCCounters = false })
i3 := e.iface(t, core, 3, "xe-0/0/3", "bad", func(i *store.Interface) { i.Unreliable, i.HasHCCounters = true, false })
i4 := e.iface(t, core, 4, "xe-0/0/4", "idle")
i5 := e.iface(t, core, 5, "xe-0/0/5", "gone")
if err := e.st.RetireInterface(t.Context(), i5.ID); err != nil {
t.Fatal(err)
}
if err := e.st.SetParents(t.Context(), core.ID, store.RelNetwork, []int64{other.ID}); err != nil {
t.Fatal(err)
}
e.q.fn = func(_, expr string, _, _ time.Time, _ time.Duration) ([]byte, error) {
if !strings.Contains(expr, `device="`+core.Slug+`"`) {
return emptyMatrix, nil
}
l := func(id, k string) map[string]string { return map[string]string{"iface": id, "k": k} }
return vector(
vrow{l(i1.ID, "in_hc"), 8e6}, vrow{l(i1.ID, "out_hc"), 4e6}, vrow{l(i1.ID, "in_err"), 0.5}, vrow{l(i1.ID, "out_err"), 0},
vrow{l(i1.ID, "in_32"), 999}, // a stale 32-bit series must not win over the 64-bit counter
vrow{l(i2.ID, "in_32"), 100}, vrow{l(i2.ID, "out_32"), 200},
vrow{l(i3.ID, "in_32"), 5}, vrow{l(i3.ID, "out_32"), 5}, vrow{l(i3.ID, "in_err"), 1},
vrow{map[string]string{"k": "uptime"}, 86400},
), nil
}
var d api.DeviceView
rec := e.getJSON(t, "/api/v1/devices/"+core.Slug, &d)
assertNoLeaks(t, "device detail", rec.Body.Bytes())
var rateQueries []string
for _, c := range e.q.queries() {
if strings.Contains(c.expr, "ifHCInOctets") {
rateQueries = append(rateQueries, c.expr)
}
}
if len(rateQueries) != 1 || !strings.Contains(rateQueries[0], "rate(ifHCInOctets{device=") || !strings.Contains(rateQueries[0], "[5m])*8") {
t.Errorf("want one batched rate query, got %q", rateQueries)
}
if d.Slug != core.Slug || d.HWSerial != "SN1" || d.SysDescr != "JunOS" || d.UptimeSeconds == nil || *d.UptimeSeconds != 86400 ||
len(d.Parents) != 1 || d.Parents[0].Slug != other.Slug || d.Parents[0].Name != "Other" || d.Groups == nil ||
d.Reachability == "" || d.PollState != "ok" || len(d.Interfaces) != 5 {
t.Fatalf("detail: %+v", d)
}
by := map[string]api.InterfaceView{}
for _, i := range d.Interfaces {
by[i.ID] = i
}
if x := by[i1.ID]; x.InBPS == nil || *x.InBPS != 8e6 || *x.OutBPS != 4e6 || *x.InErrorsPS != 0.5 || x.OutErrorsPS == nil {
t.Errorf("hc interface: %+v", x)
}
if x := by[i2.ID]; x.InBPS == nil || *x.InBPS != 100 || *x.OutBPS != 200 || x.InErrorsPS != nil {
t.Errorf("32-bit interface: %+v", x)
}
if x := by[i3.ID]; !x.Unreliable || x.InBPS != nil || x.OutBPS != nil || x.InErrorsPS != nil {
t.Errorf("unreliable interface must have null rates: %+v", x)
}
if x := by[i4.ID]; x.InBPS != nil || x.OutBPS != nil {
t.Errorf("unknown rate must be null: %+v", x)
}
if x := by[i5.ID]; x.RetiredAt == nil {
t.Errorf("retired interface should carry retired_at: %+v", x)
}
// every nullable field is present as null, not omitted
var raw map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &raw)
ifs := raw["interfaces"].([]any)[3].(map[string]any)
for _, k := range []string{"in_bps", "out_bps", "in_errors_ps", "out_errors_ps", "retired_at"} {
if v, ok := ifs[k]; !ok || v != nil {
t.Errorf("%s should be present and null: %v %v", k, v, ok)
}
}
}
func TestDeviceDetailWithStoreDownStillServes(t *testing.T) {
e := newEnv(t, 0)
d := e.device(t, "Core", "", "")
e.iface(t, d, 1, "e1", "")
e.q.err = context.DeadlineExceeded
var v api.DeviceView
e.getJSON(t, "/api/v1/devices/"+d.Slug, &v)
if len(v.Interfaces) != 1 || v.Interfaces[0].InBPS != nil || v.UptimeSeconds != nil {
t.Errorf("rates should be null when the store fails: %+v", v)
}
isProblem(t, e.do("GET", "/api/v1/devices/nope", ""), 404)
}
func TestPutDeviceEditsAndAudits(t *testing.T) {
e := newEnv(t, 0)
a := e.device(t, "A", "yyz", "core")
b := e.device(t, "B", "yyz", "edge")
var v api.DeviceView
rec := e.do("PUT", "/api/v1/devices/"+b.Slug,
`{"name":"B2","site":"yul","role":"dist","enabled":false,"parents":["`+a.Slug+`"]}`)
if rec.Code != 200 {
t.Fatalf("PUT: %d %s", rec.Code, rec.Body)
}
_ = json.Unmarshal(rec.Body.Bytes(), &v)
if v.Name != "B2" || v.Site != "yul" || v.Role != "dist" || v.Enabled || len(v.Parents) != 1 || v.Parents[0].Slug != a.Slug {
t.Errorf("edited: %+v", v)
}
if v.Slug != b.Slug {
t.Error("slug is immutable")
}
if e.ref.n.Load() != 1 {
t.Error("poller not refreshed")
}
// a partial body leaves the rest alone; an empty parents list clears them
rec = e.do("PUT", "/api/v1/devices/"+b.Slug, `{"parents":[]}`)
_ = json.Unmarshal(rec.Body.Bytes(), &v)
if rec.Code != 200 || v.Name != "B2" || v.Site != "yul" || len(v.Parents) != 0 {
t.Errorf("partial edit: %d %+v", rec.Code, v)
}
audit, _, _ := e.st.ListAudit(t.Context(), store.Page{})
var edits int
for _, a := range audit {
if a.Action == "device.edit" && a.Object == b.Slug {
edits++
}
}
if edits != 2 {
t.Errorf("audit has %d device.edit rows, want 2: %+v", edits, audit)
}
for body, status := range map[string]int{
`{"name":""}`: 422, `{"name":" "}`: 422, `{"parents":["nope"]}`: 422, `{"enabled":"yes"}`: 422, `{"bogus":1}`: 422,
`{"snmp":{"version":"v2c"}}`: 422,
} {
isProblem(t, e.do("PUT", "/api/v1/devices/"+b.Slug, body), status)
}
isProblem(t, e.do("PUT", "/api/v1/devices/nope", `{}`), 404)
if err := e.st.RetireDevice(t.Context(), a.ID); err != nil {
t.Fatal(err)
}
isProblem(t, e.do("PUT", "/api/v1/devices/"+a.Slug, `{"name":"x"}`), 409)
isProblem(t, e.do("PUT", "/api/v1/devices/"+b.Slug, `{"parents":["`+a.Slug+`"]}`), 422) // retired parent
}
func TestPutDeviceRejectsParentCyclesNamingThePath(t *testing.T) {
e := newEnv(t, 0)
a := e.device(t, "A", "", "")
b := e.device(t, "B", "", "")
c := e.device(t, "C", "", "")
set := func(d *store.Device, parents ...*store.Device) {
ids := make([]int64, len(parents))
for i, p := range parents {
ids[i] = p.ID
}
if err := e.st.SetParents(t.Context(), d.ID, store.RelNetwork, ids); err != nil {
t.Fatal(err)
}
}
set(c, b) // C depends on B
set(b, a) // B depends on A
rec := e.do("PUT", "/api/v1/devices/"+a.Slug, `{"parents":["`+c.Slug+`"]}`)
isProblem(t, rec, 422)
want := a.Slug + " -> " + c.Slug + " -> " + b.Slug + " -> " + a.Slug
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("cycle path missing; want %q in %s", want, rec.Body)
}
rec = e.do("PUT", "/api/v1/devices/"+a.Slug, `{"parents":["`+a.Slug+`"]}`)
isProblem(t, rec, 422)
if !strings.Contains(rec.Body.String(), a.Slug+" -> "+a.Slug) {
t.Errorf("self parent: %s", rec.Body)
}
if got, _ := e.st.ListParents(t.Context(), a.ID, ""); len(got) != 0 {
t.Errorf("a rejected edit must not change parents: %+v", got)
}
// a diamond is not a cycle
d := e.device(t, "D", "", "")
if rec := e.do("PUT", "/api/v1/devices/"+d.Slug, `{"parents":["`+b.Slug+`","`+c.Slug+`"]}`); rec.Code != 200 {
t.Errorf("diamond: %d %s", rec.Code, rec.Body)
}
}
func TestPutDeviceReplacesSNMPCredentialWithoutLeakingIt(t *testing.T) {
e := newEnv(t, 0)
cred := &store.SNMPCredential{Name: "old", Version: "v2c", Community: "old-community-xyz"}
if err := e.st.CreateCredential(t.Context(), cred); err != nil {
t.Fatal(err)
}
d := e.device(t, "A", "", "", func(d *store.Device) { d.SNMPCredentialID = &cred.ID })
rec := e.do("PUT", "/api/v1/devices/"+d.Slug,
`{"snmp":{"version":"v3","v3_user":"nmon","v3_auth_proto":"SHA256","v3_auth_pass":"auth-secret-123","v3_priv_proto":"AES","v3_priv_pass":"priv-secret-456","port":1161}}`)
if rec.Code != 200 {
t.Fatalf("PUT: %d %s", rec.Code, rec.Body)
}
assertNoLeaks(t, "PUT response", rec.Body.Bytes(), "auth-secret-123", "priv-secret-456", "old-community-xyz")
var v api.DeviceView
_ = json.Unmarshal(rec.Body.Bytes(), &v)
if v.SNMPVersion != "v3" {
t.Errorf("snmp_version %q", v.SNMPVersion)
}
got, err := e.st.GetDeviceBySlug(t.Context(), d.Slug)
if err != nil || got.SNMPPort != 1161 || *got.SNMPCredentialID == cred.ID {
t.Fatalf("device after: %+v %v", got, err)
}
c, err := e.st.GetCredential(t.Context(), *got.SNMPCredentialID)
if err != nil || c.V3AuthPass != "auth-secret-123" || c.V3User != "nmon" {
t.Errorf("stored credential: %+v %v", c, err)
}
if _, err := e.st.GetCredential(t.Context(), cred.ID); err == nil {
t.Error("the replaced credential should be deleted")
}
audit, _, _ := e.st.ListAudit(t.Context(), store.Page{})
for _, a := range audit {
if strings.Contains(a.After+a.Before, "secret") {
t.Errorf("audit row leaks a secret: %+v", a)
}
}
vm := e.device(t, "VM", "", "", func(d *store.Device) { d.Kind = store.KindVM })
isProblem(t, e.do("PUT", "/api/v1/devices/"+vm.Slug, `{"snmp":{"version":"v2c","community":"c"}}`), 422)
}

144
internal/api/device_put.go Normal file
View File

@@ -0,0 +1,144 @@
package api
import (
"context"
"errors"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/store"
)
// PutDeviceBody is the body of PUT /api/v1/devices/{slug}. Absent fields stay
// as they are; an empty parents list removes every parent.
type PutDeviceBody struct {
Name *string `json:"name,omitempty" minLength:"1" maxLength:"255"`
Site *string `json:"site,omitempty"`
Role *string `json:"role,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Parents []string `json:"parents,omitempty" doc:"Parent device slugs, replacing the current parents."`
SNMP *SNMPBody `json:"snmp,omitempty" doc:"Replaces the SNMP credential; secrets are write-only."`
}
func isNotFound(err error) bool { return errors.Is(err, store.ErrNotFound) }
func (s *server) putDevice(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
Body PutDeviceBody
}) (*deviceOut, error) {
dev, err := s.Store.GetDeviceBySlug(ctx, in.Slug)
if err != nil {
return nil, s.storeErr(err)
}
if dev.PurgedAt != nil {
return nil, huma.Error404NotFound("not found")
}
if dev.RetiredAt != nil {
return nil, huma.Error409Conflict("device is retired; reattach it before editing")
}
plan, err := s.planEdit(ctx, dev, in.Body)
if err != nil {
return nil, err
}
if err := s.applyEdit(ctx, dev, plan); err != nil {
return nil, s.storeErr(err)
}
s.Poller.Refresh()
return s.view(ctx, dev.Slug)
}
// editPlan is a validated edit: the device as it will be, the parents to set
// (nil leaves them) and the credential to create (nil leaves it).
type editPlan struct {
after store.Device
setParents bool
parents []*store.Device
cred *store.SNMPCredential
change map[string]any // what the audit row records; never a secret
}
// planEdit validates body against the device without writing anything.
func (s *server) planEdit(ctx context.Context, dev *store.Device, b PutDeviceBody) (*editPlan, error) {
p := &editPlan{after: *dev, change: map[string]any{}}
set := func(field string, dst *string, v *string) {
if v != nil && *v != *dst {
*dst = *v
p.change[field] = *v
}
}
set("name", &p.after.Name, b.Name)
set("site", &p.after.Site, b.Site)
set("role", &p.after.Role, b.Role)
if b.Name != nil && strings.TrimSpace(*b.Name) == "" {
return nil, huma.Error422UnprocessableEntity("name must not be empty")
}
if b.Enabled != nil && *b.Enabled != dev.Enabled {
p.after.Enabled = *b.Enabled
p.change["enabled"] = *b.Enabled
}
if b.Parents != nil {
parents, err := s.resolveParents(ctx, dev, b.Parents)
if err != nil {
return nil, err
}
p.setParents, p.parents = true, parents
p.change["parents"] = b.Parents
}
if b.SNMP != nil {
if dev.Kind != store.KindNetwork {
return nil, huma.Error422UnprocessableEntity("snmp applies to network devices only")
}
cred, err := s.credential(b.SNMP)
if err != nil {
return nil, err
}
p.cred = cred
p.after.SNMPPort = b.SNMP.Port
p.change["snmp"] = map[string]any{"version": b.SNMP.Version, "port": b.SNMP.Port, "replaced": true}
}
return p, nil
}
// applyEdit writes the plan and its audit row in one transaction. The old
// credential is deleted once nothing uses it.
func (s *server) applyEdit(ctx context.Context, dev *store.Device, p *editPlan) error {
return s.Store.WithTx(ctx, func(tx store.Store) error {
after := p.after
oldCred := dev.SNMPCredentialID
if p.cred != nil {
if err := tx.CreateCredential(ctx, p.cred); err != nil {
return err
}
after.SNMPCredentialID = &p.cred.ID
}
if err := tx.UpdateDevice(ctx, &after); err != nil {
return err
}
if p.setParents {
if err := setParentLinks(ctx, tx, &after, p.parents); err != nil {
return err
}
}
if p.cred != nil && oldCred != nil {
if err := tx.DeleteCredential(ctx, *oldCred); err != nil && !errors.Is(err, store.ErrConflict) {
return err
}
}
return tx.AppendAudit(ctx, "anonymous", "device.edit", dev.Slug, nil, p.change)
})
}
// setParentLinks replaces the device's parents. VMs and containers depend on
// their host (hosted_on); everything else on the network path.
func setParentLinks(ctx context.Context, tx store.Store, d *store.Device, parents []*store.Device) error {
rel := store.RelNetwork
if d.Kind == store.KindVM || d.Kind == store.KindContainer {
rel = store.RelHostedOn
}
ids := make([]int64, len(parents))
for i, p := range parents {
ids[i] = p.ID
}
return tx.SetParents(ctx, d.ID, rel, ids)
}

210
internal/api/device_view.go Normal file
View File

@@ -0,0 +1,210 @@
package api
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/store"
)
// DeviceRef names a device in another object. Devices are addressed by slug.
type DeviceRef struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
// GroupView is a group an interface of the device belongs to.
type GroupView struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind" enum:"site,customer,role,custom"`
}
// InterfaceView is an interface as the API shows it, addressed by ULID. The
// rate fields are five-minute rates from the hot store, null when unknown or
// when the interface is unreliable (its counters cannot be trusted).
type InterfaceView struct {
ID string `json:"id" doc:"Immutable ULID."`
IfIndex int64 `json:"ifindex" doc:"Current ifIndex; may change, the ID does not."`
IfName string `json:"ifname"`
IfDescr string `json:"ifdescr"`
IfAlias string `json:"ifalias"`
IfPhysAddr string `json:"ifphysaddr"`
IfSpeed int64 `json:"ifspeed" doc:"bits per second"`
IfHighSpeed int64 `json:"ifhighspeed" doc:"megabits per second"`
IfType int64 `json:"iftype"`
AdminStatus int64 `json:"admin_status" doc:"ifAdminStatus: 1 up, 2 down, 3 testing"`
OperStatus int64 `json:"oper_status" doc:"ifOperStatus: 1 up, 2 down, 3 testing, 4 unknown, 5 dormant, 6 notPresent, 7 lowerLayerDown"` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
HasHCCounters bool `json:"has_hc_counters"`
Excluded bool `json:"excluded"`
FirstSeen time.Time `json:"first_seen"`
LastDiscovered time.Time `json:"last_discovered"`
Unreliable bool `json:"unreliable" doc:"Counters cannot be trusted (32-bit counters on a fast link); no rate is reported."`
RetiredAt *time.Time `json:"retired_at" nullable:"true"`
InBPS *float64 `json:"in_bps" nullable:"true" doc:"Inbound bits per second over 5 minutes."`
OutBPS *float64 `json:"out_bps" nullable:"true"`
InErrorsPS *float64 `json:"in_errors_ps" nullable:"true" doc:"Inbound errors per second over 5 minutes."`
OutErrorsPS *float64 `json:"out_errors_ps" nullable:"true"`
}
// DeviceView is a device as the API shows it. It carries no numeric ID and no credential.
type DeviceView struct {
Slug string `json:"slug" doc:"Immutable public key."`
Name string `json:"name"`
Kind string `json:"kind"`
Site string `json:"site"`
Role string `json:"role"`
Profile string `json:"profile"`
Enabled bool `json:"enabled"`
MgmtIP string `json:"mgmt_ip"`
Addresses []string `json:"addresses"`
SNMPVersion string `json:"snmp_version,omitempty"`
SysName string `json:"sys_name"`
SysObjectID string `json:"sys_object_id"`
SysDescr string `json:"sys_descr"`
HWSerial string `json:"hw_serial"`
UptimeSeconds *float64 `json:"uptime_seconds" nullable:"true" doc:"sysUpTime from the hot store; null when unknown."`
Reachability string `json:"reachability" enum:"up,down,warning,unknown,maintenance"`
PollState string `json:"poll_state" enum:"ok,degraded,backoff"`
Warnings []string `json:"warnings"`
LastSeen *time.Time `json:"last_seen" nullable:"true"`
CreatedAt time.Time `json:"created_at"`
RetiredAt *time.Time `json:"retired_at,omitempty" nullable:"true"`
Parents []DeviceRef `json:"parents"`
Groups []GroupView `json:"groups"`
Interfaces []InterfaceView `json:"interfaces"`
}
type deviceOut struct{ Body DeviceView }
func (s *server) registerDeviceViews(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "get-device", Method: http.MethodGet, Path: "/api/v1/devices/{slug}", Tags: []string{"devices"},
Summary: "Get a device, its interfaces with live rates, parents and groups",
Description: "Rates are one batched query to the hot store; they are null when the store is unavailable.",
Errors: []int{http.StatusNotFound},
}, s.getDevice)
huma.Register(api, huma.Operation{
OperationID: "put-device", Method: http.MethodPut, Path: "/api/v1/devices/{slug}", Tags: []string{"devices"},
Summary: "Edit a device",
Description: "Only the fields present change. parents replaces the parent list (by slug) and is rejected with 422 " +
"naming the path when it would make a cycle. snmp replaces the credential.",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity},
}, s.putDevice)
}
func (s *server) getDevice(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
}) (*deviceOut, error) {
return s.view(ctx, in.Slug)
}
// view loads the device and everything shown with it.
func (s *server) view(ctx context.Context, slug string) (*deviceOut, error) {
d, err := s.Store.GetDeviceBySlug(ctx, slug)
if err != nil {
return nil, s.storeErr(err)
}
if d.PurgedAt != nil {
return nil, huma.Error404NotFound("not found")
}
v := DeviceView{
Slug: d.Slug, Name: d.Name, Kind: d.Kind, Site: d.Site, Role: d.Role, Profile: d.Profile, Enabled: d.Enabled,
MgmtIP: d.MgmtIP, SysName: d.SysName, SysObjectID: d.SysObjectID, SysDescr: d.SysDescr, HWSerial: d.HWSerial,
PollState: pollStateOf(d), Warnings: nonNil(d.Warnings), LastSeen: utcp(d.LastSeen), CreatedAt: d.CreatedAt.UTC(),
RetiredAt: utcp(d.RetiredAt), Addresses: []string{}, Parents: []DeviceRef{}, Groups: []GroupView{},
Reachability: s.Reachability.Reachability(ctx, []store.Device{*d})[d.Slug],
}
if err := s.fillRelations(ctx, d, &v); err != nil {
return nil, s.storeErr(err)
}
if err := s.fillInterfaces(ctx, d, &v); err != nil {
return nil, s.storeErr(err)
}
return &deviceOut{Body: v}, nil
}
// fillRelations adds addresses, the credential's version (never its secrets), parents and groups.
func (s *server) fillRelations(ctx context.Context, d *store.Device, v *DeviceView) error {
addrs, err := s.Store.ListAddresses(ctx, d.ID)
if err != nil {
return err
}
for _, a := range addrs {
v.Addresses = append(v.Addresses, a.Addr)
}
if d.SNMPCredentialID != nil {
c, err := s.Store.GetCredential(ctx, *d.SNMPCredentialID)
if err != nil {
return err
}
v.SNMPVersion = c.Version
}
links, err := s.Store.ListParents(ctx, d.ID, "")
if err != nil {
return err
}
seen := map[int64]bool{}
for _, l := range links {
if seen[l.ParentID] {
continue
}
seen[l.ParentID] = true
p, err := s.Store.GetDeviceByID(ctx, l.ParentID)
if err != nil {
return err
}
v.Parents = append(v.Parents, DeviceRef{Slug: p.Slug, Name: p.Name})
}
groups, err := s.Store.DeviceGroups(ctx, d.ID)
if err != nil {
return err
}
for _, g := range groups {
v.Groups = append(v.Groups, GroupView{ID: itoa(int(g.ID)), Name: g.Name, Kind: g.Kind})
}
return nil
}
// fillInterfaces adds the device's interfaces, retired ones included, with live rates.
func (s *server) fillInterfaces(ctx context.Context, d *store.Device, v *DeviceView) error {
ifs, err := s.Store.ListInterfaces(ctx, d.ID, true)
if err != nil {
return err
}
var rates map[string]*ifRates
v.Interfaces = make([]InterfaceView, 0, len(ifs))
if len(ifs) > 0 || d.Kind == store.KindNetwork {
rates, v.UptimeSeconds = s.liveRates(ctx, `device=`+quote(d.Slug), true)
}
for i := range ifs {
v.Interfaces = append(v.Interfaces, interfaceView(&ifs[i], rates[ifs[i].ID]))
}
return nil
}
func interfaceView(i *store.Interface, r *ifRates) InterfaceView {
in, out := r.bps(i)
inE, outE := r.errs(i)
return InterfaceView{
ID: i.ID, IfIndex: i.IfIndex, IfName: i.IfName, IfDescr: i.IfDescr, IfAlias: i.IfAlias,
IfPhysAddr: i.IfPhysAddr, IfSpeed: i.IfSpeed, IfHighSpeed: i.IfHighSpeed, IfType: i.IfType,
AdminStatus: i.AdminStatus, OperStatus: i.OperStatus, HasHCCounters: i.HasHCCounters, Excluded: i.Excluded,
FirstSeen: i.FirstSeen.UTC(), LastDiscovered: i.LastDiscovered.UTC(), Unreliable: i.Unreliable,
RetiredAt: utcp(i.RetiredAt), InBPS: in, OutBPS: out, InErrorsPS: inE, OutErrorsPS: outE,
}
}
// unreliableReason says why an interface's counters cannot be trusted.
func unreliableReason(i *store.Interface) string {
switch {
case !i.HasHCCounters:
return "counter_32bit_fast_link"
case i.CounterDiscontinuity > 0:
return "counter_discontinuity"
}
return "counter_unreliable"
}

View File

@@ -3,13 +3,16 @@ package api
import (
"context"
"fmt"
"net/http"
"net/netip"
"slices"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/discovery"
"github.com/v10networks/nmon/internal/snmp"
"github.com/v10networks/nmon/internal/snmptarget"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/ulid"
)
@@ -25,146 +28,72 @@ type SNMPBody struct {
V3PrivProto string `json:"v3_priv_proto,omitempty" doc:"DES, AES (default), AES192 or AES256."`
V3PrivPass string `json:"v3_priv_pass,omitempty"`
V3Context string `json:"v3_context,omitempty"`
Port int `json:"port,omitempty" minimum:"1" maximum:"65535" doc:"UDP port; default 161."`
}
// AddDeviceBody is the body of POST /api/v1/devices.
type AddDeviceBody struct {
Name string `json:"name" minLength:"1" maxLength:"255" example:"core-sw1"`
Addresses []string `json:"addresses" minItems:"1" doc:"Management addresses; an IPv6 address is preferred over IPv4." example:"[\"2001:db8::1\"]"`
Addresses []string `json:"addresses" minItems:"1" doc:"Management addresses; an IPv6 address is preferred over IPv4." example:"[\"2001:db8::1\"]"` //nolint:lll // struct tag, cannot wrap
Kind string `json:"kind,omitempty" enum:"network,vm,container,check_only" default:"network"`
Site string `json:"site,omitempty"`
Role string `json:"role,omitempty"`
SNMP *SNMPBody `json:"snmp,omitempty" doc:"Required for kind network."`
// Reattach and CreateNew answer a 409 reattach_candidates response.
Reattach string `json:"reattach,omitempty" doc:"Slug of a retired device to restore instead of creating a new one (its slug and interface IDs come back)."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
CreateNew bool `json:"create_new,omitempty" doc:"Create a new device although a retired one matches the address or sysName."`
}
// InterfaceView is an interface as the API shows it, addressed by ULID.
type InterfaceView struct {
ID string `json:"id" doc:"Immutable ULID."`
IfIndex int64 `json:"ifindex" doc:"Current ifIndex; may change, the ID does not."`
IfName string `json:"ifname"`
IfDescr string `json:"ifdescr"`
IfAlias string `json:"ifalias"`
IfPhysAddr string `json:"ifphysaddr"`
IfSpeed int64 `json:"ifspeed" doc:"bits per second"`
IfHighSpeed int64 `json:"ifhighspeed" doc:"megabits per second"`
IfType int64 `json:"iftype"`
AdminStatus int64 `json:"admin_status"`
OperStatus int64 `json:"oper_status"`
HasHCCounters bool `json:"has_hc_counters"`
Excluded bool `json:"excluded"`
FirstSeen time.Time `json:"first_seen"`
LastDiscovered time.Time `json:"last_discovered"`
// reattachCandidate is a retired device offered in a 409.
type reattachCandidate struct {
Slug string `json:"slug"`
Name string `json:"name"`
RetiredAt time.Time `json:"retired_at"`
}
// DeviceView is a device as the API shows it. It carries no numeric ID and no credential.
type DeviceView struct {
Slug string `json:"slug" doc:"Immutable public key."`
Name string `json:"name"`
Kind string `json:"kind"`
Site string `json:"site"`
Role string `json:"role"`
Profile string `json:"profile"`
Enabled bool `json:"enabled"`
MgmtIP string `json:"mgmt_ip"`
Addresses []string `json:"addresses"`
SNMPVersion string `json:"snmp_version,omitempty"`
SysName string `json:"sys_name"`
SysObjectID string `json:"sys_object_id"`
SysDescr string `json:"sys_descr"`
PollState string `json:"poll_state"`
Warnings []string `json:"warnings"`
LastSeen *time.Time `json:"last_seen,omitempty"`
CreatedAt time.Time `json:"created_at"`
RetiredAt *time.Time `json:"retired_at,omitempty"`
Interfaces []InterfaceView `json:"interfaces"`
// reattachProblem is the 409 problem with the retired devices the client may restore.
type reattachProblem struct {
Problem
Candidates []reattachCandidate `json:"reattach_candidates"`
}
type deviceOut struct{ Body DeviceView }
func (s *server) registerDevices(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "add-device", Method: "POST", Path: "/api/v1/devices", Tags: []string{"devices"},
Summary: "Add a device",
Description: "Creates the credential, device and addresses, runs seed discovery synchronously (30 s limit) and starts polling. A device that does not answer SNMP is rejected with 502 and nothing is stored.",
DefaultStatus: 201,
Errors: []int{409, 422, 502},
Summary: "Add a device",
Description: "Creates the credential, device and addresses, runs seed discovery synchronously (30 s limit) and " +
"starts polling. A device that does not answer SNMP is rejected with 502 and nothing is stored.",
DefaultStatus: http.StatusCreated,
Errors: []int{http.StatusConflict, http.StatusUnprocessableEntity, http.StatusBadGateway},
}, s.addDevice)
huma.Register(api, huma.Operation{
OperationID: "get-device", Method: "GET", Path: "/api/v1/devices/{slug}", Tags: []string{"devices"},
Summary: "Get a device and its interfaces",
Errors: []int{404},
}, s.getDevice)
}
func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }) (*deviceOut, error) {
b := in.Body
kind := b.Kind
if kind == "" {
kind = store.KindNetwork
}
var addrs []netip.Addr
for _, a := range b.Addresses {
ip, err := netip.ParseAddr(a)
if err != nil {
return nil, huma.Error422UnprocessableEntity(fmt.Sprintf("address %q is not an IP address", a))
}
addrs = append(addrs, ip.WithZone("").Unmap())
}
if len(addrs) == 0 {
return nil, huma.Error422UnprocessableEntity("at least one address is required")
}
if kind == store.KindNetwork && b.SNMP == nil {
return nil, huma.Error422UnprocessableEntity("snmp credentials are required for kind network")
}
var (
cred *store.SNMPCredential
res *discovery.Result
)
if b.SNMP != nil {
var err error
if cred, err = s.credential(b.SNMP); err != nil {
return nil, err
}
pref := addrs[0]
for _, a := range addrs {
if a.Is6() {
pref = a
break
}
}
if res, err = s.collect(ctx, pref, cred); err != nil {
return nil, err
}
}
// addRequest is an AddDeviceBody after validation: defaults applied and
// addresses parsed.
type addRequest struct {
AddDeviceBody
kind string
addrs []netip.Addr
strs []string // addrs in canonical text form, as stored
}
var slug string
err := s.Store.WithTx(ctx, func(tx store.Store) error {
d := &store.Device{Name: b.Name, Kind: kind, Site: b.Site, Role: b.Role, Enabled: true, Profile: "generic"}
if cred != nil {
if err := tx.CreateCredential(ctx, cred); err != nil {
return err
}
d.SNMPCredentialID = &cred.ID
}
if err := tx.CreateDevice(ctx, d); err != nil {
return err
}
strs := make([]string, len(addrs))
for i, a := range addrs {
strs[i] = a.String()
}
if err := tx.SetAddresses(ctx, d.ID, strs); err != nil {
return err
}
if res != nil {
if err := discovery.Apply(ctx, tx, d, res, s.Clock); err != nil {
return err
}
}
slug = d.Slug
return tx.AppendAudit(ctx, "anonymous", "device.add", d.Slug, nil,
map[string]any{"name": d.Name, "kind": d.Kind, "addresses": strs})
})
// addDevice validates the request, collects from the device before anything is
// stored, decides between a new device and a reattach, then persists in one
// transaction.
func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }) (*deviceOut, error) {
req, err := validateAdd(in.Body)
if err != nil {
return nil, err
}
cred, res, err := s.credentialAndSeed(ctx, req)
if err != nil {
return nil, err
}
restore, err := s.reattachDecision(ctx, req, res)
if err != nil {
return nil, err
}
slug, err := s.persistDevice(ctx, req, cred, res, restore)
if err != nil {
return nil, s.storeErr(err)
}
@@ -172,6 +101,183 @@ func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }
return s.view(ctx, slug)
}
// validateAdd applies the kind default and parses the addresses.
func validateAdd(b AddDeviceBody) (*addRequest, error) {
req := &addRequest{AddDeviceBody: b, kind: b.Kind}
if req.kind == "" {
req.kind = store.KindNetwork
}
for _, a := range b.Addresses {
ip, err := netip.ParseAddr(a)
if err != nil {
return nil, huma.Error422UnprocessableEntity(fmt.Sprintf("address %q is not an IP address", a))
}
ip = ip.WithZone("").Unmap()
req.addrs = append(req.addrs, ip)
req.strs = append(req.strs, ip.String())
}
if len(req.addrs) == 0 {
return nil, huma.Error422UnprocessableEntity("at least one address is required")
}
if req.kind == store.KindNetwork && b.SNMP == nil {
return nil, huma.Error422UnprocessableEntity("snmp credentials are required for kind network")
}
return req, nil
}
// credentialAndSeed builds the credential from the request and runs the seed
// walk against the preferred address. Both are nil when the request has no
// SNMP block.
func (s *server) credentialAndSeed(ctx context.Context, req *addRequest) (*store.SNMPCredential, *discovery.Result, error) {
if req.SNMP == nil {
return nil, nil, nil
}
cred, err := s.credential(req.SNMP)
if err != nil {
return nil, nil, err
}
res, err := s.collect(ctx, req.preferredAddr(), s.port(req.SNMP.Port), cred)
if err != nil {
return nil, nil, err
}
return cred, res, nil
}
// preferredAddr is the first IPv6 address, else the first address.
func (r *addRequest) preferredAddr() netip.Addr {
for _, a := range r.addrs {
if a.Is6() {
return a
}
}
return r.addrs[0]
}
// reattachDecision looks for retired devices matching the request. It returns
// the one to restore, nil for a brand-new device, or a 409 offering the
// candidates when the client has not chosen.
func (s *server) reattachDecision(ctx context.Context, req *addRequest, res *discovery.Result) (*discovery.ReattachCandidate, error) {
if req.CreateNew {
return nil, nil
}
sysName := ""
if res != nil {
sysName = res.SysName
}
cands, err := discovery.FindReattachCandidates(ctx, s.Store, req.strs, sysName)
if err != nil {
return nil, s.storeErr(err)
}
if req.Reattach != "" {
i := slices.IndexFunc(cands, func(c discovery.ReattachCandidate) bool { return c.Slug == req.Reattach })
if i < 0 {
return nil, huma.Error422UnprocessableEntity(
"reattach: " + req.Reattach + " is not a retired device matching this address or sysName")
}
return &cands[i], nil
}
if len(cands) == 0 {
return nil, nil
}
if err := discovery.OfferReattach(ctx, s.Store, cands, req.Name, req.strs, sysName); err != nil {
return nil, s.storeErr(err)
}
return nil, reattachOffer(cands)
}
// reattachOffer is the 409 listing the retired devices the client may restore.
func reattachOffer(cands []discovery.ReattachCandidate) *reattachProblem {
p := &reattachProblem{Candidates: make([]reattachCandidate, len(cands))}
p.Status, p.Title = http.StatusConflict, http.StatusText(http.StatusConflict)
p.Detail = "a retired device has this address or sysName; send reattach: <slug> to restore it or create_new: true to add a new device"
for i, c := range cands {
p.Candidates[i] = reattachCandidate{Slug: c.Slug, Name: c.Name, RetiredAt: c.RetiredAt}
}
return p
}
// persistDevice writes the credential, device, addresses, seeded inventory and
// audit row in one transaction and returns the device slug.
func (s *server) persistDevice(
ctx context.Context, req *addRequest, cred *store.SNMPCredential, res *discovery.Result, restore *discovery.ReattachCandidate,
) (string, error) {
var slug string
err := s.Store.WithTx(ctx, func(tx store.Store) error {
d, err := s.writeDevice(ctx, tx, req, cred, res, restore)
if err != nil {
return err
}
slug = d.Slug
action := "device.add"
if restore != nil {
action = "device.reattach"
}
return tx.AppendAudit(ctx, "anonymous", action, d.Slug, nil,
map[string]any{"name": d.Name, "kind": d.Kind, "addresses": req.strs})
})
return slug, err
}
// writeDevice stores the credential, the device row, its addresses and the
// seeded inventory inside tx.
func (s *server) writeDevice(
ctx context.Context, tx store.Store, req *addRequest, cred *store.SNMPCredential, res *discovery.Result,
restore *discovery.ReattachCandidate,
) (*store.Device, error) {
d, err := s.deviceRow(ctx, tx, req, restore)
if err != nil {
return nil, err
}
if cred != nil {
if err := tx.CreateCredential(ctx, cred); err != nil {
return nil, err
}
d.SNMPCredentialID = &cred.ID
}
if restore != nil {
err = tx.UpdateDevice(ctx, d)
} else {
err = tx.CreateDevice(ctx, d)
}
if err != nil {
return nil, err
}
if err := tx.SetAddresses(ctx, d.ID, req.strs); err != nil {
return nil, err
}
if res != nil {
if _, err := discovery.Apply(ctx, tx, d, res, s.Clock); err != nil {
return nil, err
}
}
return d, nil
}
// deviceRow is the device to write: a fresh row, or the restored one with the
// request's name, kind and (when given) site and role laid over it.
func (s *server) deviceRow(
ctx context.Context, tx store.Store, req *addRequest, restore *discovery.ReattachCandidate,
) (*store.Device, error) {
d := &store.Device{Name: req.Name, Kind: req.kind, Site: req.Site, Role: req.Role, Enabled: true, Profile: "generic"}
if restore != nil {
var err error
if d, err = tx.Reattach(ctx, restore.DeviceID); err != nil {
return nil, err
}
d.Name, d.Kind, d.Enabled = req.Name, req.kind, true
if req.Site != "" {
d.Site = req.Site
}
if req.Role != "" {
d.Role = req.Role
}
}
if req.SNMP != nil {
d.SNMPPort = req.SNMP.Port
}
return d, nil
}
// credential validates the request's SNMP block and returns a fresh credential.
func (s *server) credential(b *SNMPBody) (*store.SNMPCredential, error) {
c := &store.SNMPCredential{
@@ -201,70 +307,21 @@ func (s *server) credential(b *SNMPBody) (*store.SNMPCredential, error) {
}
// collect runs the seed walk against addr before anything is stored.
func (s *server) collect(ctx context.Context, addr netip.Addr, cred *store.SNMPCredential) (*discovery.Result, error) {
func (s *server) collect(ctx context.Context, addr netip.Addr, port int, cred *store.SNMPCredential) (*discovery.Result, error) {
dctx, cancel := context.WithTimeout(ctx, s.DiscoveryTimeout)
defer cancel()
sess, err := snmp.Dial(dctx, discovery.Target(addr, s.SNMPPort, cred, s.Clock), "discovery")
sess, err := snmp.Dial(dctx, snmptarget.For(addr, port, cred, s.Clock), "discovery")
if err != nil {
return nil, huma.Error422UnprocessableEntity("snmp: " + err.Error())
}
defer sess.Close()
res, err := discovery.Collect(dctx, sess, s.Clock)
defer func() { _ = sess.Close() }() // read-only session; nothing to lose on close
res, err := discovery.Collect(dctx, sess, s.Clock, discovery.Options{Profiles: s.Profiles})
if err != nil {
return nil, huma.Error502BadGateway("discovery of " + addr.String() + " failed: " + err.Error())
}
return res, nil
}
func (s *server) getDevice(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
}) (*deviceOut, error) {
return s.view(ctx, in.Slug)
}
func (s *server) view(ctx context.Context, slug string) (*deviceOut, error) {
d, err := s.Store.GetDeviceBySlug(ctx, slug)
if err != nil {
return nil, s.storeErr(err)
}
if d.PurgedAt != nil {
return nil, huma.Error404NotFound("not found")
}
v := DeviceView{
Slug: d.Slug, Name: d.Name, Kind: d.Kind, Site: d.Site, Role: d.Role, Profile: d.Profile, Enabled: d.Enabled,
MgmtIP: d.MgmtIP, SysName: d.SysName, SysObjectID: d.SysObjectID, SysDescr: d.SysDescr,
PollState: d.PollState, Warnings: nonNil(d.Warnings), LastSeen: utcp(d.LastSeen), CreatedAt: d.CreatedAt.UTC(),
RetiredAt: utcp(d.RetiredAt), Addresses: []string{}, Interfaces: []InterfaceView{},
}
addrs, err := s.Store.ListAddresses(ctx, d.ID)
if err != nil {
return nil, s.storeErr(err)
}
for _, a := range addrs {
v.Addresses = append(v.Addresses, a.Addr)
}
if d.SNMPCredentialID != nil {
c, err := s.Store.GetCredential(ctx, *d.SNMPCredentialID)
if err != nil {
return nil, s.storeErr(err)
}
v.SNMPVersion = c.Version // the secrets stay behind
}
ifs, err := s.Store.ListInterfaces(ctx, d.ID, false)
if err != nil {
return nil, s.storeErr(err)
}
for _, i := range ifs {
v.Interfaces = append(v.Interfaces, InterfaceView{
ID: i.ID, IfIndex: i.IfIndex, IfName: i.IfName, IfDescr: i.IfDescr, IfAlias: i.IfAlias,
IfPhysAddr: i.IfPhysAddr, IfSpeed: i.IfSpeed, IfHighSpeed: i.IfHighSpeed, IfType: i.IfType,
AdminStatus: i.AdminStatus, OperStatus: i.OperStatus, HasHCCounters: i.HasHCCounters, Excluded: i.Excluded,
FirstSeen: i.FirstSeen.UTC(), LastDiscovered: i.LastDiscovered.UTC(),
})
}
return &deviceOut{Body: v}, nil
}
func nonNil(s []string) []string {
if s == nil {
return []string{}
@@ -279,3 +336,11 @@ func utcp(t *time.Time) *time.Time {
u := t.UTC()
return &u
}
// port is the device's SNMP port unless the dev-only --snmp-port override is set.
func (s *server) port(p int) int {
if s.SNMPPort != 0 {
return s.SNMPPort
}
return p
}

View File

@@ -0,0 +1,136 @@
package api
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/store"
)
// DeviceSummary is one row of the device list.
type DeviceSummary struct {
Slug string `json:"slug"`
Name string `json:"name"`
Kind string `json:"kind" enum:"network,vm,container,check_only"`
Site string `json:"site"`
Role string `json:"role"`
MgmtIP string `json:"mgmt_ip"`
Profile string `json:"profile"`
Reachability string `json:"reachability" enum:"up,down,warning,unknown,maintenance"`
PollState string `json:"poll_state" enum:"ok,degraded,backoff"`
LastSeen *time.Time `json:"last_seen" nullable:"true"`
InterfacesTotal int64 `json:"interfaces_total" doc:"Active interfaces, excluded ones left out."`
InterfacesDown int64 `json:"interfaces_down" doc:"Active interfaces that are admin up and not oper up."`
Warnings []string `json:"warnings"`
}
// DeviceList is a page of DeviceSummary.
type DeviceList struct {
Items []DeviceSummary `json:"items"`
Page int64 `json:"page"`
PerPage int64 `json:"per_page"`
Total int64 `json:"total"`
}
// DeviceFacets are the distinct values the list's filter dropdowns offer.
type DeviceFacets struct {
Sites []string `json:"sites"`
Roles []string `json:"roles"`
Kinds []string `json:"kinds"`
}
func (s *server) registerDeviceList(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "list-devices", Method: http.MethodGet, Path: "/api/v1/devices", Tags: []string{"devices"},
Summary: "List devices",
Description: "Paged, with reachability and interface tallies. state filters on the derived reachability, " +
"so it is applied after the other filters.",
}, s.listDevices)
huma.Register(api, huma.Operation{
OperationID: "get-device-facets", Method: http.MethodGet, Path: "/api/v1/devices/facets", Tags: []string{"devices"},
Summary: "Distinct site, role and kind values of unretired devices",
}, s.deviceFacets)
}
func (s *server) listDevices(ctx context.Context, in *struct {
Site string `query:"site"`
Role string `query:"role"`
Kind string `query:"kind" enum:"network,vm,container,check_only"`
Q string `query:"q" doc:"Substring of name, slug, management address or sysName."`
State string `query:"state" enum:"up,down,warning,unknown,maintenance" doc:"Reachability."`
PageParams
}) (*struct{ Body DeviceList }, error) {
f := store.DeviceFilter{Site: in.Site, Role: in.Role, Kind: in.Kind, Q: in.Q}
var (
devs []store.Device
total int64
reach map[string]string
err error
)
if in.State == "" {
devs, total, err = s.Store.ListDevices(ctx, f, store.Page{Offset: in.offset(), Limit: in.limit()})
if err == nil {
reach = s.Reachability.Reachability(ctx, devs)
}
} else {
devs, total, reach, err = s.devicesInState(ctx, f, in.State, in.PageParams)
}
if err != nil {
return nil, s.storeErr(err)
}
counts, err := s.Store.InterfaceCounts(ctx)
if err != nil {
return nil, s.storeErr(err)
}
out := DeviceList{Items: make([]DeviceSummary, 0, len(devs)), Page: in.pageNo(), PerPage: in.limit(), Total: total}
for i := range devs {
d := &devs[i]
c := counts[d.ID]
out.Items = append(out.Items, DeviceSummary{
Slug: d.Slug, Name: d.Name, Kind: d.Kind, Site: d.Site, Role: d.Role, MgmtIP: d.MgmtIP, Profile: d.Profile,
Reachability: reach[d.Slug], PollState: pollStateOf(d), LastSeen: utcp(d.LastSeen),
InterfacesTotal: c.Total, InterfacesDown: c.Down, Warnings: nonNil(d.Warnings),
})
}
return &struct{ Body DeviceList }{out}, nil
}
// devicesInState pages over the devices whose derived reachability is state.
// Reachability is not a column, so every device matching the other filters is
// classified first and the page is cut from the survivors.
func (s *server) devicesInState(
ctx context.Context, f store.DeviceFilter, state string, p PageParams,
) (page []store.Device, total int64, reach map[string]string, err error) {
var all []store.Device
for off := int64(0); ; off += store.MaxLimit {
rows, n, err := s.Store.ListDevices(ctx, f, store.Page{Offset: off, Limit: store.MaxLimit})
if err != nil {
return nil, 0, nil, err
}
all = append(all, rows...)
if off+store.MaxLimit >= n {
break
}
}
reach = s.Reachability.Reachability(ctx, all)
var match []store.Device
for _, d := range all {
if reach[d.Slug] == state {
match = append(match, d)
}
}
lo := min(p.offset(), int64(len(match)))
hi := min(lo+p.limit(), int64(len(match)))
return match[lo:hi], int64(len(match)), reach, nil
}
func (s *server) deviceFacets(ctx context.Context, _ *struct{}) (*struct{ Body DeviceFacets }, error) {
sites, roles, kinds, err := s.Store.DeviceFacets(ctx)
if err != nil {
return nil, s.storeErr(err)
}
return &struct{ Body DeviceFacets }{DeviceFacets{Sites: nonNil(sites), Roles: nonNil(roles), Kinds: nonNil(kinds)}}, nil
}

31
internal/api/docs.go Normal file
View File

@@ -0,0 +1,31 @@
package api
import (
"embed"
"io/fs"
"net/http"
)
// docsFS holds the Swagger UI page and its vendored swagger-ui-dist 5.17.14
// assets, so /docs works on a network with no internet access (huma's own
// renderers load their JavaScript from a CDN).
//
//go:embed docsui
var docsFS embed.FS
// registerDocs serves Swagger UI at /docs and its assets under /docs/assets/.
func registerDocs(mux *http.ServeMux) {
sub, err := fs.Sub(docsFS, "docsui")
if err != nil {
panic("api: embedded docs missing: " + err.Error()) // the embed directive guarantees the directory
}
page, err := fs.ReadFile(sub, "index.html")
if err != nil {
panic("api: embedded docs page missing: " + err.Error())
}
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(page)
})
mux.Handle("GET /docs/assets/", http.StripPrefix("/docs/assets/", http.FileServerFS(sub)))
}

202
internal/api/docsui/LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,2 @@
swagger-ui
Copyright 2020-2021 SmartBear Software Inc.

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>nmon API</title>
<link rel="stylesheet" href="/docs/assets/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="/docs/assets/swagger-ui-bundle.js"></script>
<script src="/docs/assets/init.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
window.ui = SwaggerUIBundle({ url: '/openapi.json', dom_id: '#swagger-ui', deepLinking: true });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,9 @@
package api
import (
"errors"
"fmt"
"net/http"
"testing"
"github.com/danielgtaylor/huma/v2"
@@ -17,11 +19,12 @@ func TestStoreErrorMapping(t *testing.T) {
status int
detail string
}{
{fmt.Errorf("%w: slug taken", store.ErrConflict), 409, "slug taken"},
{fmt.Errorf("%w: bad kind", store.ErrInvalid), 422, "bad kind"},
{fmt.Errorf("wrapped: %w", store.ErrNotFound), 404, "not found"},
{fmt.Errorf("%w: slug taken", store.ErrConflict), http.StatusConflict, "slug taken"},
{fmt.Errorf("%w: bad kind", store.ErrInvalid), http.StatusUnprocessableEntity, "bad kind"},
{fmt.Errorf("wrapped: %w", store.ErrNotFound), http.StatusNotFound, "not found"},
} {
se, ok := s.storeErr(tc.err).(huma.StatusError)
var se huma.StatusError
ok := errors.As(s.storeErr(tc.err), &se)
if !ok || se.GetStatus() != tc.status || se.Error() != tc.detail {
t.Errorf("%v: got %v, want %d %q", tc.err, se, tc.status, tc.detail)
}

142
internal/api/graph.go Normal file
View File

@@ -0,0 +1,142 @@
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)})
}
}
}

163
internal/api/graph_fetch.go Normal file
View File

@@ -0,0 +1,163 @@
package api
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/vmsup"
)
const (
dirIn = "in"
dirOut = "out"
// minRateWindow keeps the rate window above two poll intervals so a step of
// one minute still sees two samples.
minRateWindow = 2 * time.Minute
// maxInnerStep is the resolution of the max-over-step subquery.
maxInnerStep = time.Minute
)
// graphData holds the samples found, by direction and Unix second.
type graphData struct {
avg map[string]map[int64]float64
max map[string]map[int64]float64
}
func newGraphData() *graphData {
return &graphData{avg: map[string]map[int64]float64{}, max: map[string]map[int64]float64{}}
}
func (g *graphData) put(isMax bool, dir string, ts int64, v float64) {
m := g.avg
if isMax {
m = g.max
}
if m[dir] == nil {
m[dir] = map[int64]float64{}
}
if old, ok := m[dir][ts]; !ok || v > old {
m[dir][ts] = v // stores agree on duplicates; the highest wins
}
}
// graphQuery is what one fetch needs to know.
type graphQuery struct {
iface *store.Interface
dirs []string
withMax bool
}
// octets is the counter that carries direction dir for the interface.
func (q *graphQuery) octets(dir string) string {
switch {
case q.iface.HasHCCounters && dir == dirIn:
return "ifHCInOctets"
case q.iface.HasHCCounters:
return "ifHCOutOctets"
case dir == dirIn:
return "ifInOctets"
}
return "ifOutOctets"
}
func secs(d time.Duration) string { return strconv.FormatInt(int64(d/time.Second), 10) + "s" }
// rawExpr is the average bits per second of dir over the rate window.
func (q *graphQuery) rawExpr(dir string, step time.Duration) string {
sel := q.octets(dir) + `{iface=` + quote(q.iface.ID) + `}`
return `rate(` + sel + `[` + secs(max(step, minRateWindow)) + `])*8`
}
// rawMaxExpr is the highest one-minute rate seen within each step.
func (q *graphQuery) rawMaxExpr(dir string, step time.Duration) string {
sel := q.octets(dir) + `{iface=` + quote(q.iface.ID) + `}`
return `max_over_time((rate(` + sel + `[` + secs(maxInnerStep) + `])*8)[` + secs(step) + `:` + secs(maxInnerStep) + `])`
}
// longExpr reads an hourly rollup metric. Versions of a rewritten window share
// the same value, so max by (dir) collapses them; a coarser step averages (or
// takes the max of) the hours it covers.
func (q *graphQuery) longExpr(metric, over string, dir string, step time.Duration) string {
inner := `max by (dir) (` + metric + `{iface=` + quote(q.iface.ID) + `,dir=` + quote(dir) + `})`
if step <= longStepMin {
return inner
}
return over + `((` + inner + `)[` + secs(step) + `:` + secs(longStepMin) + `])`
}
// fetchTier fills d from the store that owns the tier. Hot errors fail the
// graph; a long store that is down only leaves its stretch empty, which the
// response reports as partial.
func (s *server) fetchTier(ctx context.Context, q *graphQuery, w tierWindow, d *graphData) error {
storeName := "hot"
if w.tier == tier1h {
storeName = "long"
}
for _, dir := range q.dirs {
for _, wantMax := range []bool{false, true} {
if wantMax && !q.withMax {
continue
}
err := s.fetchOne(ctx, storeName, q.exprFor(w, dir, wantMax), w, func(ts int64, v float64) { d.put(wantMax, dir, ts, v) })
if err == nil {
continue
}
if storeName == "long" && !errors.Is(err, context.Canceled) {
s.Logger.Warn("long store query failed; its stretch is reported partial", "err", err)
continue
}
return err
}
}
return nil
}
func (q *graphQuery) exprFor(w tierWindow, dir string, wantMax bool) string {
switch {
case w.tier == tierRaw && wantMax:
return q.rawMaxExpr(dir, w.step)
case w.tier == tierRaw:
return q.rawExpr(dir, w.step)
case wantMax:
return q.longExpr("nmon_if_rate_bps_max_1h", "max_over_time", dir, w.step)
}
return q.longExpr("nmon_if_rate_bps_avg_1h", "avg_over_time", dir, w.step)
}
// fetchOne runs one range query over the tier's window and hands every finite sample to add.
func (s *server) fetchOne(ctx context.Context, storeName, expr string, w tierWindow, add func(ts int64, v float64)) error {
body, err := s.Querier.QueryRange(ctx, storeName, expr, w.from, w.last, w.step)
if err != nil {
return s.vmErr(ctx, err)
}
rows, err := parseVM(body)
if err != nil {
return fmt.Errorf("graph: %w", err)
}
for _, r := range rows {
for _, p := range r.Values {
if p.OK {
add(p.TS, p.V)
}
}
}
return nil
}
// vmErr maps a metric-store failure to a problem. It is shared by /query and /graph.
func (s *server) vmErr(ctx context.Context, err error) error {
switch {
case errors.Is(err, vmsup.ErrStoreDown):
return errStoreDown()
case errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded):
return errQueryTimeout()
case isBadQuery(err):
return badQuery(err)
}
s.Logger.Warn("query failed", "err", err)
return errStoreFailed()
}

View File

@@ -0,0 +1,160 @@
package api
import (
"math"
"testing"
"time"
)
func TestDefaultStepIsMaxOf60sAndRangeOver1000RoundedToMinutes(t *testing.T) {
for rng, want := range map[time.Duration]time.Duration{
time.Hour: time.Minute, 16 * time.Hour: time.Minute, 17 * time.Hour: 2 * time.Minute,
24 * time.Hour: 2 * time.Minute, 30 * 24 * time.Hour: 44 * time.Minute, 365 * 24 * time.Hour: 526 * time.Minute,
} {
if got := defaultStep(rng); got != want {
t.Errorf("defaultStep(%s) = %s, want %s", rng, got, want)
}
}
}
func TestPlanGraphTiers(t *testing.T) {
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
hotStart := now.Add(-30 * 24 * time.Hour)
cases := []struct {
name string
from, to time.Time
tiers []string
stitched bool
wantErr bool
step time.Duration
newestStep time.Duration
}{
{"1h", now.Add(-time.Hour), now, []string{"raw"}, false, false, 0, time.Minute},
{"exactly the hot window", hotStart, now, []string{"raw"}, false, false, 0, 44 * time.Minute},
{"a hair beyond hot is still raw", hotStart.Add(-20 * time.Minute), now, []string{"raw"}, false, false, 0, 44 * time.Minute},
{"straddles", now.Add(-40 * 24 * time.Hour), now, []string{"1h", "raw"}, true, false, 0, 58 * time.Minute},
{"ends inside hot start", now.Add(-60 * 24 * time.Hour), hotStart, []string{"1h"}, false, false, 0, time.Hour},
{"old", now.Add(-90 * 24 * time.Hour), now.Add(-60 * 24 * time.Hour), []string{"1h"}, false, false, 0, time.Hour},
{"too many points", now.Add(-30 * 24 * time.Hour), now, nil, false, true, time.Second, 0},
{"explicit step", now.Add(-time.Hour), now, []string{"raw"}, false, false, 5 * time.Minute, 5 * time.Minute},
}
for _, tc := range cases {
p, err := planGraph(tc.from, tc.to, hotStart, tc.step)
if (err != nil) != tc.wantErr {
t.Errorf("%s: err %v", tc.name, err)
continue
}
if tc.wantErr {
continue
}
var got []string
for _, w := range p.tiers {
got = append(got, w.tier)
}
if len(got) != len(tc.tiers) || (p.stitchedAt != nil) != tc.stitched || p.newest().step != tc.newestStep {
t.Errorf("%s: tiers %v stitched %v newest step %s, want %v %v %s", tc.name, got, p.stitchedAt, p.newest().step, tc.tiers, tc.stitched, tc.newestStep)
continue
}
for i := range got {
if got[i] != tc.tiers[i] {
t.Errorf("%s: tiers %v, want %v", tc.name, got, tc.tiers)
}
}
if tc.stitched {
join := *p.stitchedAt
if join.Before(hotStart) || join.Sub(hotStart) >= p.newest().step || join.Unix()%int64(p.newest().step/time.Second) != 0 {
t.Errorf("%s: join %v", tc.name, join)
}
if long := p.tiers[0]; long.last.Unix() >= join.Unix() || long.bound != join {
t.Errorf("%s: long tier overlaps raw: %+v", tc.name, long)
}
}
}
}
func TestFindGapsThresholdIsTwoSteps(t *testing.T) {
grid := make([]slot, 11)
for i := range grid {
grid[i] = slot{ts: int64(i * 60), step: 60}
}
has := func(missing ...int) func(int64) bool {
return func(ts int64) bool {
for _, m := range missing {
if ts == int64(m*60) {
return false
}
}
return true
}
}
for _, tc := range []struct {
name string
missing []int
want [][2]int64
}{
{"complete", nil, nil},
{"one missing sample", []int{4}, nil},
{"two missing samples", []int{4, 5}, [][2]int64{{180, 360}}},
{"leading", []int{0, 1, 2}, [][2]int64{{0, 180}}},
{"leading short", []int{0, 1}, nil},
{"trailing", []int{8, 9, 10}, [][2]int64{{420, 600}}},
{"two separate holes", []int{2, 3, 7, 8}, [][2]int64{{60, 240}, {360, 540}}},
} {
got := findGaps(grid, has(tc.missing...), 0, 600)
if len(got) != len(tc.want) {
t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
continue
}
for i, g := range got {
if g.From.Unix() != tc.want[i][0] || g.To.Unix() != tc.want[i][1] {
t.Errorf("%s: gap %d is %v..%v, want %v", tc.name, i, g.From.Unix(), g.To.Unix(), tc.want[i])
}
}
}
none := func(int64) bool { return false }
if g := findGaps(grid, none, 0, 600); len(g) != 1 || g[0].From.Unix() != 0 || g[0].To.Unix() != 600 {
t.Errorf("no data: %v", g)
}
if g := findGaps(nil, none, 0, 0); g != nil {
t.Errorf("empty grid: %v", g)
}
}
func TestNearestRank(t *testing.T) {
seq := func(n int) []float64 {
out := make([]float64, n)
for i := range out {
out[i] = float64(n - i) // descending, so sorting matters
}
return out
}
for n, want := range map[int]float64{1: 1, 2: 2, 19: 19, 20: 19, 21: 20, 100: 95, 101: 96, 1000: 950} {
if got := nearestRank(seq(n), 0.95); got != want {
t.Errorf("p95 of 1..%d = %v, want %v", n, got, want)
}
}
if !math.IsNaN(nearestRank(nil, 0.95)) {
t.Error("no values should be NaN")
}
in := []float64{3, 1, 2}
nearestRank(in, 0.5)
if in[0] != 3 {
t.Error("input must not be reordered")
}
}
func TestParentCycleHelpersAndEvidence(t *testing.T) {
got := evidenceLines(`{"b":2,"a":"x","c":["y"]}`)
if len(got) != 3 || got[0] != "a: x" || got[1] != "b: 2" || got[2] != `c: ["y"]` {
t.Errorf("evidence: %q", got)
}
if got := evidenceLines(`not json`); len(got) != 1 || got[0] != "not json" {
t.Errorf("evidence fallback: %q", got)
}
if got := evidenceLines(``); got == nil || len(got) != 0 {
t.Errorf("empty evidence must be [] not nil: %#v", got)
}
if quote(`a"b\c`) != `"a\"b\\c"` {
t.Errorf("quote: %s", quote(`a"b\c`))
}
}

64
internal/api/graph_p95.go Normal file
View File

@@ -0,0 +1,64 @@
package api
import (
"context"
"time"
)
const (
p95Quantile = 0.95
p95Slot = 5 * time.Minute
// maxP95Span bounds the live computation to what one range query can return (about 25,000 slots).
maxP95Span = 85 * 24 * time.Hour
)
// livep95 computes the 95th percentile of 5-minute average rates over the
// raw (hot) part of the drawn range, by nearest rank. M5 adds the materialised
// monthly value; until then the source is "live" and coverage says how much of
// the range's 5-minute slots had data, so a short history never passes for a full month.
func (s *server) livep95(ctx context.Context, q *graphQuery, p *graphPlan, hotStart time.Time) (*GraphP95, error) {
raw := p.newest()
if raw.tier != tierRaw {
return &GraphP95{Source: "live"}, nil
}
start := ceilTo(latest(raw.from, hotStart, raw.bound.Add(-maxP95Span)), p95Slot)
end := floorTo(raw.bound, p95Slot)
expected := max(int64(p.to.Sub(p.from)/p95Slot), 1)
out := &GraphP95{Source: "live"}
if end.Before(start) {
return out, nil
}
w := tierWindow{tier: tierRaw, from: start, last: end, bound: end, step: p95Slot}
coverage := 1.0
for _, dir := range q.dirs {
var vals []float64
expr := `rate(` + q.octets(dir) + `{iface=` + quote(q.iface.ID) + `}[` + secs(p95Slot) + `])*8`
err := s.fetchOne(ctx, "hot", expr, w, func(_ int64, v float64) { vals = append(vals, v) })
if err != nil {
return nil, err
}
coverage = min(coverage, float64(len(vals))/float64(expected))
if len(vals) == 0 {
continue
}
v := nearestRank(vals, p95Quantile)
if dir == dirIn {
out.InBPS = &v
} else {
out.OutBPS = &v
}
}
out.Coverage = min(coverage, 1)
return out, nil
}
// latest returns the latest of ts.
func latest(ts ...time.Time) time.Time {
out := ts[0]
for _, t := range ts[1:] {
if t.After(out) {
out = t
}
}
return out
}

119
internal/api/graph_plan.go Normal file
View File

@@ -0,0 +1,119 @@
package api
import (
"errors"
"fmt"
"time"
)
const (
// maxGraphPoints bounds the points per series; the metric store caps a range query near 30,000.
maxGraphPoints = 11_000
// minRawStep is the default floor of a raw step: one poll interval.
minRawStep = 60 * time.Second
// targetPoints is how many points the default step aims for.
targetPoints = 1000
// longStepMin is the resolution of the long store's hourly rollup.
longStepMin = time.Hour
)
var errTooManyPoints = errors.New("too many points")
// tierWindow is one stretch of a graph answered by one store: points at
// from, from+step, ... up to and including last. bound is where the tier
// officially ends (the join for the older tier of a stitched graph).
type tierWindow struct {
tier string
from time.Time
last time.Time
bound time.Time
step time.Duration
}
// graphPlan says which stores answer which part of [from, to]. Tiers are oldest first.
type graphPlan struct {
from, to time.Time // aligned bounds of the whole graph
tiers []tierWindow
stitchedAt *time.Time
}
func (p *graphPlan) newest() tierWindow { return p.tiers[len(p.tiers)-1] }
// floorTo rounds t down to a multiple of d, counted from the Unix epoch so that
// panning a graph never shifts its sample times.
func floorTo(t time.Time, d time.Duration) time.Time {
ds := int64(d / time.Second)
return time.Unix(floorDiv(t.Unix(), ds)*ds, 0).UTC()
}
func ceilTo(t time.Time, d time.Duration) time.Time {
f := floorTo(t, d)
if f.Equal(t) {
return f
}
return f.Add(d)
}
func floorDiv(a, b int64) int64 {
q := a / b
if a%b != 0 && (a < 0) != (b < 0) {
q--
}
return q
}
// defaultStep is max(60 s, range/1000) rounded up to whole minutes, so the
// same range always lands on the same grid.
func defaultStep(rng time.Duration) time.Duration {
return max(minRawStep, ceilDur(rng/targetPoints, minRawStep))
}
func ceilDur(d, unit time.Duration) time.Duration {
return (d + unit - 1) / unit * unit
}
// planGraph decides the tiers for [from, to] at the given step (0: default).
// hotStart is where the hot store's retention begins. A range that lies within
// hot (give or take one step) is answered by raw data alone, a range wholly
// older than hot by hourly data alone, and one that straddles hotStart is
// stitched: hourly before the join, raw from it.
func planGraph(from, to, hotStart time.Time, step time.Duration) (*graphPlan, error) {
if step <= 0 {
step = defaultStep(to.Sub(from))
}
if n := to.Sub(from) / step; n > maxGraphPoints {
return nil, fmt.Errorf("%w: %d points at a %s step; the limit is %d, use a larger step", errTooManyPoints, n, step, maxGraphPoints)
}
fromRaw, toRaw := floorTo(from, step), floorTo(to, step)
long := max(longStepMin, ceilDur(step, longStepMin))
switch {
case !fromRaw.Add(step).Before(hotStart):
return &graphPlan{from: fromRaw, to: toRaw, tiers: []tierWindow{
{tier: tierRaw, from: fromRaw, last: toRaw, bound: toRaw, step: step},
}}, nil
case !toRaw.After(hotStart):
fl, tl := floorTo(from, long), floorTo(to, long)
return &graphPlan{from: fl, to: tl, tiers: []tierWindow{
{tier: tier1h, from: fl, last: tl, bound: tl, step: long},
}}, nil
}
join := ceilTo(hotStart, step)
fl := floorTo(from, long)
return &graphPlan{from: fl, to: toRaw, stitchedAt: &join, tiers: []tierWindow{
{tier: tier1h, from: fl, last: floorTo(join.Add(-time.Nanosecond), long), bound: join, step: long},
{tier: tierRaw, from: join, last: toRaw, bound: toRaw, step: step},
}}, nil
}
// slots lists the sample times of a tier.
func (w tierWindow) slots() []int64 {
if w.last.Before(w.from) {
return nil
}
n := int(w.last.Sub(w.from)/w.step) + 1
out := make([]int64, 0, n)
for t := w.from; !t.After(w.last); t = t.Add(w.step) {
out = append(out, t.Unix())
}
return out
}

View File

@@ -0,0 +1,80 @@
package api
import (
"math"
"sort"
"time"
)
// gapFactor is how many steps without data make a stretch "partial".
const gapFactor = 2
// slot is one sample time of the graph and the step of the tier it belongs to.
type slot struct {
ts int64
step int64
}
// gridOf lists every sample time of the plan, oldest first.
func gridOf(p *graphPlan) []slot {
var out []slot
for _, w := range p.tiers {
step := int64(w.step / time.Second)
for _, ts := range w.slots() {
out = append(out, slot{ts: ts, step: step})
}
}
return out
}
// pointsOf lays vals over the grid; a slot without data is a null point.
func pointsOf(grid []slot, vals map[int64]float64) []Point {
out := make([]Point, len(grid))
for i, g := range grid {
out[i] = Point{TS: g.ts}
if v, ok := vals[g.ts]; ok {
x := v
out[i].V = &x
}
}
return out
}
// findGaps returns the stretches of [start, end] longer than gapFactor steps in
// which no slot has data. A stretch runs from the last slot with data (or the
// start) to the next one (or the end); its step is the larger of its two
// neighbours', so a join between tiers does not report a false gap.
func findGaps(grid []slot, has func(ts int64) bool, start, end int64) []TimeSpan {
if len(grid) == 0 {
return nil
}
var gaps []TimeSpan
prevTS, prevStep := start, grid[0].step
report := func(from, to, step int64) {
if to-from > gapFactor*step {
gaps = append(gaps, TimeSpan{From: time.Unix(from, 0).UTC(), To: time.Unix(to, 0).UTC()})
}
}
for _, g := range grid {
if !has(g.ts) {
continue
}
report(prevTS, g.ts, max(prevStep, g.step))
prevTS, prevStep = g.ts, g.step
}
report(prevTS, end, prevStep)
return gaps
}
// nearestRank is the p-quantile (0 < p <= 1) of vals by the nearest-rank
// method: the value at rank ceil(p*n) of the sorted list. It is exact, takes
// an actual sample and needs no interpolation convention. NaN for no values.
func nearestRank(vals []float64, p float64) float64 {
if len(vals) == 0 {
return math.NaN()
}
s := append([]float64(nil), vals...)
sort.Float64s(s)
rank := int(math.Ceil(p*float64(len(s)) - 1e-9)) // the epsilon keeps 0.95*20 from rounding up to 20
return s[min(max(rank, 1), len(s))-1]
}

444
internal/api/graph_test.go Normal file
View File

@@ -0,0 +1,444 @@
package api_test
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/url"
"sort"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/vmsup"
)
// graphVM fakes the two stores. hot and long say whether a store has a sample
// at ts; the value is a function of direction and time so tests can check which store answered.
type graphVM struct {
hot, long func(ts int64) bool
hotVal func(dir string, ts int64) float64
longVal func(dir string, ts int64) float64
longErr error
}
func dirOf(expr string) string {
if strings.Contains(expr, `dir="in"`) || strings.Contains(expr, "InOctets") {
return "in"
}
return "out"
}
func (g *graphVM) fn(storeName, expr string, from, to time.Time, step time.Duration) ([]byte, error) {
has, val := g.hot, g.hotVal
if storeName == "long" {
if g.longErr != nil {
return nil, g.longErr
}
has, val = g.long, g.longVal
}
dir := dirOf(expr)
vals := map[int64]float64{}
for ts := from.Unix(); ts <= to.Unix(); ts += int64(step / time.Second) {
if has == nil || has(ts) {
vals[ts] = val(dir, ts)
}
}
if len(vals) == 0 {
return emptyMatrix, nil
}
return matrix(mrow{labels: map[string]string{"dir": dir}, vals: vals}), nil
}
func rising(dir string, ts int64) float64 {
if dir == "in" {
return 1e6 + float64(ts%100000)
}
return 2e6 + float64(ts%100000)
}
func newGraphEnv(t *testing.T, vm *graphVM, mut ...func(*store.Interface)) (*env, store.Interface) {
e := newEnv(t, 0)
e.q.fn = vm.fn
d := e.device(t, "Core", "", "")
return e, e.iface(t, d, 1, "xe-0/0/1", "uplink", mut...)
}
func iso(ts time.Time) string { return ts.UTC().Format(time.RFC3339) }
func graphURL(id string, from, to time.Time, extra string) string {
return "/api/v1/graph?iface=" + id + "&from=" + url.QueryEscape(iso(from)) + "&to=" + url.QueryEscape(iso(to)) + extra
}
func TestGraphRawTierWithinHotRetention(t *testing.T) {
vm := &graphVM{hotVal: rising}
e, i := newGraphEnv(t, vm)
var g api.GraphResponse
rec := e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, ""), &g)
assertNoLeaks(t, "graph", rec.Body.Bytes())
if g.Tier != "raw" || g.StepSeconds != 60 || g.StitchedAt != nil || len(g.Tiers) != 0 || g.Unreliable {
t.Fatalf("raw graph: %+v", g)
}
if g.Partial == nil || len(g.Partial) != 0 {
t.Errorf("partial must be [] when complete: %v", g.Partial)
}
if g.Iface.ID != i.ID || g.Iface.IfName != "xe-0/0/1" || g.Iface.IfAlias != "uplink" || g.Iface.SpeedBPS != 1_000_000_000 || g.Iface.Device.Name != "Core" {
t.Errorf("iface: %+v", g.Iface)
}
if len(g.Series.In) != 61 || len(g.Series.Out) != 61 || g.Series.InMax != nil {
t.Fatalf("series: %d in, %d out", len(g.Series.In), len(g.Series.Out))
}
first, last := g.Series.In[0], g.Series.In[60]
if first.TS != t0.Add(-time.Hour).Unix() || last.TS != t0.Unix() || first.V == nil || *first.V != rising("in", first.TS) {
t.Errorf("first %+v last %+v", first, last)
}
if *g.Series.Out[10].V != rising("out", g.Series.Out[10].TS) {
t.Errorf("out series %+v", g.Series.Out[10])
}
qs := e.q.queries()
if len(qs) != 2 || qs[0].store != "hot" || qs[0].step != time.Minute ||
qs[0].expr != `rate(ifHCInOctets{iface="`+i.ID+`"}[120s])*8` || !strings.Contains(qs[1].expr, "ifHCOutOctets") {
t.Errorf("queries: %+v", qs)
}
var raw map[string]json.RawMessage
_ = json.Unmarshal(rec.Body.Bytes(), &raw)
for _, k := range []string{"stitched_at", "tiers", "unreliable_reason", "p95"} {
if _, ok := raw[k]; ok {
t.Errorf("%s must be omitted from a plain raw graph", k)
}
}
if !strings.HasPrefix(string(raw["series"]), `{"in":[[`) {
t.Errorf("points must be [ts,value] pairs: %.60s", raw["series"])
}
}
func TestGraphUses32BitCountersWhenNoHC(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising}, func(i *store.Interface) { i.HasHCCounters = false })
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, ""), &g)
if q := e.q.queries()[0].expr; !strings.Contains(q, "ifInOctets{") || strings.Contains(q, "HC") {
t.Errorf("32-bit query: %s", q)
}
}
func TestGraphStepDefaultsAndLimits(t *testing.T) {
for _, tc := range []struct {
rng time.Duration
step int64
}{{time.Hour, 60}, {24 * time.Hour, 120}, {7 * 24 * time.Hour, 660}, {30 * 24 * time.Hour, 2640}} {
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-tc.rng), t0, ""), &g)
if g.StepSeconds != tc.step || g.Tier != "raw" || g.StitchedAt != nil {
t.Errorf("range %s: step %d tier %s stitched %v, want step %d raw", tc.rng, g.StepSeconds, g.Tier, g.StitchedAt, tc.step)
}
if n := len(g.Series.In); n < 700 && tc.rng >= 24*time.Hour || n > 1001 {
t.Errorf("range %s: %d points", tc.rng, n)
}
for _, p := range g.Series.In {
if p.TS%g.StepSeconds != 0 {
t.Fatalf("range %s: sample %d is not aligned to the %d s step", tc.rng, p.TS, g.StepSeconds)
}
}
}
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, "&step=300"), &g)
if g.StepSeconds != 300 || len(g.Series.In) != 13 {
t.Errorf("explicit step: %d, %d points", g.StepSeconds, len(g.Series.In))
}
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-30*24*time.Hour), t0, "&step=1"), ""), 422)
}
func TestGraphStitchesHourlyLongDataToRawHot(t *testing.T) {
join := t0.Add(-30 * 24 * time.Hour)
vm := &graphVM{
hot: func(ts int64) bool { return ts >= join.Unix() }, hotVal: rising,
longVal: func(dir string, ts int64) float64 { return -1 }, // long values are recognisable
}
e, i := newGraphEnv(t, vm)
from := t0.Add(-40 * 24 * time.Hour)
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, from, t0, "&max=true"), &g)
if g.StitchedAt == nil || g.Tier != "raw" || len(g.Tiers) != 2 {
t.Fatalf("stitched graph: stitched_at=%v tier=%s tiers=%+v", g.StitchedAt, g.Tier, g.Tiers)
}
if g.Tiers[0].Tier != "1h" || g.Tiers[0].StepSeconds != 3600 || g.Tiers[1].Tier != "raw" || g.Tiers[1].StepSeconds != g.StepSeconds {
t.Errorf("tiers: %+v step=%d", g.Tiers, g.StepSeconds)
}
if !g.Tiers[0].To.Equal(*g.StitchedAt) || !g.Tiers[1].From.Equal(*g.StitchedAt) || g.StitchedAt.Before(join) ||
g.StitchedAt.Sub(join) >= time.Duration(g.StepSeconds)*time.Second {
t.Errorf("stitched_at %v should be the hot retention edge %v rounded up by less than a step", g.StitchedAt, join)
}
var long, rawN int
prev := int64(0)
for _, p := range g.Series.In {
if p.TS <= prev {
t.Fatalf("series not strictly increasing at %d", p.TS)
}
prev = p.TS
if p.V == nil {
t.Fatalf("hole at %d: both stores have data here", p.TS)
}
if *p.V == -1 {
long++
if p.TS >= g.StitchedAt.Unix() || p.TS%3600 != 0 {
t.Errorf("long sample %d misplaced", p.TS)
}
} else {
rawN++
if p.TS < g.StitchedAt.Unix() {
t.Errorf("raw sample %d before the join", p.TS)
}
}
}
if long < 200 || rawN < 400 {
t.Errorf("%d long and %d raw samples", long, rawN)
}
if len(g.Partial) != 0 {
t.Errorf("no gaps expected: %v", g.Partial)
}
if len(g.Series.InMax) != len(g.Series.In) || len(g.Series.OutMax) != len(g.Series.Out) {
t.Errorf("max series lengths %d/%d", len(g.Series.InMax), len(g.Series.OutMax))
}
var sawLong, sawHot bool
for _, c := range e.q.queries() {
switch c.store {
case "long":
sawLong = true
if !strings.Contains(c.expr, "max by (dir)") || !strings.Contains(c.expr, `iface="`+i.ID+`"`) ||
(!strings.Contains(c.expr, "nmon_if_rate_bps_avg_1h") && !strings.Contains(c.expr, "nmon_if_rate_bps_max_1h")) {
t.Errorf("long query: %s", c.expr)
}
case "hot":
sawHot = true
if c.from.Before(*g.StitchedAt) {
t.Errorf("hot queried before the join: %v", c.from)
}
}
}
if !sawLong || !sawHot {
t.Error("a stitched graph must query both stores")
}
}
func TestGraphLongRangeUsesCoarserStepAndHourlyAverages(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising, longVal: rising})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-365*24*time.Hour), t0, ""), &g)
if g.StitchedAt == nil || g.Tiers[0].StepSeconds%3600 != 0 || g.Tiers[0].StepSeconds <= 3600 {
t.Fatalf("1y tiers: %+v", g.Tiers)
}
var longExpr string
for _, c := range e.q.queries() {
if c.store == "long" {
longExpr = c.expr
}
}
if !strings.Contains(longExpr, "avg_over_time((max by (dir)") || !strings.Contains(longExpr, ":3600s])") {
t.Errorf("coarse long query should average the hourly points: %s", longExpr)
}
}
func TestGraphOlderThanHotIsLongTierOnly(t *testing.T) {
vm := &graphVM{hot: func(int64) bool { return false }, hotVal: rising, longVal: rising}
e, i := newGraphEnv(t, vm)
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-90*24*time.Hour), t0.Add(-60*24*time.Hour), ""), &g)
if g.Tier != "1h" || g.StepSeconds != 3600 || g.StitchedAt != nil || len(g.Tiers) != 0 || len(g.Series.In) < 700 {
t.Errorf("long-only graph: tier=%s step=%d stitched=%v %d points", g.Tier, g.StepSeconds, g.StitchedAt, len(g.Series.In))
}
for _, c := range e.q.queries() {
if c.store != "long" {
t.Errorf("hot queried for a range wholly beyond hot retention: %+v", c)
}
}
}
func TestGraphReportsPartialWhereNeitherStoreHasData(t *testing.T) {
// Until M5's rollup exists the long store is empty: the stitched span is partial, honestly.
join := t0.Add(-30 * 24 * time.Hour)
vm := &graphVM{hot: func(ts int64) bool { return ts >= join.Unix() }, hotVal: rising, long: func(int64) bool { return false }}
e, i := newGraphEnv(t, vm)
from := t0.Add(-40 * 24 * time.Hour)
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, from, t0, ""), &g)
if g.StitchedAt == nil || len(g.Partial) != 1 {
t.Fatalf("partial: %+v stitched %v", g.Partial, g.StitchedAt)
}
p := g.Partial[0]
if p.From.After(from.Add(time.Hour)) || p.To.Before(join) || p.To.After(join.Add(2*time.Hour)) {
t.Errorf("gap %v..%v, want about %v..%v", p.From, p.To, from, join)
}
for _, pt := range g.Series.In {
if pt.TS < g.StitchedAt.Unix() && pt.V != nil {
t.Fatalf("a null expected before the join, got %v at %d", *pt.V, pt.TS)
}
}
// a short hole in raw data is a partial span; a single missed sample is not
e2, i2 := newGraphEnv(t, &graphVM{hotVal: rising, hot: func(ts int64) bool {
m := (ts - t0.Add(-time.Hour).Unix()) / 60
return m < 20 || m > 50 && m != 55
}})
e2.getJSON(t, graphURL(i2.ID, t0.Add(-time.Hour), t0, ""), &g)
if len(g.Partial) != 1 || !g.Partial[0].From.Equal(t0.Add(-41*time.Minute)) || !g.Partial[0].To.Equal(t0.Add(-9*time.Minute)) {
t.Errorf("hole: %+v", g.Partial)
}
// no data at all: one partial span covering the range
e3, i3 := newGraphEnv(t, &graphVM{hotVal: rising, hot: func(int64) bool { return false }})
e3.getJSON(t, graphURL(i3.ID, t0.Add(-time.Hour), t0, ""), &g)
if len(g.Partial) != 1 || !g.Partial[0].From.Equal(t0.Add(-time.Hour)) || !g.Partial[0].To.Equal(t0) {
t.Errorf("empty: %+v", g.Partial)
}
// data that starts late: a leading gap
e4, i4 := newGraphEnv(t, &graphVM{hotVal: rising, hot: func(ts int64) bool { return ts >= t0.Add(-20*time.Minute).Unix() }})
e4.getJSON(t, graphURL(i4.ID, t0.Add(-time.Hour), t0, ""), &g)
if len(g.Partial) != 1 || !g.Partial[0].From.Equal(t0.Add(-time.Hour)) || !g.Partial[0].To.Equal(t0.Add(-20*time.Minute)) {
t.Errorf("leading gap: %+v", g.Partial)
}
}
func TestGraphLongStoreDownIsPartialNotAnError(t *testing.T) {
join := t0.Add(-30 * 24 * time.Hour)
vm := &graphVM{hot: func(ts int64) bool { return ts >= join.Unix() }, hotVal: rising, longErr: fmt.Errorf("%w: long", vmsup.ErrStoreDown)}
e, i := newGraphEnv(t, vm)
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-40*24*time.Hour), t0, ""), &g)
if g.StitchedAt == nil || len(g.Partial) != 1 {
t.Errorf("long store down: %+v", g.Partial)
}
}
func TestGraphUnreliableInterfaceHasNoSeries(t *testing.T) {
for name, tc := range map[string]struct {
mut func(*store.Interface)
reason string
}{
"32-bit fast link": {func(i *store.Interface) { i.Unreliable, i.HasHCCounters = true, false }, "counter_32bit_fast_link"},
"discontinuities": {func(i *store.Interface) { i.Unreliable, i.CounterDiscontinuity = true, 3 }, "counter_discontinuity"},
"unreliable for HC": {func(i *store.Interface) { i.Unreliable = true }, "counter_unreliable"},
} {
t.Run(name, func(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising}, tc.mut)
var g api.GraphResponse
rec := e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, "&max=true&p95=true"), &g)
if !g.Unreliable || g.UnreliableReason != tc.reason || len(g.Series.In) != 0 || len(g.Series.Out) != 0 ||
g.P95 != nil || g.Partial == nil || g.Tier != "raw" || g.StepSeconds != 60 {
t.Errorf("unreliable graph: %+v", g)
}
if n := len(e.q.queries()); n != 0 {
t.Errorf("an unreliable interface must not be queried, %d queries", n)
}
if !strings.Contains(rec.Body.String(), `"in":[],"out":[]`) {
t.Errorf("series must be empty arrays: %s", rec.Body)
}
})
}
}
func TestGraphDirectionFilterAndMax(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, "&dir=out&max=true"), &g)
if len(g.Series.In) != 0 || len(g.Series.Out) != 61 || len(g.Series.OutMax) != 61 || g.Series.InMax != nil {
t.Errorf("dir=out: in=%d out=%d out_max=%d", len(g.Series.In), len(g.Series.Out), len(g.Series.OutMax))
}
qs := e.q.queries()
if len(qs) != 2 {
t.Fatalf("queries %d", len(qs))
}
for _, c := range qs {
if strings.Contains(c.expr, "In") {
t.Errorf("dir=out queried the in counter: %s", c.expr)
}
}
if !strings.Contains(qs[1].expr, "max_over_time((rate(ifHCOutOctets{iface=") || !strings.Contains(qs[1].expr, "[60s:60s])") {
t.Errorf("max query: %s", qs[1].expr)
}
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-time.Hour), t0, "&dir=sideways"), ""), 422)
}
// referenceP95 is an independent nearest-rank implementation: the smallest
// value such that at least 95% of the values are at or below it.
func referenceP95(vals []float64) float64 {
s := append([]float64(nil), vals...)
sort.Float64s(s)
for _, v := range s {
n := 0
for _, x := range s {
if x <= v {
n++
}
}
if float64(n)*100 >= 95*float64(len(s)) {
return v
}
}
return math.NaN()
}
func TestGraphP95IsNearestRankOverFiveMinuteAverages(t *testing.T) {
// 24 h of 5-minute averages; one hour of the day has no data.
from := t0.Add(-24 * time.Hour)
vals := func(dir string, ts int64) float64 {
k := float64((ts / 300 * 7919) % 1000) // scrambled but deterministic
if dir == "out" {
k *= 3
}
return k * 1000
}
hole := func(ts int64) bool { return ts < t0.Add(-5*time.Hour).Unix() || ts >= t0.Add(-4*time.Hour).Unix() }
e, i := newGraphEnv(t, &graphVM{hotVal: vals, hot: hole})
var g api.GraphResponse
e.getJSON(t, graphURL(i.ID, from, t0, "&p95=true"), &g)
if g.P95 == nil || g.P95.Source != "live" || g.P95.InBPS == nil || g.P95.OutBPS == nil {
t.Fatalf("p95: %+v", g.P95)
}
var in, out []float64
for ts := from.Unix(); ts <= t0.Unix(); ts += 300 {
if hole(ts) {
in, out = append(in, vals("in", ts)), append(out, vals("out", ts))
}
}
if *g.P95.InBPS != referenceP95(in) || *g.P95.OutBPS != referenceP95(out) {
t.Errorf("p95 in %v out %v, reference %v / %v", *g.P95.InBPS, *g.P95.OutBPS, referenceP95(in), referenceP95(out))
}
wantCov := float64(len(in)) / (24 * 12)
if math.Abs(g.P95.Coverage-wantCov) > 0.01 || g.P95.Coverage >= 1 {
t.Errorf("coverage %v, want about %v", g.P95.Coverage, wantCov)
}
var withoutP95 api.GraphResponse
e.getJSON(t, graphURL(i.ID, from, t0, ""), &withoutP95)
if withoutP95.P95 != nil {
t.Error("p95 only when asked")
}
}
func TestGraphErrors(t *testing.T) {
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
isProblem(t, e.do("GET", "/api/v1/graph", ""), 422)
isProblem(t, e.do("GET", "/api/v1/graph?iface=01ARZ3NDEKTSV4RRFFQ69G5FAV", ""), 404)
isProblem(t, e.do("GET", graphURL(i.ID, t0, t0.Add(-time.Hour), ""), ""), 422)
isProblem(t, e.do("GET", "/api/v1/graph?iface="+i.ID+"&from=yesterday", ""), 422)
isProblem(t, e.do("GET", "/api/v1/graph?iface="+i.ID+"&step=0", ""), 422)
e.q.fn = func(string, string, time.Time, time.Time, time.Duration) ([]byte, error) {
return nil, fmt.Errorf("%w: hot", vmsup.ErrStoreDown)
}
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-time.Hour), t0, ""), ""), 503)
e.q.fn = func(string, string, time.Time, time.Time, time.Duration) ([]byte, error) {
return nil, errors.New("boom")
}
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-time.Hour), t0, ""), ""), 502)
// defaults: to=now, from=1h earlier
e.q.fn = (&graphVM{hotVal: rising}).fn
var g api.GraphResponse
e.getJSON(t, "/api/v1/graph?iface="+i.ID, &g)
if len(g.Series.In) != 61 || g.Series.In[60].TS != t0.Unix() {
t.Errorf("default range: %d points", len(g.Series.In))
}
}

125
internal/api/graph_types.go Normal file
View File

@@ -0,0 +1,125 @@
package api
import (
"encoding/json"
"time"
"github.com/danielgtaylor/huma/v2"
)
// Graph tiers: raw samples from the hot store, or hourly rollups from the long store.
const (
tierRaw = "raw"
tier1h = "1h"
)
// Point is one sample of a series: Unix seconds and bits per second, or null
// when the store had no data for that step. It marshals as [ts, value].
type Point struct {
TS int64
V *float64
}
// MarshalJSON writes the compact [unix_s, bps|null] form uPlot can use as is.
func (p Point) MarshalJSON() ([]byte, error) {
return json.Marshal([]any{p.TS, p.V})
}
// UnmarshalJSON reads the [ts, value] form (tests and clients written in Go).
func (p *Point) UnmarshalJSON(b []byte) error {
var raw [2]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
if err := json.Unmarshal(raw[0], &p.TS); err != nil {
return err
}
return json.Unmarshal(raw[1], &p.V)
}
// Schema describes Point as a two-element tuple. huma's Schema has no
// prefixItems field, so it goes in as an extension; generated clients type it as [number, number | null].
func (Point) Schema(r huma.Registry) *huma.Schema {
two := 2
r.Map()["Point"] = &huma.Schema{
Type: huma.TypeArray, MinItems: &two, MaxItems: &two,
Description: "[unix seconds, bits per second or null].",
Extensions: map[string]any{"prefixItems": []any{
map[string]any{"type": "number", "description": "unix seconds"},
map[string]any{"anyOf": []any{
map[string]any{"type": "number", "description": "bits per second"}, map[string]any{"type": "null"},
}},
}},
}
return &huma.Schema{Ref: "#/components/schemas/Point"}
}
// GraphIface identifies the graphed interface.
type GraphIface struct {
ID string `json:"id"`
IfName string `json:"ifname"`
IfAlias string `json:"ifalias"`
SpeedBPS int64 `json:"speed_bps"`
Device DeviceRef `json:"device"`
}
// TimeSpan is a half-open stretch of time.
type TimeSpan struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
}
// GraphTier says which store and step answered one stretch of a stitched graph.
type GraphTier struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
Tier string `json:"tier" enum:"raw,1h"`
StepSeconds int64 `json:"step_seconds"`
}
// GraphSeries holds the bits-per-second series. in and out are always present
// (empty for a direction not asked for); the max series only when requested.
type GraphSeries struct {
In []Point `json:"in"`
Out []Point `json:"out"`
InMax []Point `json:"in_max,omitempty"`
OutMax []Point `json:"out_max,omitempty"`
}
// set stores pts as the average (or maximum) series of dir.
func (g *GraphSeries) set(dir string, isMax bool, pts []Point) {
switch {
case dir == dirIn && isMax:
g.InMax = pts
case dir == dirIn:
g.In = pts
case isMax:
g.OutMax = pts
default:
g.Out = pts
}
}
// GraphP95 is the 95th percentile of 5-minute averages (nearest rank).
type GraphP95 struct {
InBPS *float64 `json:"in_bps" nullable:"true"`
OutBPS *float64 `json:"out_bps" nullable:"true"`
Source string `json:"source" enum:"materialised,live"`
Coverage float64 `json:"coverage" doc:"Share of the drawn range's 5-minute slots that had data, 0 to 1."`
}
// GraphResponse is a rate graph for one interface. A graph answered from two
// stores always carries stitched_at; stretches with no data in either store are
// listed in partial; an unreliable interface has no series at all.
type GraphResponse struct {
Iface GraphIface `json:"iface"`
StepSeconds int64 `json:"step_seconds" doc:"Step of the newest tier."`
Tier string `json:"tier" enum:"raw,1h" doc:"Tier of the newest data."`
StitchedAt *time.Time `json:"stitched_at,omitempty" doc:"Where the hourly long-store data ends and raw hot data begins."`
Tiers []GraphTier `json:"tiers,omitempty" doc:"Present when two stores answered."`
UnreliableReason string `json:"unreliable_reason,omitempty" doc:"Why the counters are not trusted, e.g. counter_32bit_fast_link."`
Partial []TimeSpan `json:"partial" doc:"Stretches of the range where neither store has data."`
Unreliable bool `json:"unreliable"`
Series GraphSeries `json:"series"`
P95 *GraphP95 `json:"p95,omitempty"`
}

184
internal/api/health.go Normal file
View File

@@ -0,0 +1,184 @@
package api
import (
"context"
"net/http"
"sync"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/metrics"
"github.com/v10networks/nmon/internal/store"
)
// PollerDeviceHealth is the poller's view of one device. It uses the slug, never the numeric ID.
type PollerDeviceHealth struct {
Slug string `json:"slug"`
Name string `json:"name"`
State string `json:"state" enum:"ok,degraded,backoff" doc:"backoff: healthy but polled slower than its base interval."`
Varbinds int64 `json:"varbinds" doc:"Varbinds per request right now."`
IntervalS int64 `json:"interval_s" doc:"Current cadence, backoff included."`
BaseIntervalS int64 `json:"base_interval_s"`
LastCycleS float64 `json:"last_cycle_s" doc:"Duration of the last cycle."`
MissedDeadlines int64 `json:"missed_deadlines" doc:"Consecutive cycles that ended at the deadline."`
ConsecutiveFailures int64 `json:"consecutive_failures"`
LastError string `json:"last_error"`
NextCycle *time.Time `json:"next_cycle" nullable:"true"`
Profile string `json:"profile"`
}
// PollerTotals are the poller's aggregate numbers. The per-minute figures are
// deltas of nmon's own counters over about the last minute.
type PollerTotals struct {
PDUsPerMin float64 `json:"pdus_per_min"`
TimeoutsPerMin float64 `json:"timeouts_per_min"`
DeadlineMissesPerMin float64 `json:"deadline_misses_per_min"`
WriteQueueDepth int64 `json:"write_queue_depth" doc:"Samples waiting in the hot write queue."`
WriteQueueCapacity int64 `json:"write_queue_capacity"`
DevicesDegraded int64 `json:"devices_degraded"`
}
// PollerHealth is the poller health page.
type PollerHealth struct {
Devices []PollerDeviceHealth `json:"devices"`
Totals PollerTotals `json:"totals"`
}
func (s *server) registerHealth(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "get-poller-health", Method: http.MethodGet, Path: "/api/v1/poller/health", Tags: []string{"poller"},
Summary: "Poller state of every polled device, with PDU, timeout and queue totals",
Description: "Rates are over about the last minute; the first call after start may report zero.",
}, s.pollerHealth)
}
func (s *server) pollerHealth(ctx context.Context, _ *struct{}) (*struct{ Body PollerHealth }, error) {
names, err := s.deviceNames(ctx)
if err != nil {
return nil, s.storeErr(err)
}
out := PollerHealth{Devices: []PollerDeviceHealth{}}
if s.PollerStatus != nil {
for _, d := range s.PollerStatus() {
h := PollerDeviceHealth{
Slug: d.Device, Name: names[d.Device], State: d.State, Varbinds: int64(d.Varbinds),
IntervalS: int64(d.Interval.Seconds()), BaseIntervalS: int64(d.BaseInterval.Seconds()),
LastCycleS: d.LastCycleDuration.Seconds(), MissedDeadlines: int64(d.MissedDeadlines),
ConsecutiveFailures: int64(d.ConsecutiveFailures), LastError: d.LastError, NextCycle: tp(d.NextCycle),
Profile: d.Profile,
}
if h.State == store.PollOK && d.Interval > d.BaseInterval {
h.State = store.PollBackoff
}
if h.State != store.PollOK {
out.Totals.DevicesDegraded++
}
out.Devices = append(out.Devices, h)
}
}
s.fillTotals(&out.Totals)
return &struct{ Body PollerHealth }{out}, nil
}
// deviceNames maps slugs to display names.
func (s *server) deviceNames(ctx context.Context) (map[string]string, error) {
names := map[string]string{}
for off := int64(0); ; off += store.MaxLimit {
rows, n, err := s.Store.ListDevices(ctx, store.DeviceFilter{IncludeRetired: true}, store.Page{Offset: off, Limit: store.MaxLimit})
if err != nil {
return nil, err
}
for _, d := range rows {
names[d.Slug] = d.Name
}
if off+store.MaxLimit >= n {
return names, nil
}
}
}
// fillTotals reads nmon's own counters from the registry.
func (s *server) fillTotals(t *PollerTotals) {
t.PDUsPerMin, t.TimeoutsPerMin, t.DeadlineMissesPerMin = s.rate.perMinute(s.readCounters())
for _, x := range s.Registry.Snapshot(s.Clock.Now()) {
if x.Name == "nmon_write_queue_depth" && labelValue(x.Labels, "queue") == "hot" {
t.WriteQueueDepth += int64(x.Value)
}
}
t.WriteQueueCapacity = int64(s.WriteQueueCapacity)
}
// counterSample is the registry's counter totals at one instant.
type counterSample struct {
at time.Time
pdus, timeouts, misses float64
}
// readCounters sums the poll-path counters across all their series.
func (s *server) readCounters() counterSample {
c := counterSample{at: s.Clock.Now()}
for _, x := range s.Registry.Snapshot(c.at) {
switch x.Name {
case "nmon_pdus_total":
if labelValue(x.Labels, "path") == "poll" {
c.pdus += x.Value
}
case "nmon_snmp_timeouts_total":
if labelValue(x.Labels, "path") == "poll" {
c.timeouts += x.Value
}
case "nmon_poll_deadline_missed_total":
c.misses += x.Value
}
}
return c
}
// counterRates turns successive counter totals into per-minute rates. It keeps
// about a minute and a half of history and measures from the oldest sample, so
// concurrent pollers of the page do not shrink the window.
type counterRates struct {
mu sync.Mutex
samples []counterSample
}
const rateHistory = 90 * time.Second
// perMinute records cur and returns the per-minute rates since the oldest retained sample.
func (c *counterRates) perMinute(cur counterSample) (pdus, timeouts, misses float64) {
c.mu.Lock()
defer c.mu.Unlock()
for len(c.samples) > 0 && cur.at.Sub(c.samples[0].at) > rateHistory {
c.samples = c.samples[1:]
}
defer func() { c.samples = append(c.samples, cur) }()
if len(c.samples) == 0 {
return 0, 0, 0
}
base := c.samples[0]
span := cur.at.Sub(base.at).Minutes()
if span <= 0 {
return 0, 0, 0
}
per := func(now, then float64) float64 { return max(now-then, 0) / span }
return per(cur.pdus, base.pdus), per(cur.timeouts, base.timeouts), per(cur.misses, base.misses)
}
func labelValue(ls []metrics.Label, name string) string {
for _, l := range ls {
if l.Name == name {
return l.Value
}
}
return ""
}
// tp is t in UTC, or nil for the zero time.
func tp(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
u := t.UTC()
return &u
}

View File

@@ -0,0 +1,180 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/ulid"
)
// device stores a network device and returns it with its generated slug.
func (e *env) device(t *testing.T, name, site, role string, mut ...func(*store.Device)) *store.Device {
t.Helper()
d := &store.Device{Name: name, Kind: store.KindNetwork, Site: site, Role: role, Enabled: true, Profile: "generic", MgmtIP: "192.0.2.1"}
for _, m := range mut {
m(d)
}
if err := e.st.CreateDevice(context.Background(), d); err != nil {
t.Fatal(err)
}
return d
}
// iface stores one interface of d and returns it.
func (e *env) iface(t *testing.T, d *store.Device, idx int64, name, alias string, mut ...func(*store.Interface)) store.Interface {
t.Helper()
i := store.Interface{
ID: ulid.New(e.clk.Now()), DeviceID: d.ID, IfIndex: idx, IfName: name, IfDescr: name, IfAlias: alias,
IfSpeed: 1_000_000_000, IfHighSpeed: 1000, AdminStatus: 1, OperStatus: 1, HasHCCounters: true,
FirstSeen: e.clk.Now(), LastDiscovered: e.clk.Now(),
}
for _, m := range mut {
m(&i)
}
if err := e.st.UpsertInterfaces(context.Background(), d.ID, []store.Interface{i}); err != nil {
t.Fatal(err)
}
return i
}
// getJSON GETs path and decodes a 200 body into v.
func (e *env) getJSON(t *testing.T, path string, v any) *httptest.ResponseRecorder {
t.Helper()
rec := e.do("GET", path, "")
if rec.Code != 200 {
t.Fatalf("GET %s: %d %s", path, rec.Code, rec.Body)
}
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
t.Fatalf("GET %s: %v: %s", path, err, rec.Body)
}
return rec
}
// vector builds an instant-query response from rows of (labels, value).
type vrow struct {
labels map[string]string
v float64
}
func vector(rows ...vrow) []byte {
var b bytes.Buffer
b.WriteString(`{"status":"success","data":{"resultType":"vector","result":[`)
for i, r := range rows {
if i > 0 {
b.WriteByte(',')
}
lb, _ := json.Marshal(r.labels)
fmt.Fprintf(&b, `{"metric":%s,"value":[1,"%v"]}`, lb, r.v)
}
b.WriteString(`]}}`)
return b.Bytes()
}
// matrix builds a range-query response with one series per entry of rows:
// labels and a map of unix second to value.
type mrow struct {
labels map[string]string
vals map[int64]float64
}
func matrix(rows ...mrow) []byte {
var b bytes.Buffer
b.WriteString(`{"status":"success","data":{"resultType":"matrix","result":[`)
for i, r := range rows {
if i > 0 {
b.WriteByte(',')
}
lb, _ := json.Marshal(r.labels)
fmt.Fprintf(&b, `{"metric":%s,"values":[`, lb)
first := true
for _, ts := range sortedKeys(r.vals) {
if !first {
b.WriteByte(',')
}
first = false
fmt.Fprintf(&b, `[%d,"%v"]`, ts, r.vals[ts])
}
b.WriteString(`]}`)
}
b.WriteString(`]}}`)
return b.Bytes()
}
func sortedKeys(m map[int64]float64) []int64 {
out := make([]int64, 0, len(m))
for k := range m {
out = append(out, k)
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// emptyMatrix is a range response with no series.
var emptyMatrix = []byte(`{"status":"success","data":{"resultType":"matrix","result":[]}}`)
// walkJSON calls fn for every key/value of every object in the JSON document.
func walkJSON(t *testing.T, body []byte, fn func(path, key string, val any)) {
t.Helper()
var doc any
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatalf("not JSON: %v: %s", err, body)
}
var rec func(path string, v any)
rec = func(path string, v any) {
switch x := v.(type) {
case map[string]any:
for k, c := range x {
fn(path, k, c)
rec(path+"."+k, c)
}
case []any:
for i, c := range x {
rec(fmt.Sprintf("%s[%d]", path, i), c)
}
}
}
rec("$", doc)
}
// assertNoLeaks fails when body has a numeric id, a credential field or a secret.
func assertNoLeaks(t *testing.T, what string, body []byte, secrets ...string) {
t.Helper()
walkJSON(t, body, func(path, key string, val any) {
lower := strings.ToLower(key)
if _, isNum := val.(float64); isNum && (lower == "id" || strings.HasSuffix(lower, "_id") || lower == "device_id") {
t.Errorf("%s: numeric id at %s.%s = %v", what, path, key, val)
}
for _, bad := range []string{"community", "password", "pass", "hash", "secret", "token", "credential"} {
if strings.Contains(lower, bad) {
t.Errorf("%s: credential-like key %s.%s", what, path, key)
}
}
})
for _, s := range secrets {
if bytes.Contains(body, []byte(s)) {
t.Errorf("%s: response contains secret %q", what, s)
}
}
}
var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
// doFrom is do with a chosen remote address, which the admission limits key on.
func (e *env) doFrom(method, path, remote string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
req.RemoteAddr = remote
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}

View File

@@ -0,0 +1,208 @@
//go:build integration && linux
package api_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/metrics"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/testutil"
"github.com/v10networks/nmon/internal/ulid"
"github.com/v10networks/nmon/internal/vmsup"
)
// TestIntegrationGraphFromRealStores writes synthetic octet counters whose rate
// rises, and hourly rollups for the weeks before, into real hot and long
// stores, then asks the API for a one-hour graph and a stitched 40-day graph.
func TestIntegrationGraphFromRealStores(t *testing.T) {
bin, err := filepath.Abs(filepath.Join("..", "vmsup", "bin", "victoria-metrics-prod-linux-"+runtime.GOARCH))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(bin); err != nil {
t.Skipf("store binary not found (%v); run scripts/fetch-vm.sh", err)
}
t.Setenv("NMON_VM_BINARY", bin)
t.Setenv("NMON_VM_BINARY_UNVERIFIED", "")
sup, err := vmsup.New(vmsup.Options{DataDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if err := sup.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sup.Stop(context.Background()) })
clk := clock.Real()
st := testutil.OpenStore(t, clk)
dev := &store.Device{Name: "Core", Kind: store.KindNetwork, Enabled: true, Profile: "generic"}
if err := st.CreateDevice(ctx, dev); err != nil {
t.Fatal(err)
}
ifc := store.Interface{ID: ulid.New(clk.Now()), DeviceID: dev.ID, IfIndex: 1, IfName: "xe-0/0/1", IfSpeed: 1e9, IfHighSpeed: 1000,
AdminStatus: 1, OperStatus: 1, HasHCCounters: true}
if err := st.UpsertInterfaces(ctx, dev.ID, []store.Interface{ifc}); err != nil {
t.Fatal(err)
}
// 3 h of one-minute samples ending 2 min ago. The in rate climbs from 1 to 4 Mbit/s; out is a flat 500 kbit/s.
end := time.Now().Add(-2 * time.Minute).Truncate(time.Minute)
const minutes = 3 * 60
var hot []metrics.Sample
var in, out float64
for i := 0; i <= minutes; i++ {
ts := end.Add(-time.Duration(minutes-i) * time.Minute)
inBps := 1e6 + 3e6*float64(i)/minutes
in += inBps / 8 * 60
out += 500e3 / 8 * 60
hot = append(hot,
metrics.New("ifHCInOctets", in, ts, "device", dev.Slug, "iface", ifc.ID),
metrics.New("ifHCOutOctets", out, ts, "device", dev.Slug, "iface", ifc.ID))
}
// Hourly rollups for 40 days back to 31 days ago, 800 kbit/s in and 200 out.
var long []metrics.Sample
for h := 40 * 24; h >= 31*24; h-- {
ts := time.Now().Add(-time.Duration(h) * time.Hour).Truncate(time.Hour)
long = append(long,
metrics.New("nmon_if_rate_bps_avg_1h", 800e3, ts, "device", dev.Slug, "iface", ifc.ID, "dir", "in", "v", "1"),
metrics.New("nmon_if_rate_bps_avg_1h", 200e3, ts, "device", dev.Slug, "iface", ifc.ID, "dir", "out", "v", "1"))
}
for name, ss := range map[string][]metrics.Sample{vmsup.StoreHot: hot, vmsup.StoreLong: long} {
q := vmsup.NewQueue(name, sup, vmsup.QueueOptions{MaxAge: 100 * 24 * time.Hour})
if err := q.Write(ctx, ss); err != nil {
t.Fatal(err)
}
if err := q.Flush(ctx); err != nil {
t.Fatal(err)
}
}
h := api.New(api.Deps{Store: st, Querier: vmsup.NewQuerier(sup), Poller: noRefresh{}, Clock: clk})
get := func(q url.Values) api.GraphResponse {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/graph?"+q.Encode(), nil))
if rec.Code != http.StatusOK {
t.Fatalf("graph: %d %s", rec.Code, rec.Body)
}
var g api.GraphResponse
if err := json.Unmarshal(rec.Body.Bytes(), &g); err != nil {
t.Fatal(err)
}
return g
}
// The store needs a moment to make flushed samples searchable.
var g api.GraphResponse
deadline := time.Now().Add(30 * time.Second)
for {
now := time.Now()
g = get(url.Values{"iface": {ifc.ID}, "from": {now.Add(-time.Hour).UTC().Format(time.RFC3339)}, "to": {now.UTC().Format(time.RFC3339)},
"max": {"true"}, "p95": {"true"}})
if countValues(g.Series.In) > 30 || time.Now().After(deadline) {
break
}
time.Sleep(500 * time.Millisecond)
}
if g.Tier != "raw" || g.StitchedAt != nil || g.StepSeconds != 60 {
t.Fatalf("1h graph: tier=%s step=%d stitched=%v", g.Tier, g.StepSeconds, g.StitchedAt)
}
first, last := firstLast(g.Series.In)
if first == nil || last == nil || *last <= *first*1.1 || *first < 2.5e6 || *last > 4.2e6 {
t.Errorf("in series should rise from about 2.5 to 4 Mbit/s: first %v last %v", deref(first), deref(last))
}
if f, l := firstLast(g.Series.Out); f == nil || *f < 450e3 || *f > 550e3 || *l < 450e3 || *l > 550e3 {
t.Errorf("out series should be a flat 500 kbit/s: %v %v", deref(f), deref(l))
}
if countValues(g.Series.InMax) < 30 {
t.Errorf("max series: %d values", countValues(g.Series.InMax))
}
if g.P95 == nil || g.P95.InBPS == nil || *g.P95.InBPS < 2.5e6 || g.P95.Source != "live" || g.P95.Coverage <= 0 {
t.Errorf("p95: %+v", g.P95)
}
now := time.Now()
g = get(url.Values{"iface": {ifc.ID}, "from": {now.Add(-40 * 24 * time.Hour).UTC().Format(time.RFC3339)}, "to": {now.UTC().Format(time.RFC3339)}})
if g.StitchedAt == nil || len(g.Tiers) != 2 || g.Tiers[0].Tier != "1h" {
t.Fatalf("40d graph should be stitched: %+v", g.Tiers)
}
var fromLong int
for _, p := range g.Series.In {
if p.TS < g.StitchedAt.Unix() && p.V != nil {
fromLong++
if *p.V != 800e3 {
t.Fatalf("long sample %d = %v, want 800000", p.TS, *p.V)
}
}
}
if fromLong < 150 {
t.Errorf("only %d hourly samples came from the long store", fromLong)
}
if len(g.Partial) == 0 {
t.Error("hot has 3 h of data in a 30-day window: the rest is partial")
}
// A year needs a coarser step: the store must accept the averaging subquery and average equal hours to the same value.
g = get(url.Values{"iface": {ifc.ID}, "from": {now.Add(-365 * 24 * time.Hour).UTC().Format(time.RFC3339)}, "to": {now.UTC().Format(time.RFC3339)}})
if g.StitchedAt == nil || g.Tiers[0].StepSeconds <= 3600 {
t.Fatalf("1y tiers: %+v", g.Tiers)
}
var coarse int
for _, p := range g.Series.In {
if p.TS < g.StitchedAt.Unix() && p.V != nil {
coarse++
if *p.V < 799e3 || *p.V > 801e3 {
t.Errorf("coarse long sample %d = %v, want 800000", p.TS, *p.V)
}
}
}
if coarse == 0 {
t.Error("the 1y graph has no long-store samples")
}
}
type noRefresh struct{}
func (noRefresh) Refresh() {}
func countValues(ps []api.Point) int {
n := 0
for _, p := range ps {
if p.V != nil {
n++
}
}
return n
}
func firstLast(ps []api.Point) (first, last *float64) {
for _, p := range ps {
if p.V == nil {
continue
}
if first == nil {
first = p.V
}
last = p.V
}
return first, last
}
func deref(p *float64) float64 {
if p == nil {
return -1
}
return *p
}

Some files were not shown because too many files have changed in this diff Show More