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

126 lines
5.3 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/notify"
)
// NotifyTester sends one immediate test notification through the running outbound policy. *notify.Queue fits.
type NotifyTester interface {
SendTest(ctx context.Context, r notify.Receiver, state string) (notify.TestResult, error)
}
// NotifyRequest is a receiver as the settings form holds it plus the sample state to render.
type NotifyRequest struct {
Receiver ReceiverForm `json:"receiver" doc:"The receiver as edited. Blank credentials resolve to the stored receiver of the same name."`
State string `json:"state,omitempty" enum:"firing,resolved,flapping,stale,suppressed_summary" doc:"Sample state; default firing."`
}
// NotifyPreview is a rendered sample payload, or why it could not be rendered.
type NotifyPreview struct {
Output string `json:"output" doc:"The payload that would be sent, redacted. Empty on error."`
Error string `json:"error" doc:"Template, JSON or receiver validation problem. Empty on success."`
}
// NotifyTestResult is the outcome of one test send; the receiver's own failures are reported here with a 200.
type NotifyTestResult struct {
Payload string `json:"payload" doc:"What was sent, redacted."`
StatusCode int `json:"status_code" doc:"HTTP status of a webhook; 0 for SMTP or when no response arrived."`
ResponseBody string `json:"response_body" doc:"First 4 KB of the response, redacted."`
DurationMS int64 `json:"duration_ms"`
Error string `json:"error" doc:"Why the send failed. Empty on success."`
OK bool `json:"ok"`
}
// NotifyPreset is a built-in webhook template.
type NotifyPreset struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Template string `json:"template"`
URLHint string `json:"url_hint" doc:"Shape of the service's webhook URL, for a placeholder."`
ContentType string `json:"content_type"`
}
func (s *server) registerNotify(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "list-notify-presets", Method: http.MethodGet, Path: "/api/v1/notify/presets", Tags: []string{"notify"},
Summary: "Built-in webhook templates", Description: "Teams (Workflows), Slack, Discord and generic JSON.",
}, s.listNotifyPresets)
huma.Register(api, huma.Operation{
OperationID: "preview-notify", Method: http.MethodPost, Path: "/api/v1/notify/preview", Tags: []string{"notify"},
Summary: "Render a receiver's template", Description: "Admin only. Renders the template against a sample " +
"notification with the delivery code path. Nothing is sent or stored.",
}, s.previewNotify)
huma.Register(api, huma.Operation{
OperationID: "test-notify", Method: http.MethodPost, Path: "/api/v1/notify/test", Tags: []string{"notify"},
Summary: "Send one test notification",
Description: "Admin only. Sends a sample notification to the receiver once, now, through the outbound allow-list, " +
"without the retry queue. Only an audit row is stored. At most two run at a time.",
Errors: []int{http.StatusTooManyRequests, http.StatusServiceUnavailable},
}, s.testNotify)
}
func (s *server) listNotifyPresets(context.Context, *struct{}) (*struct{ Body []NotifyPreset }, error) {
ps := notify.Presets()
out := make([]NotifyPreset, len(ps))
for i, p := range ps {
out[i] = NotifyPreset(p)
}
return &struct{ Body []NotifyPreset }{out}, nil
}
// stateOrDefault is the requested sample state, firing when unset.
func stateOrDefault(st string) string {
if st == "" {
return notify.StateFiring
}
return st
}
// resolveReceiver builds the receiver to use from the form, filling blank credentials from the stored receiver
// of the same name, exactly like PUT /settings/form.
func (s *server) resolveReceiver(f ReceiverForm) notify.Receiver {
var old notify.Receiver
if a, ok := s.Settings.(SettingsConfigAdmin); ok {
cfg, _ := a.CurrentConfig()
old = storedReceivers(cfg.Alerting)[strings.TrimSpace(f.Name)]
}
return receiverFrom(f, old)
}
func (s *server) previewNotify(_ context.Context, in *struct{ Body NotifyRequest }) (*struct{ Body NotifyPreview }, error) {
r := receiverFrom(in.Body.Receiver, notify.Receiver{})
out, err := notify.Preview(r, stateOrDefault(in.Body.State), s.Clock.Now())
p := NotifyPreview{Output: out}
if err != nil {
p.Error = err.Error()
}
return &struct{ Body NotifyPreview }{p}, nil
}
func (s *server) testNotify(ctx context.Context, in *struct{ Body NotifyRequest }) (*struct{ Body NotifyTestResult }, error) {
if s.Notify == nil {
return nil, errNotConfigured("notifications")
}
r, state := s.resolveReceiver(in.Body.Receiver), stateOrDefault(in.Body.State)
res, err := s.Notify.SendTest(ctx, r, state)
if errors.Is(err, notify.ErrTestBusy) {
return nil, huma.Error429TooManyRequests("another test send is in progress; try again in a moment")
}
if err != nil {
return nil, huma.Error422UnprocessableEntity(err.Error())
}
detail := map[string]any{"kind": r.Kind, "state": state, "ok": res.OK, "status_code": res.StatusCode}
if aerr := s.Store.AppendAudit(ctx, actorOf(ctx), "notify.test", "receiver:"+r.Name, nil, detail); aerr != nil {
s.Logger.Warn("audit notify.test", "err", aerr)
}
return &struct{ Body NotifyTestResult }{NotifyTestResult(res)}, nil
}