Files
nmon/internal/poller/debug.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

140 lines
4.3 KiB
Go

package poller
import (
"sync"
"time"
"github.com/v10networks/nmon/internal/snmp"
)
// Debug limits.
const (
// MaxDebugMinutes is the longest a device's SNMP debug recording may be switched on for.
MaxDebugMinutes = 60
// DebugCycles is how many recorded cycles a device keeps; the oldest is dropped first.
DebugCycles = 20
// debugRowCap bounds the per-OID rows of one recorded cycle (failures first), so the ring stays small.
debugRowCap = 500
)
// DebugState is one device's SNMP debug recording.
type DebugState struct {
Enabled bool `json:"enabled"`
Until *time.Time `json:"until,omitempty" doc:"When recording stops by itself."`
Target string `json:"target,omitempty" doc:"Address and port polled, as of the latest recorded cycle."`
SNMPVersion string `json:"snmp_version,omitempty"`
Profile string `json:"profile,omitempty"`
MaxCycles int `json:"max_cycles"`
Cycles []GroupTrace `json:"cycles" doc:"Recorded cycles, newest first. In memory only: gone on restart."`
}
type debugRing struct {
until time.Time
meta DebugState
cycles []GroupTrace // oldest first
}
// debugStore holds the recordings by device ID. It is in memory only, by design.
type debugStore struct {
mu sync.Mutex
m map[int64]*debugRing
}
func newDebugStore() *debugStore { return &debugStore{m: map[int64]*debugRing{}} }
// set switches recording on until the given time, dropping an earlier recording, or off when until is zero (the
// recording so far stays readable).
func (d *debugStore) set(id int64, until time.Time) {
d.mu.Lock()
defer d.mu.Unlock()
r := d.m[id]
switch {
case until.IsZero() && r != nil:
r.until = time.Time{}
case !until.IsZero():
d.m[id] = &debugRing{until: until}
}
}
func (d *debugStore) active(id int64, now time.Time) bool {
d.mu.Lock()
defer d.mu.Unlock()
r := d.m[id]
return r != nil && now.Before(r.until)
}
func (d *debugStore) add(id int64, meta DebugState, c GroupTrace) {
d.mu.Lock()
defer d.mu.Unlock()
r := d.m[id]
if r == nil {
return
}
r.meta = meta
r.cycles = append(r.cycles, c)
if len(r.cycles) > DebugCycles {
r.cycles = append([]GroupTrace(nil), r.cycles[len(r.cycles)-DebugCycles:]...)
}
}
func (d *debugStore) drop(id int64) {
d.mu.Lock()
delete(d.m, id)
d.mu.Unlock()
}
func (d *debugStore) snapshot(id int64, now time.Time) DebugState {
d.mu.Lock()
defer d.mu.Unlock()
out := DebugState{MaxCycles: DebugCycles, Cycles: []GroupTrace{}}
r := d.m[id]
if r == nil {
return out
}
out.Target, out.SNMPVersion, out.Profile = r.meta.Target, r.meta.SNMPVersion, r.meta.Profile
if now.Before(r.until) {
u := r.until
out.Enabled, out.Until = true, &u
}
for i := len(r.cycles) - 1; i >= 0; i-- {
out.Cycles = append(out.Cycles, r.cycles[i])
}
return out
}
// SetDebug records every scheduled cycle of device id for the next minutes (0 turns it off, more than
// MaxDebugMinutes is capped) and returns the state. Recordings live in memory only.
func (p *Poller) SetDebug(id int64, minutes int) DebugState {
minutes = max(0, min(minutes, MaxDebugMinutes))
var until time.Time
if minutes > 0 {
until = p.clk.Now().Add(time.Duration(minutes) * time.Minute)
}
p.dbg.set(id, until)
return p.dbg.snapshot(id, p.clk.Now())
}
// DebugState returns device id's recording state and recorded cycles.
func (p *Poller) DebugState(id int64) DebugState { return p.dbg.snapshot(id, p.clk.Now()) }
// debugRecorder returns a recorder when device debug is on, else nil.
func (l *devLoop) debugRecorder(t *target) *traceRec {
if !l.p.dbg.active(l.id, l.p.clk.Now()) {
return nil
}
return newTraceRec(l.p.clk, t.cred)
}
// recordDebug files a finished fetch in the device's ring and logs a one-line summary. rec nil: not recording.
func (l *devLoop) recordDebug(rec *traceRec, t *target, g *group, vbs []snmp.Varbind, got []bool, err error) {
if rec == nil {
return
}
gt := rec.groupTrace(g, vbs, got, err, debugRowCap)
meta := DebugState{Target: t.addr.String(), SNMPVersion: trimV(t.cred.Version), Profile: t.profile}
l.p.dbg.add(l.id, meta, gt)
l.p.log.Info("snmp debug cycle", "device", l.slug, "group_s", gt.IntervalSeconds, "requests", len(gt.Requests),
"requested", gt.Requested, "returned", gt.Returned, "missing", gt.Missing, "skipped", gt.Skipped,
"duration_ms", gt.DurationMS, "err", gt.Error)
}