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

250 lines
7.8 KiB
Go

package api
import (
"fmt"
"maps"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/v10networks/nmon/internal/checks"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/notify"
"github.com/v10networks/nmon/internal/proxmox"
)
// fieldError is a form value that cannot even be turned into configuration (an unparsable duration); Loc is
// the JSON path of the field.
type fieldError struct{ Loc, Msg string }
func (e *fieldError) Error() string { return e.Loc + ": " + e.Msg }
// parseDur reads a form duration; empty is unset (zero).
func parseDur(loc, s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}
d, err := config.ParseDuration(s)
if err != nil {
return 0, &fieldError{Loc: loc, Msg: fmt.Sprintf("%q is not a duration (examples: 30d, 5m, 1h30m)", s)}
}
return d, nil
}
// durs parses several durations in order, stopping at the first bad one.
type durs struct{ err error }
func (p *durs) get(loc, s string) time.Duration {
if p.err != nil {
return 0
}
d, err := parseDur(loc, s)
p.err = err
return d
}
// applyFormPatch replaces the sections present in p inside c. Validation of the result is the caller's job
// and is the same as for a YAML document.
func applyFormPatch(c *config.Config, p SettingsFormPatch) error {
steps := []func(*config.Config, SettingsFormPatch) error{
patchRetention, patchGeneral, patchSites, patchAlerting, patchReceivers, patchAuth, patchBackupDisk,
patchL2OUI, patchProxmox, patchChecks,
}
for _, step := range steps {
if err := step(c, p); err != nil {
return err
}
}
return nil
}
func patchRetention(c *config.Config, p SettingsFormPatch) error {
if p.Retention == nil {
return nil
}
var d durs
r := config.Retention{Hot: config.Duration(d.get("retention.hot", p.Retention.Hot)),
FiveMinute: config.Duration(d.get("retention.fiveminute", p.Retention.FiveMinute)),
Long: config.Duration(d.get("retention.long", p.Retention.Long))}
c.Retention = r
return d.err
}
func patchGeneral(c *config.Config, p SettingsFormPatch) error {
if p.Timezone != nil {
c.Timezone = strings.TrimSpace(*p.Timezone)
}
var d durs
if p.RetiredPurgeAfter != nil {
c.RetiredPurgeAfter = config.Duration(d.get("retired_purge_after", *p.RetiredPurgeAfter))
}
if p.PollerInterval != nil {
c.Poller.Interval = config.Duration(d.get("poller.interval", *p.PollerInterval))
}
return d.err
}
func patchSites(c *config.Config, p SettingsFormPatch) error {
if p.Sites == nil {
return nil
}
sites := make(map[string]config.Site, len(*p.Sites))
for i, s := range *p.Sites {
name := strings.TrimSpace(s.Name)
if _, dup := sites[name]; name == "" || dup {
return &fieldError{Loc: fmt.Sprintf("sites[%d].name", i), Msg: "site names must be non-empty and unique"}
}
sites[name] = config.Site{Timezone: strings.TrimSpace(s.Timezone)}
}
if len(sites) == 0 {
sites = nil
}
c.Sites = sites
return nil
}
func patchAlerting(c *config.Config, p SettingsFormPatch) error {
if p.Alerting == nil {
return nil
}
var d durs
c.Alerting.EvaluationInterval = config.Duration(d.get("alerting.evaluation_interval", p.Alerting.EvaluationInterval))
c.Alerting.OutboundAllow = cleanList(p.Alerting.OutboundAllow)
return d.err
}
// cleanList trims entries and drops blanks; an empty list is nil so it is omitted from config.yaml.
func cleanList(in []string) []string {
var out []string
for _, s := range in {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
// storedReceivers are the receivers now in the configuration, by name, so that write-only credentials the form
// leaves blank can be carried over.
func storedReceivers(a config.Alerting) map[string]notify.Receiver {
out := make(map[string]notify.Receiver, len(a.Receivers))
for i := range a.Receivers {
var r notify.Receiver
if err := a.Receivers[i].Decode(&r); err == nil {
out[r.Name] = r
}
}
return out
}
// receiverFrom builds a receiver from its form, keeping the stored credentials the form left blank.
func receiverFrom(f ReceiverForm, old notify.Receiver) notify.Receiver {
headers := maps.Clone(f.Headers)
for k, v := range headers {
if stored, ok := old.Headers[k]; ok && v == "" {
headers[k] = stored
}
}
password := f.SMTP.Password
if password == "" && !f.SMTP.ClearPassword {
password = old.SMTP.Password
}
return notify.Receiver{Name: strings.TrimSpace(f.Name), Kind: f.Kind, URL: strings.TrimSpace(f.URL),
Headers: headers, Template: f.Template,
SMTP: notify.SMTP{Host: strings.TrimSpace(f.SMTP.Host), Port: f.SMTP.Port, From: strings.TrimSpace(f.SMTP.From),
To: cleanList(f.SMTP.To), Username: f.SMTP.Username, Password: password, StartTLS: f.SMTP.StartTLS}}
}
func patchReceivers(c *config.Config, p SettingsFormPatch) error {
if p.Receivers == nil {
return nil
}
stored := storedReceivers(c.Alerting)
var nodes []yaml.Node
for i, f := range *p.Receivers {
var n yaml.Node
if err := n.Encode(receiverFrom(f, stored[strings.TrimSpace(f.Name)])); err != nil {
return &fieldError{Loc: fmt.Sprintf("receivers[%d]", i), Msg: err.Error()}
}
nodes = append(nodes, n)
}
c.Alerting.Receivers = nodes
return nil
}
func patchAuth(c *config.Config, p SettingsFormPatch) error {
if p.Auth == nil {
return nil
}
f := p.Auth
var d durs
a := config.Auth{BreakGlassFile: strings.TrimSpace(f.BreakGlassFile), BreakGlassUser: strings.TrimSpace(f.BreakGlassUser),
TrustedProxies: cleanList(f.TrustedProxies),
Session: config.Session{Idle: config.Duration(d.get("auth.session.idle", f.Session.Idle)),
Absolute: config.Duration(d.get("auth.session.absolute", f.Session.Absolute))}}
if o := f.OIDC; o != nil {
a.OIDC = &config.OIDC{Issuer: strings.TrimSpace(o.Issuer), ClientID: strings.TrimSpace(o.ClientID),
ClientSecretName: strings.TrimSpace(o.ClientSecretName), RedirectURL: strings.TrimSpace(o.RedirectURL),
GroupsClaim: strings.TrimSpace(o.GroupsClaim), AdminGroup: strings.TrimSpace(o.AdminGroup)}
}
c.Auth = a
return d.err
}
func patchBackupDisk(c *config.Config, p SettingsFormPatch) error {
if b := p.Backup; b != nil {
c.Backup = config.Backup{At: strings.TrimSpace(b.At), Dir: strings.TrimSpace(b.Dir), Keep: b.Keep}
}
if d := p.Disk; d != nil {
c.Disk = config.Disk{AlertUsed: d.AlertUsed, AdmitUsed: d.AdmitUsed, ReserveFree: d.ReserveFree}
}
return nil
}
func patchL2OUI(c *config.Config, p SettingsFormPatch) error {
var d durs
if l := p.L2; l != nil {
c.L2 = config.L2{Interval: config.Duration(d.get("l2.interval", l.Interval)), Keep: config.Duration(d.get("l2.keep", l.Keep)),
EdgeMaxMACs: l.EdgeMaxMACs, Concurrency: l.Concurrency,
DeviceTimeout: config.Duration(d.get("l2.device_timeout", l.DeviceTimeout))}
}
if o := p.OUI; o != nil {
oui := config.OUI{Refresh: config.Duration(d.get("oui.refresh", o.Refresh))}
if !o.Fetch { // fetch defaults to on, so only "off" is written
off := false
oui.Fetch = &off
}
c.OUI = oui
}
return d.err
}
func patchProxmox(c *config.Config, p SettingsFormPatch) error {
f := p.Proxmox
if f == nil {
return nil
}
var d durs
pc := proxmox.Config{Interval: d.get("proxmox.interval", f.Interval), Timeout: d.get("proxmox.timeout", f.Timeout),
RetireGrace: d.get("proxmox.retire_grace", f.RetireGrace), Workers: f.Workers}
for _, cl := range f.Clusters {
pc.Clusters = append(pc.Clusters, proxmox.ClusterConfig{Name: strings.TrimSpace(cl.Name), URL: strings.TrimSpace(cl.URL),
TokenID: strings.TrimSpace(cl.TokenID), SecretName: strings.TrimSpace(cl.SecretName), CAFile: strings.TrimSpace(cl.CAFile),
InsecureSkipVerify: cl.InsecureSkipVerify})
}
c.Proxmox = pc
return d.err
}
func patchChecks(c *config.Config, p SettingsFormPatch) error {
f := p.Checks
if f == nil {
return nil
}
var d durs
c.Checks = checks.Config{Workers: f.Workers, Timeout: d.get("checks.timeout", f.Timeout), Tick: d.get("checks.tick", f.Tick)}
return d.err
}