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

141 lines
3.9 KiB
Go

package poller
import (
"context"
"errors"
"sync"
"sync/atomic"
"time"
"github.com/v10networks/nmon/internal/reach"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
)
var errDeadline = errors.New("poller: cycle deadline")
// devLoop is the pair of goroutines serving one device.
type devLoop struct {
p *Poller
id int64
slug string
cancel context.CancelFunc
tgt atomic.Pointer[target]
wake chan struct{} // the target changed
kick chan struct{} // poll the primary group now
duration selfmon.Gauge
missed, skipped, mismatch, reboots, discontins selfmon.Counter
mu sync.Mutex // guards st
st devState
// Touched only by the SNMP goroutine.
snmpUp *bool // last state logged
persisted string
up uptimeTracker
disc map[string]int64 // last ifCounterDiscontinuityTime (ticks) by interface ID
supply map[string]float64 // last printer supply percent by component
}
func (l *devLoop) setTarget(t *target) {
l.tgt.Store(t)
select {
case l.wake <- struct{}{}:
default:
}
}
func (l *devLoop) status() DeviceStatus {
t := l.tgt.Load()
l.mu.Lock()
defer l.mu.Unlock()
s := DeviceStatus{
Device: l.slug, DeviceID: l.id, State: l.st.state, Varbinds: l.st.varbinds,
ConsecutiveFailures: l.st.consecFail, MissedDeadlines: l.st.consecMissed,
LastCycleDuration: l.st.lastDur, LastCycle: l.st.lastCycle, NextCycle: l.st.next, LastError: l.st.lastErr,
}
if t != nil {
s.Profile = t.profile
s.Interfaces = t.ifaces
if len(t.groups) > 0 {
s.BaseInterval = t.groups[0].base
s.Interval = l.st.interval(s.BaseInterval)
}
}
return s
}
func (l *devLoop) varbinds() int {
l.mu.Lock()
defer l.mu.Unlock()
return l.st.varbinds
}
// halve shrinks the device's varbind count after a request it could not carry, and returns the new count.
func (l *devLoop) halve() int {
l.mu.Lock()
defer l.mu.Unlock()
return l.st.halve()
}
func (l *devLoop) effInterval(g *group) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
return l.st.interval(g.base)
}
func (l *devLoop) failing() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.st.consecFail > 0
}
// persistState stores a change of poll state, keeping the device's warnings and
// leaving a device in maintenance alone.
func (l *devLoop) persistState(ctx context.Context, want string) {
if want == l.persisted {
return
}
d, err := l.p.o.Store.GetDeviceByID(ctx, l.id)
if err != nil {
l.p.log.Warn("read poll state", "device", l.slug, "err", err)
return
}
seen := want
if d.PollState == store.PollMaintenance {
seen = reach.PollMaintenance
}
l.tell(func(o reach.Observer, at time.Time) { o.ObservePoll(l.slug, seen, at) })
if d.PollState != store.PollMaintenance && d.PollState != want {
if err := l.p.o.Store.SetPollState(ctx, l.id, want, []string(d.Warnings)); err != nil {
l.p.log.Warn("persist poll state", "device", l.slug, "state", want, "err", err)
return
}
l.p.log.Info("poll state", "device", l.slug, "state", want)
}
l.persisted = want
}
func (l *devLoop) rebooted(ctx context.Context, ev rebootEvent) {
l.reboots.Inc()
l.p.log.Warn("device rebooted", "device", l.slug, "uptime_before_s", ev.before, "uptime_after_s", ev.after)
err := l.p.o.Store.AppendAudit(ctx, "poller", "device_rebooted", l.slug,
map[string]any{"uptime_seconds": ev.before}, map[string]any{"uptime_seconds": ev.after})
if err != nil {
l.p.log.Warn("audit reboot", "device", l.slug, "err", err)
}
}
func (l *devLoop) discontinuity(ctx context.Context, t *target, ev discEvent) {
if !ev.first {
l.discontins.Inc()
l.p.log.Info("counter discontinuity", "device", l.slug, "iface", ev.ifID, "ticks", ev.ticks)
}
if cs, ok := l.p.o.Store.(store.CounterDiscontinuities); ok {
if err := cs.SetCounterDiscontinuity(ctx, ev.ifID, ev.ticks); err != nil {
l.p.log.Warn("record counter discontinuity", "device", l.slug, "iface", ev.ifID, "err", err)
}
}
}