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

132 lines
4.3 KiB
Go

package notify
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/v10networks/nmon/internal/store"
)
// testExcerpt caps the response body a test send reports.
const testExcerpt = 4 << 10
// ErrTestBusy is returned when too many test sends are already in flight.
var ErrTestBusy = errors.New("notify: too many test sends in progress")
// TestResult is the outcome of one immediate test send. Payload and ResponseBody are redacted.
type TestResult struct {
Payload string `json:"payload"`
StatusCode int `json:"status_code"`
ResponseBody string `json:"response_body"`
DurationMS int64 `json:"duration_ms"`
Error string `json:"error"`
OK bool `json:"ok"`
}
// Preview renders r's template against a sample notification in state, exactly as a delivery would, and returns
// the redacted output. Connection settings are not needed (a placeholder fills in what validation insists on), so
// a half-filled receiver form can be previewed. The error is the template, JSON or validation problem.
func Preview(r Receiver, state string, now time.Time) (string, error) {
if !ValidState(state) {
return "", fmt.Errorf("unknown state %q", state)
}
if r.Name == "" {
r.Name = "preview"
}
switch r.Kind {
case KindWebhook:
r.URL = "https://preview.invalid/"
r.Headers = nil
case KindSMTP:
r.SMTP = SMTP{Host: "preview.invalid", From: "nmon@preview.invalid", To: []string{"ops@preview.invalid"}}
}
c, err := compile(r)
if err != nil {
return "", unwrapReceiver(err, r.Name)
}
return c.render(SampleNotification(state, now))
}
// unwrapReceiver drops the `receiver "x": ` prefix that compile adds, which is noise in a single-receiver preview.
func unwrapReceiver(err error, name string) error {
return errors.New(strings.TrimPrefix(err.Error(), fmt.Sprintf("receiver %q: ", name)))
}
// SendTest renders a sample notification for r and sends it once, now, through the queue's outbound policy and
// timeout. It bypasses the delivery table and the retry schedule and persists nothing. At most two test sends run
// at a time; more return ErrTestBusy. Failures of the receiver itself are reported in the result, not as an error.
func (q *Queue) SendTest(ctx context.Context, r Receiver, state string) (TestResult, error) {
select {
case q.testSlots <- struct{}{}:
defer func() { <-q.testSlots }()
default:
return TestResult{}, ErrTestBusy
}
if !ValidState(state) {
return TestResult{}, fmt.Errorf("unknown state %q", state)
}
start := q.clk.Now()
res := q.sendTest(ctx, r, state, start)
res.DurationMS = q.clk.Now().Sub(start).Milliseconds()
return res, nil
}
// sendTest is SendTest after the slot and state checks.
func (q *Queue) sendTest(ctx context.Context, r Receiver, state string, start time.Time) (res TestResult) {
c, err := compile(r)
if err != nil {
res.Error = sanitizeErr(unwrapReceiver(err, r.Name))
return res
}
n := SampleNotification(state, start)
if res.Payload, err = c.render(n); err != nil {
res.Error = sanitizeErr(err)
return res
}
actx, cancel := context.WithTimeout(ctx, q.o.Timeout)
defer cancel()
d := store.Delivery{AlertID: n.AlertID, Receiver: r.Name, IncidentID: "test-" + n.IncidentID, Payload: res.Payload}
switch c.Kind {
case KindWebhook:
err = q.testWebhook(actx, c, d, &res)
case KindSMTP:
err = q.sendSMTP(actx, c, d)
}
if err != nil {
res.Error = sanitizeErr(err)
}
res.OK = err == nil
return res
}
// testWebhook POSTs d like sendWebhook but keeps the status and a body excerpt for the caller.
func (q *Queue) testWebhook(ctx context.Context, c *compiled, d store.Delivery, res *TestResult) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, strings.NewReader(d.Payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "nmon")
for k, v := range c.Headers {
req.Header.Set(k, v)
}
req.Header.Set(HeaderIncident, d.IncidentID)
resp, err := q.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }() // the excerpt is read below
body, _ := io.ReadAll(io.LimitReader(resp.Body, testExcerpt))
res.StatusCode = resp.StatusCode
res.ResponseBody = RedactText(strings.ToValidUTF8(string(body), "?"))
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("webhook returned HTTP %d", resp.StatusCode)
}
return nil
}