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

146 lines
6.0 KiB
Go

package api
import (
"fmt"
"slices"
"strings"
"time"
"github.com/v10networks/nmon/internal/auth"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/diskguard"
"github.com/v10networks/nmon/internal/notify"
"github.com/v10networks/nmon/internal/proxmox"
"github.com/v10networks/nmon/internal/store"
)
// durString renders a duration for the form; zero is "unset" and shows as empty.
func durString(d time.Duration) string {
if d == 0 {
return ""
}
s := config.FormatDuration(d)
// Go prints 15m as "15m0s" and 1h as "1h0m0s"; the form shows the short spelling.
if strings.HasSuffix(s, "m0s") {
s = strings.TrimSuffix(s, "0s")
}
if strings.HasSuffix(s, "h0m") {
s = strings.TrimSuffix(s, "0m")
}
return s
}
// formView converts the configuration into the typed form.
func formView(c config.Config) (SettingsFormView, error) {
recv, err := receiverViews(c.Alerting)
if err != nil {
return SettingsFormView{}, err
}
v := SettingsFormView{
Retention: RetentionForm{Hot: durString(c.Retention.Hot.D()), FiveMinute: durString(c.Retention.FiveMinute.D()),
Long: durString(c.Retention.Long.D())},
Timezone: c.Timezone,
RetiredPurgeAfter: durString(c.RetiredPurgeAfter.D()),
PollerInterval: durString(c.Poller.Interval.D()),
Sites: siteForms(c.Sites),
Alerting: AlertingForm{OutboundAllow: nonNil(c.Alerting.OutboundAllow),
EvaluationInterval: durString(c.Alerting.EvaluationInterval.D())},
Receivers: recv,
Auth: authForm(c.Auth),
Backup: BackupForm{At: c.Backup.At, Dir: c.Backup.Dir, Keep: c.Backup.Keep},
Disk: DiskForm{AlertUsed: c.Disk.AlertUsed, AdmitUsed: c.Disk.AdmitUsed, ReserveFree: c.Disk.ReserveFree},
L2: L2Form{Interval: durString(c.L2.Interval.D()), Keep: durString(c.L2.Keep.D()), EdgeMaxMACs: c.L2.EdgeMaxMACs,
Concurrency: c.L2.Concurrency, DeviceTimeout: durString(c.L2.DeviceTimeout.D())},
OUI: OUIForm{Fetch: c.OUI.FetchEnabled(), Refresh: durString(c.OUI.Refresh.D())},
Proxmox: proxmoxForm(c.Proxmox),
Checks: ChecksForm{Workers: c.Checks.Workers, Timeout: durString(c.Checks.Timeout), Tick: durString(c.Checks.Tick)},
}
return v, nil
}
func siteForms(sites map[string]config.Site) []SiteForm {
out := make([]SiteForm, 0, len(sites))
for name, s := range sites {
out = append(out, SiteForm{Name: name, Timezone: s.Timezone})
}
slices.SortFunc(out, func(a, b SiteForm) int { return compareStrings(a.Name, b.Name) })
return out
}
func compareStrings(a, b string) int {
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
func receiverViews(a config.Alerting) ([]ReceiverView, error) {
out := make([]ReceiverView, 0, len(a.Receivers))
for i := range a.Receivers {
var r notify.Receiver
if err := a.Receivers[i].Decode(&r); err != nil {
return nil, fmt.Errorf("alerting.receivers[%d]: %w", i, err)
}
headers := make(map[string]string, len(r.Headers)) // names only: the values may be credentials
for k := range r.Headers {
headers[k] = ""
}
out = append(out, ReceiverView{Name: r.Name, Kind: r.Kind, URL: r.URL, Headers: headers, Template: r.Template,
SMTP: SMTPView{Host: r.SMTP.Host, Port: r.SMTP.Port, From: r.SMTP.From, To: nonNil(r.SMTP.To),
Username: r.SMTP.Username, LoginStored: r.SMTP.Password != "", StartTLS: r.SMTP.StartTLS}})
}
return out, nil
}
func authForm(a config.Auth) AuthForm {
f := AuthForm{BreakGlassFile: a.BreakGlassFile, BreakGlassUser: a.BreakGlassUser, TrustedProxies: nonNil(a.TrustedProxies),
Session: SessionForm{Idle: durString(a.Session.Idle.D()), Absolute: durString(a.Session.Absolute.D())}}
if o := a.OIDC; o != nil {
f.OIDC = &OIDCForm{Issuer: o.Issuer, ClientID: o.ClientID, ClientSecretName: o.ClientSecretName, RedirectURL: o.RedirectURL,
GroupsClaim: o.GroupsClaim, AdminGroup: o.AdminGroup}
}
return f
}
func proxmoxForm(p proxmox.Config) ProxmoxForm {
clusters := make([]ProxmoxClusterForm, 0, len(p.Clusters))
for _, c := range p.Clusters {
clusters = append(clusters, ProxmoxClusterForm{Name: c.Name, URL: c.URL, TokenID: c.TokenID, SecretName: c.SecretName,
CAFile: c.CAFile, InsecureSkipVerify: c.InsecureSkipVerify})
}
return ProxmoxForm{Clusters: clusters, Interval: durString(p.Interval), Timeout: durString(p.Timeout),
RetireGrace: durString(p.RetireGrace), Workers: p.Workers}
}
// formDefaults is what each unset value means; the UI shows it as a placeholder.
func formDefaults() SettingsFormView {
d := config.Default()
return SettingsFormView{
Retention: RetentionForm{Hot: durString(d.Retention.Hot.D()), FiveMinute: durString(d.Retention.FiveMinute.D()),
Long: durString(d.Retention.Long.D())},
Timezone: d.Timezone,
RetiredPurgeAfter: durString(config.DefaultRetiredPurgeAfter),
PollerInterval: durString(store.DefaultPollInterval),
Sites: []SiteForm{},
Alerting: AlertingForm{OutboundAllow: []string{}, EvaluationInterval: "30s"},
Receivers: []ReceiverView{},
Auth: AuthForm{BreakGlassUser: "breakglass", TrustedProxies: []string{}, Session: SessionForm{
Idle: durString(auth.DefaultIdleTimeout), Absolute: durString(auth.DefaultAbsoluteTimeout)}},
Backup: BackupForm{Keep: config.DefaultBackupKeep},
Disk: DiskForm{AlertUsed: diskguard.DefaultAlertUsed, AdmitUsed: diskguard.DefaultAdmitUsed,
ReserveFree: diskguard.DefaultReserveFree},
L2: L2Form{Interval: durString(config.DefaultL2Interval), Keep: durString(config.DefaultL2Keep),
EdgeMaxMACs: config.DefaultL2EdgeMaxMACs, Concurrency: config.DefaultL2Concurrency,
DeviceTimeout: durString(config.DefaultL2DeviceTimeout)},
OUI: OUIForm{Fetch: true, Refresh: durString(config.DefaultOUIRefresh)},
Proxmox: ProxmoxForm{Clusters: []ProxmoxClusterForm{}, Interval: durString(proxmox.DefaultInterval),
Timeout: durString(proxmox.DefaultTimeout), RetireGrace: durString(proxmox.DefaultRetireGrace),
Workers: proxmox.DefaultWorkers},
// The checks runner's defaults are literals in internal/checks (Workers 32, Timeout 10s, Tick 5s).
Checks: ChecksForm{Workers: 32, Timeout: "10s", Tick: "5s"},
}
}