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
156 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|