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
103 lines
4.1 KiB
Go
103 lines
4.1 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"github.com/v10networks/nmon/internal/config"
|
|
)
|
|
|
|
// SettingsConfigAdmin is the typed side of SettingsAdmin. UpdateConfig runs mutate on the current configuration
|
|
// under the same lock as every other settings write, then validates, saves and reloads it exactly like Apply
|
|
// does for a YAML document. An error from mutate or from validation wraps ErrInvalidSettings.
|
|
type SettingsConfigAdmin interface {
|
|
CurrentConfig() (cfg config.Config, restartRequired []string)
|
|
UpdateConfig(ctx context.Context, actor string, mutate func(*config.Config) error) (SettingsResult, error)
|
|
}
|
|
|
|
func (s *server) registerSettingsForm(api huma.API) {
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "get-settings-form", Method: http.MethodGet, Path: "/api/v1/settings/form", Tags: []string{"settings"},
|
|
Summary: "Read the configuration as a typed form", Description: "Admin only. The same configuration as GET /settings, " +
|
|
"as JSON sections, plus the defaults that apply to unset values.",
|
|
Errors: []int{http.StatusServiceUnavailable},
|
|
}, s.getSettingsForm)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "put-settings-form", Method: http.MethodPut, Path: "/api/v1/settings/form", Tags: []string{"settings"},
|
|
Summary: "Replace configuration sections",
|
|
Description: "Admin only. Each section present in the body replaces that section; absent sections are untouched. " +
|
|
"The result is validated, saved and reloaded exactly like PUT /settings. An invalid value is a 422 whose errors " +
|
|
"locate the field, and nothing changes.",
|
|
Errors: []int{http.StatusUnprocessableEntity, http.StatusServiceUnavailable},
|
|
}, s.putSettingsForm)
|
|
}
|
|
|
|
func (s *server) formAdmin() (SettingsConfigAdmin, error) {
|
|
a, ok := s.Settings.(SettingsConfigAdmin)
|
|
if !ok {
|
|
return nil, errNotConfigured("settings")
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (s *server) getSettingsForm(context.Context, *struct{}) (*struct{ Body SettingsFormState }, error) {
|
|
a, err := s.formAdmin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, pending := a.CurrentConfig()
|
|
v, err := formView(cfg)
|
|
if err != nil {
|
|
s.Logger.Error("read settings form", "err", err)
|
|
return nil, huma.Error500InternalServerError("could not read the configuration; see the log")
|
|
}
|
|
return &struct{ Body SettingsFormState }{SettingsFormState{Form: v, Defaults: formDefaults(), RestartRequired: nonNil(pending)}}, nil
|
|
}
|
|
|
|
func (s *server) putSettingsForm(ctx context.Context, in *struct{ Body SettingsFormPatch }) (*struct{ Body SettingsFormApplied }, error) {
|
|
a, err := s.formAdmin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := a.UpdateConfig(ctx, actorOf(ctx), func(c *config.Config) error { return applyFormPatch(c, in.Body) })
|
|
if errors.Is(err, ErrInvalidSettings) {
|
|
return nil, settingsProblem(trimSentinel(err, ErrInvalidSettings), err)
|
|
}
|
|
if err != nil {
|
|
s.Logger.Error("apply settings form", "err", err)
|
|
return nil, huma.Error500InternalServerError("could not save the configuration; see the log")
|
|
}
|
|
cfg, pending := a.CurrentConfig()
|
|
v, err := formView(cfg)
|
|
if err != nil {
|
|
s.Logger.Warn("read back settings form", "err", err)
|
|
}
|
|
out := SettingsFormApplied{Form: v, Applied: nonNil(res.Applied), RestartRequired: nonNil(pending)}
|
|
return &struct{ Body SettingsFormApplied }{out}, nil
|
|
}
|
|
|
|
// validationPath finds the dotted config path a validation message starts with ("retention.hot must be ...",
|
|
// "auth.oidc.issuer is required", "timezone: ...").
|
|
var validationPath = regexp.MustCompile(`^([a-z_][a-z_0-9]*(?:\.[A-Za-z0-9_]+|\[\d+\])*)(:| )`)
|
|
|
|
// settingsProblem is the 422 for a rejected form: the detail is the message and errors[0].location is
|
|
// body.<path> when the message names a field, so the form can mark it.
|
|
func settingsProblem(msg string, err error) error {
|
|
loc := ""
|
|
var fe *fieldError
|
|
if errors.As(err, &fe) {
|
|
loc, msg = fe.Loc, fe.Msg
|
|
} else if m := validationPath.FindStringSubmatch(msg); m != nil && (strings.Contains(m[1], ".") || m[2] == ":") {
|
|
loc = m[1]
|
|
}
|
|
if loc == "" {
|
|
return huma.Error422UnprocessableEntity(msg)
|
|
}
|
|
return huma.Error422UnprocessableEntity(msg, &huma.ErrorDetail{Message: msg, Location: "body." + loc})
|
|
}
|