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

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

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

156 lines
6.7 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/store"
)
// PollTools is what the poll test, poll-now and SNMP debug routes need from the poller. *poller.Poller implements it.
type PollTools interface {
PollNow(id int64) bool
TestPoll(ctx context.Context, id int64, group time.Duration) (*poller.Trace, error)
SetDebug(id int64, minutes int) poller.DebugState
DebugState(id int64) poller.DebugState
}
// PollNowView answers POST /devices/{slug}/poll-now.
type PollNowView struct {
Queued bool `json:"queued" doc:"The device's primary group will be polled right away; its result shows in the normal places."`
}
// SNMPDebugBody is the body of PUT /devices/{slug}/snmp-debug.
type SNMPDebugBody struct {
Minutes int `json:"minutes" minimum:"0" maximum:"60" doc:"Minutes to record scheduled cycles for; 0 turns recording off."`
}
func (s *server) registerPollTools(api huma.API) {
tags := []string{"devices"}
huma.Register(api, huma.Operation{
OperationID: "poll-device-now", Method: http.MethodPost, Path: "/api/v1/devices/{slug}/poll-now", Tags: tags,
Summary: "Poll a device now", DefaultStatus: http.StatusAccepted,
Description: "Asks the poller to run the device's primary group right away, outside its schedule. The result is recorded " +
"like any poll. Admin-only.",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable},
}, s.pollNow)
huma.Register(api, huma.Operation{
OperationID: "test-poll-device", Method: http.MethodPost, Path: "/api/v1/devices/{slug}/poll-test", Tags: tags,
Summary: "Test-poll a device with a trace",
Description: "Runs one synchronous SNMP cycle of the device's real poll plan (stored credential, resolved profile, address) " +
"and returns a trace: per-request timings and errors, and every requested OID with its result. Nothing is written to " +
"the metric store and no poller state changes. One test per device at a time (409), a few at once overall (429), " +
"60 s at most, 5,000 OID rows at most. Credentials are never returned. Admin-only.",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity, http.StatusTooManyRequests,
http.StatusServiceUnavailable},
}, s.testPoll)
huma.Register(api, huma.Operation{
OperationID: "get-snmp-debug", Method: http.MethodGet, Path: "/api/v1/devices/{slug}/snmp-debug", Tags: tags,
Summary: "Read a device's SNMP debug recording",
Description: "Whether recording is on and the last cycles recorded (20 at most, newest first). In memory only: cleared " +
"on restart. Admin-only.",
Errors: []int{http.StatusNotFound, http.StatusServiceUnavailable},
}, s.getSNMPDebug)
huma.Register(api, huma.Operation{
OperationID: "set-snmp-debug", Method: http.MethodPut, Path: "/api/v1/devices/{slug}/snmp-debug", Tags: tags,
Summary: "Switch a device's SNMP debug recording on or off",
Description: "While on, the poller records each scheduled cycle of the device (per-request timings and errors, per-OID " +
"results) and logs a one-line summary at INFO. It stops by itself after the given minutes (60 at most). Starting " +
"a recording drops the previous one. Admin-only.",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable},
}, s.setSNMPDebug)
}
type slugIn struct {
Slug string `path:"slug" doc:"Device slug."`
}
// pollableDevice loads the device and refuses one that is gone, retired or has no SNMP credential.
func (s *server) pollableDevice(ctx context.Context, slug string) (*store.Device, error) {
if s.PollTools == nil {
return nil, errNotConfigured("the poller")
}
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")
}
if d.RetiredAt != nil || d.Kind != store.KindNetwork || d.SNMPCredentialID == nil {
return nil, huma.Error409Conflict("device is retired or has no SNMP credential; it is not polled over SNMP")
}
return d, nil
}
func (s *server) pollNow(ctx context.Context, in *slugIn) (*struct{ Body PollNowView }, error) {
d, err := s.pollableDevice(ctx, in.Slug)
if err != nil {
return nil, err
}
if !d.Enabled || !s.PollTools.PollNow(d.ID) {
return nil, huma.Error409Conflict("device is not being polled (disabled, or the poller has not loaded it yet)")
}
s.auditPoll(ctx, "device.poll_now", d.Slug, nil)
return &struct{ Body PollNowView }{PollNowView{Queued: true}}, nil
}
func (s *server) testPoll(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
Interval int `query:"interval_seconds" minimum:"0" doc:"Test only the group with this cadence in seconds; 0 tests every group."`
}) (*struct{ Body *poller.Trace }, error) {
d, err := s.pollableDevice(ctx, in.Slug)
if err != nil {
return nil, err
}
tr, err := s.PollTools.TestPoll(ctx, d.ID, time.Duration(in.Interval)*time.Second)
switch {
case errors.Is(err, poller.ErrTestBusy):
return nil, huma.Error409Conflict("a test poll of this device is already running")
case errors.Is(err, poller.ErrTestLimit):
return nil, huma.Error429TooManyRequests("too many test polls are running; try again shortly")
case errors.Is(err, poller.ErrNotPolled), errors.Is(err, poller.ErrNoSuchPoll):
return nil, huma.Error422UnprocessableEntity(err.Error())
case err != nil:
return nil, s.storeErr(err)
}
s.auditPoll(ctx, "device.poll_test", d.Slug, map[string]any{
"interval_seconds": in.Interval, "requested": tr.Requested, "returned": tr.Returned, "missing": tr.Missing,
"duration_ms": tr.DurationMS,
})
return &struct{ Body *poller.Trace }{tr}, nil
}
func (s *server) getSNMPDebug(ctx context.Context, in *slugIn) (*struct{ Body poller.DebugState }, error) {
d, err := s.pollableDevice(ctx, in.Slug)
if err != nil {
return nil, err
}
return &struct{ Body poller.DebugState }{s.PollTools.DebugState(d.ID)}, nil
}
func (s *server) setSNMPDebug(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
Body SNMPDebugBody
}) (*struct{ Body poller.DebugState }, error) {
d, err := s.pollableDevice(ctx, in.Slug)
if err != nil {
return nil, err
}
st := s.PollTools.SetDebug(d.ID, in.Body.Minutes)
s.auditPoll(ctx, "device.snmp_debug", d.Slug, map[string]any{"minutes": in.Body.Minutes})
return &struct{ Body poller.DebugState }{st}, nil
}
// auditPoll records an operator action on a device's polling. A failed audit write is logged, not fatal: the action ran.
func (s *server) auditPoll(ctx context.Context, action, slug string, detail map[string]any) {
if err := s.Store.AppendAudit(ctx, actorOf(ctx), action, slug, nil, detail); err != nil {
s.Logger.Warn("audit "+action, "device", slug, "err", err)
}
}