Files
nmon/internal/api/reach.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

144 lines
4.1 KiB
Go

package api
import (
"context"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/store"
)
// Reachability values shown in lists and on device pages.
const (
ReachUp = "up"
ReachDown = "down"
ReachWarning = "warning"
ReachUnknown = "unknown"
ReachMaintenance = "maintenance"
)
// ReachabilitySource decides each device's reachability. The result is keyed by
// device slug and holds one of the Reach* values. M4 replaces the default with
// the alerter's state (dependencies, maintenance windows, suppression).
type ReachabilitySource interface {
Reachability(ctx context.Context, devs []store.Device) map[string]string
}
// persistedReachability derives reachability from what is stored right now:
// the ICMP and SNMP up series in the hot store, else the device's last_seen
// age against the poll interval. It knows nothing of dependencies.
type persistedReachability struct {
q Querier
clk clock.Clock
cadence *store.PollCadence
interval time.Duration
}
// upLookback is how stale an up series may be and still count.
// It must outlast two default SNMP polls, or nmon_snmp_up would drop out between polls.
const upLookback = "11m"
// maxScoped is the most devices a query names explicitly; beyond it all devices are fetched.
const maxScoped = 200
func (p *persistedReachability) Reachability(ctx context.Context, devs []store.Device) map[string]string {
icmp, snmp := p.upSeries(ctx, devs)
now := p.clk.Now()
out := make(map[string]string, len(devs))
for i := range devs {
d := &devs[i]
out[d.Slug] = reachabilityOf(d, icmp[d.Slug], snmp[d.Slug], now, d.PollInterval(p.cadence.Or(p.interval)))
}
return out
}
// upSeries returns the last nmon_icmp_up and nmon_snmp_up per device slug. A
// store that cannot be queried yields empty maps, and the caller falls back to last_seen.
func (p *persistedReachability) upSeries(ctx context.Context, devs []store.Device) (icmp, snmp map[string]*float64) {
match := ""
if len(devs) > 0 && len(devs) <= maxScoped {
slugs := make([]string, len(devs))
for i, d := range devs {
slugs[i] = d.Slug
}
match = `device=~` + quote(regexAlt(slugs))
}
get := func(metric string) map[string]*float64 {
body, err := p.q.Query(ctx, "hot", `last_over_time(`+metric+`{`+match+`}[`+upLookback+`])`, p.clk.Now())
if err != nil {
return nil
}
rows, err := parseVM(body)
if err != nil {
return nil
}
m := make(map[string]*float64, len(rows))
for _, r := range rows {
if r.Value != nil && r.Value.OK {
v := r.Value.V
m[r.Metric["device"]] = &v
}
}
return m
}
return get("nmon_icmp_up"), get("nmon_snmp_up")
}
// reachabilityOf is the decision for one device. icmp and snmp are the last up
// values (1 or 0) or nil when the store has none.
func reachabilityOf(d *store.Device, icmp, snmp *float64, now time.Time, interval time.Duration) string {
switch {
case d.RetiredAt != nil || !d.Enabled:
return ReachUnknown
case d.PollState == store.PollMaintenance:
return ReachMaintenance
}
var probes []bool
for _, v := range []*float64{icmp, snmp} {
if v != nil {
probes = append(probes, *v >= 1)
}
}
if len(probes) == 0 {
return staleness(d, now, interval)
}
up := 0
for _, ok := range probes {
if ok {
up++
}
}
switch {
case up == 0:
return ReachDown
case up < len(probes), d.PollState == store.PollDegraded, d.PollState == store.PollBackoff:
return ReachWarning
}
return ReachUp
}
// staleness judges a device with no up series by when it last answered: not
// heard from in three poll intervals is down.
func staleness(d *store.Device, now time.Time, interval time.Duration) string {
if d.LastSeen == nil {
return ReachUnknown
}
if now.Sub(*d.LastSeen) > 3*interval {
return ReachDown
}
if d.PollState == store.PollDegraded || d.PollState == store.PollBackoff {
return ReachWarning
}
return ReachUp
}
// pollStateOf maps the stored state to the API enum (ok, degraded, backoff);
// maintenance is a reachability, not a poll state.
func pollStateOf(d *store.Device) string {
switch d.PollState {
case store.PollDegraded, store.PollBackoff:
return d.PollState
}
return store.PollOK
}