Files
nmon/internal/api/alert_rules.go
Vibe Coder b529423324 Add custom alert rules built from metrics and inventory fields
The predefined rules are too constraining: an ISP cannot say "CPU over
80% on servers for 15 minutes" or "uplink without a description", and
every new need meant editing config.yaml. Admins now build their own
rules; the shipped ones stay as read-only built-in macros with their
names unchanged, so v1.1 policies and alert history keep working.

Custom rules live in a new alert_rules table (migration 13) and are
builder-only. A metric rule names a catalogue metric (every pollable
profile metric plus push-ingested names) with a transform (value, rate,
percent_of), window, function, aggregate, comparison and threshold; the
pure internal/alertrule package compiles it to MetricsQL from fixed
templates, so no user text reaches a query. An inventory rule ANDs one to
five conditions over whitelisted device or port fields and is evaluated
in Go over store rows. The evaluator takes its rule set from a function
each cycle, so edits apply live, and both kinds use the existing state
machine, policies, suppression and notifications. Comparisons now cover
> >= < <= == != everywhere. utilisation is not offered: port speed is not
a stored series. config.yaml rules still work but are deprecated.

API (additive): rule list gains id/kind/editable/spec/expr, plus get,
create, update, delete, catalogue, test (viewer, read-only) and copy of
a built-in (cpu_high, ups_on_battery, printer_supply_empty) to a custom
rule. Deleting or renaming a rule that a policy names is a 409. The UI
gets an Alert rules tab with a builder dialog, a test button and the
generated query, and the policy dialog lists custom rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 00:17:48 +00:00

457 lines
17 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/alertrule"
"github.com/v10networks/nmon/internal/store"
)
// AlertRuleHost is the rule registry as the API sees it: the catalogue of what a builder may use, validation
// and a dry run of a draft, and the combined rule list.
type AlertRuleHost interface {
// Rules lists every rule: built-in, config and custom (and the reachability and system rules).
Rules() []AlertRuleInfo
// Catalogue lists the metrics and inventory fields a builder may use.
Catalogue(ctx context.Context) RuleCatalogue
// Build validates a draft; the error text lists every problem.
Build(ctx context.Context, d alertrule.Draft) (RuleBuilt, error)
// Test compiles a draft and runs it once against the live data.
Test(ctx context.Context, d alertrule.Draft) (RuleTestResult, error)
// Reserved reports a name a custom rule cannot take: any built-in, config, reachability or system rule.
Reserved(name string) bool
// Copy returns the builder draft equivalent to a built-in rule. A non-empty reason says why it cannot.
Copy(ctx context.Context, name string) (d alertrule.Draft, reason string)
// Changed drops the registry's caches so a write applies on the next evaluation cycle.
Changed()
}
// RuleBuilt is what a valid draft compiles to.
type RuleBuilt struct {
Expr string
Level string
}
// RuleCatalogue is what the builder offers.
type RuleCatalogue struct {
Metrics []alertrule.MetricInfo `json:"metrics"`
Fields []alertrule.InventoryField `json:"fields" doc:"Inventory fields with the ops each type accepts."`
Ops []string `json:"ops" doc:"Comparison operators of a metric rule."`
Window RuleWindowLimits `json:"window"`
}
// RuleWindowLimits are the bounds of a metric rule's window.
type RuleWindowLimits struct {
DefaultSeconds int64 `json:"default_seconds"`
MaxSeconds int64 `json:"max_seconds"`
}
// AlertRuleBody is the body of POST and PUT /alert-rules and of POST /alert-rules/test.
type AlertRuleBody struct {
Name string `json:"name" minLength:"1" maxLength:"64" doc:"Unique across all rules. Policies name a rule by this."`
Kind string `json:"kind" enum:"metric,inventory"`
Enabled *bool `json:"enabled,omitempty" doc:"Omitted: on for create, kept for update. Off retires open alerts silently."`
Severity string `json:"severity" enum:"critical,warning,info"`
For *string `json:"for,omitempty" doc:"How long the condition must hold, a duration such as 15m. Default 0."`
Summary string `json:"summary,omitempty" doc:"A text/template over rule, device, iface, component, value, threshold, op."`
Description string `json:"description,omitempty" doc:"As summary."`
Spec alertrule.RuleSpec `json:"spec"`
}
// AlertRuleCopyBody is the optional body of POST /alert-rules/{id}/copy.
type AlertRuleCopyBody struct {
Name string `json:"name,omitempty" maxLength:"64" doc:"Name of the copy; default is the built-in's name plus _custom."`
}
// RuleTestResult is a dry run of a draft.
type RuleTestResult struct {
Expr string `json:"expr" doc:"The generated query; empty for an inventory rule."`
Level string `json:"level" enum:"device,interface,component"`
Entities int `json:"entities" doc:"Series (metric rule) or rows (inventory rule) the rule saw."`
Breaching int `json:"breaching" doc:"How many breach right now, before any alert policy is applied."`
Samples []RuleTestSample `json:"samples" doc:"Up to 20, breaching ones first."`
}
// RuleTestSample is one entity of a dry run.
type RuleTestSample struct {
Device string `json:"device" doc:"Device slug."`
Iface string `json:"iface,omitempty" doc:"Interface ULID."`
Component string `json:"component,omitempty"`
Value float64 `json:"value"`
Breach bool `json:"breach"`
}
func (s *server) registerAlertRules(api huma.API) {
tags := []string{"alerts"}
bad := []int{http.StatusConflict, http.StatusUnprocessableEntity, http.StatusServiceUnavailable}
huma.Register(api, huma.Operation{OperationID: "get-alert-rule-catalogue", Method: http.MethodGet, Path: "/api/v1/alert-rules/catalogue",
Tags: tags, Summary: "Metrics and inventory fields the builder offers", Errors: []int{http.StatusServiceUnavailable}}, s.ruleCatalogue)
huma.Register(api, huma.Operation{OperationID: "test-alert-rule", Method: http.MethodPost, Path: "/api/v1/alert-rules/test", Tags: tags,
Summary: "Compile a draft rule and run it once",
Description: "Returns the generated query, how many entities the rule sees and how many breach now, ignoring alert policies.",
Errors: []int{http.StatusUnprocessableEntity, http.StatusServiceUnavailable}}, s.testAlertRule)
huma.Register(api, huma.Operation{OperationID: "create-alert-rule", Method: http.MethodPost, Path: "/api/v1/alert-rules", Tags: tags,
DefaultStatus: http.StatusCreated, Summary: "Create a custom alert rule",
Description: "Builder only: a metric rule names a catalogue metric, an inventory rule names whitelisted fields. " +
"A rule alerts only where an alert policy binds it.", Errors: bad}, s.createAlertRule)
huma.Register(api, huma.Operation{OperationID: "get-alert-rule", Method: http.MethodGet, Path: "/api/v1/alert-rules/{id}", Tags: tags,
Summary: "An alert rule", Errors: []int{http.StatusNotFound, http.StatusServiceUnavailable}}, s.getAlertRule)
huma.Register(api, huma.Operation{OperationID: "update-alert-rule", Method: http.MethodPut, Path: "/api/v1/alert-rules/{id}", Tags: tags,
Summary: "Replace a custom alert rule", Description: "A built-in or config rule is 409.",
Errors: append([]int{http.StatusNotFound}, bad...)}, s.updateAlertRule)
huma.Register(api, huma.Operation{OperationID: "delete-alert-rule", Method: http.MethodDelete, Path: "/api/v1/alert-rules/{id}",
Tags: tags,
DefaultStatus: http.StatusNoContent, Summary: "Delete a custom alert rule",
Description: "409 while an alert policy names the rule, or for a built-in. Open alerts of the rule close silently as retired.",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable}}, s.deleteAlertRule)
huma.Register(api, huma.Operation{OperationID: "copy-alert-rule", Method: http.MethodPost, Path: "/api/v1/alert-rules/{id}/copy",
Tags: tags,
DefaultStatus: http.StatusCreated, Summary: "Clone a built-in rule into a custom rule",
Description: "422 with the reason when the builder cannot express the built-in.",
Errors: append([]int{http.StatusNotFound}, bad...)}, s.copyAlertRule)
}
func (s *server) ruleHost() (AlertRuleHost, error) {
if s.AlertRules == nil {
return nil, errNotConfigured("alert rules")
}
return s.AlertRules, nil
}
// ruleInfos is the rule list with each rule's policy count.
func (s *server) ruleInfos(ctx context.Context) ([]AlertRuleInfo, error) {
var items []AlertRuleInfo
if s.AlertRules != nil {
items = s.AlertRules.Rules()
} else {
h, err := s.policyHost()
if err != nil {
return nil, err
}
items = h.Rules()
}
pols, err := s.Store.ListAlertPolicies(ctx)
if err != nil {
return nil, s.storeErr(err)
}
counts := map[string]int{}
for _, p := range pols {
counts[p.Rule]++
}
for i := range items {
items[i].PolicyCount = counts[items[i].Name]
}
return items, nil
}
func (s *server) ruleInfo(ctx context.Context, id string) (AlertRuleInfo, error) {
items, err := s.ruleInfos(ctx)
if err != nil {
return AlertRuleInfo{}, err
}
for _, it := range items {
if it.ID == id || (it.Kind != "metric" && it.Kind != "inventory" && it.Name == id) {
return it, nil
}
}
return AlertRuleInfo{}, huma.Error404NotFound("not found")
}
func (s *server) getAlertRule(ctx context.Context, in *struct {
ID string `path:"id" doc:"A custom rule's ULID, or a built-in's name."`
}) (*struct{ Body AlertRuleInfo }, error) {
if _, err := s.ruleHost(); err != nil {
return nil, err
}
it, err := s.ruleInfo(ctx, in.ID)
if err != nil {
return nil, err
}
return &struct{ Body AlertRuleInfo }{it}, nil
}
func (s *server) ruleCatalogue(ctx context.Context, _ *struct{}) (*struct{ Body RuleCatalogue }, error) {
h, err := s.ruleHost()
if err != nil {
return nil, err
}
return &struct{ Body RuleCatalogue }{h.Catalogue(ctx)}, nil
}
// draftOf converts a request body to a builder draft.
func draftOf(b AlertRuleBody) (alertrule.Draft, error) {
d := alertrule.Draft{Name: strings.TrimSpace(b.Name), Kind: b.Kind, Severity: b.Severity, Summary: b.Summary,
Description: b.Description, Spec: b.Spec}
if b.For != nil {
f, err := time.ParseDuration(*b.For)
if err != nil {
return d, huma.Error422UnprocessableEntity("for: " + err.Error())
}
d.For = f
}
return d, nil
}
func (s *server) testAlertRule(ctx context.Context, in *struct{ Body AlertRuleBody }) (*struct{ Body RuleTestResult }, error) {
h, err := s.ruleHost()
if err != nil {
return nil, err
}
d, err := draftOf(in.Body)
if err != nil {
return nil, err
}
res, err := h.Test(ctx, d)
if err != nil {
return nil, s.ruleErr(err)
}
return &struct{ Body RuleTestResult }{res}, nil
}
// ruleErr maps a registry error: a validation problem is 422, anything else is logged and 500.
func (s *server) ruleErr(err error) error {
var ve *RuleInvalid
if errors.As(err, &ve) {
return huma.Error422UnprocessableEntity(flatten(ve))
}
s.Logger.Error("alert rule", "err", err)
return huma.Error500InternalServerError("internal error")
}
// RuleInvalid is a draft that fails validation.
type RuleInvalid struct{ Err error }
func (e *RuleInvalid) Error() string { return e.Err.Error() }
func (e *RuleInvalid) Unwrap() error { return e.Err }
// ruleRow validates a body and converts it to a store row; enabled is the value to keep when the body omits it.
func (s *server) ruleRow(ctx context.Context, h AlertRuleHost, b AlertRuleBody, enabled bool) (store.AlertRule, RuleBuilt, error) {
d, err := draftOf(b)
if err != nil {
return store.AlertRule{}, RuleBuilt{}, err
}
built, err := h.Build(ctx, d)
if err != nil {
return store.AlertRule{}, RuleBuilt{}, s.ruleErr(err)
}
spec, err := json.Marshal(d.Spec)
if err != nil {
return store.AlertRule{}, RuleBuilt{}, huma.Error500InternalServerError("internal error")
}
if b.Enabled != nil {
enabled = *b.Enabled
}
return store.AlertRule{Name: d.Name, Kind: d.Kind, Enabled: enabled, Severity: d.Severity, ForSeconds: int64(d.For.Seconds()),
Summary: d.Summary, Description: d.Description, Spec: string(spec)}, built, nil
}
func (s *server) createAlertRule(ctx context.Context, in *struct{ Body AlertRuleBody }) (*struct{ Body AlertRuleInfo }, error) {
h, err := s.ruleHost()
if err != nil {
return nil, err
}
return s.storeNewRule(ctx, h, in.Body, "alert_rule.create")
}
func (s *server) storeNewRule(ctx context.Context, h AlertRuleHost, b AlertRuleBody, action string) (*struct{ Body AlertRuleInfo }, error) {
if h.Reserved(strings.TrimSpace(b.Name)) {
return nil, huma.Error409Conflict(fmt.Sprintf("rule name %q is taken by a built-in, config or reachability rule", b.Name))
}
row, _, err := s.ruleRow(ctx, h, b, true)
if err != nil {
return nil, err
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.CreateAlertRule(ctx, &row); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), action, row.ID, nil, b)
})
if err != nil {
return nil, s.storeErr(err)
}
h.Changed()
return s.ruleResult(ctx, row.ID)
}
func (s *server) ruleResult(ctx context.Context, id string) (*struct{ Body AlertRuleInfo }, error) {
it, err := s.ruleInfo(ctx, id)
if err != nil {
return nil, err
}
return &struct{ Body AlertRuleInfo }{it}, nil
}
// customRule loads a custom rule, or says why the id cannot be edited.
func (s *server) customRule(ctx context.Context, tx store.AlertRules, id string) (*store.AlertRule, error) {
r, err := tx.GetAlertRule(ctx, id)
if err == nil {
return r, nil
}
if errors.Is(err, store.ErrNotFound) {
if it, ierr := s.ruleInfo(ctx, id); ierr == nil && !it.Editable {
return nil, huma.Error409Conflict(fmt.Sprintf("rule %q is %s and cannot be changed; copy it to a custom rule", it.Name, it.Kind))
}
}
return nil, s.storeErr(err)
}
// referencing lists the names of policies that bind rule.
func referencing(ctx context.Context, tx store.AlertPolicies, rule string) ([]string, error) {
ps, err := tx.ListAlertPolicies(ctx)
if err != nil {
return nil, err
}
var names []string
for _, p := range ps {
if p.Rule == rule {
names = append(names, p.Name)
}
}
return names, nil
}
func (s *server) updateAlertRule(ctx context.Context, in *struct {
ID string `path:"id" doc:"A custom rule's ULID."`
Body AlertRuleBody
}) (*struct{ Body AlertRuleInfo }, error) {
h, err := s.ruleHost()
if err != nil {
return nil, err
}
cur, err := s.customRule(ctx, s.Store, in.ID)
if err != nil {
return nil, err
}
name := strings.TrimSpace(in.Body.Name)
if name != cur.Name && h.Reserved(name) {
return nil, huma.Error409Conflict(fmt.Sprintf("rule name %q is taken by a built-in, config or reachability rule", name))
}
row, built, err := s.ruleRow(ctx, h, in.Body, cur.Enabled)
if err != nil {
return nil, err
}
row.ID, row.CreatedAt = cur.ID, cur.CreatedAt
oldLevel := "" // read before the transaction: a second store call inside it would wait for the first forever on SQLite
if old, ierr := s.ruleInfo(ctx, cur.ID); ierr == nil {
oldLevel = old.Level
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := checkEditable(ctx, tx, cur, row.Name, oldLevel, built); err != nil {
return err
}
if err := tx.UpdateAlertRule(ctx, &row); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "alert_rule.update", row.ID, cur, in.Body)
})
if err != nil {
return nil, s.ruleStoreErr(err)
}
h.Changed()
return s.ruleResult(ctx, row.ID)
}
// checkEditable refuses a rename, or a change of level, while policies name the rule: both would orphan or
// invalidate them (a policy with port matchers needs an interface rule).
func checkEditable(ctx context.Context, tx store.AlertPolicies, cur *store.AlertRule, newName, oldLevel string, built RuleBuilt) error {
names, err := referencing(ctx, tx, cur.Name)
if err != nil || len(names) == 0 {
return err
}
if newName != cur.Name {
return &ruleBusy{fmt.Sprintf("policies %s name this rule; they would lose it on a rename", strings.Join(names, ", "))}
}
if oldLevel != "" && oldLevel != built.Level {
return &ruleBusy{fmt.Sprintf("policies %s bind this rule at the %s level; change them first", strings.Join(names, ", "), oldLevel)}
}
return nil
}
// ruleBusy is a write the rule's policies forbid.
type ruleBusy struct{ msg string }
func (e *ruleBusy) Error() string { return e.msg }
func (s *server) ruleStoreErr(err error) error {
var rb *ruleBusy
if errors.As(err, &rb) {
return huma.Error409Conflict(rb.msg)
}
return s.storeErr(err)
}
func (s *server) deleteAlertRule(ctx context.Context, in *struct {
ID string `path:"id" doc:"A custom rule's ULID."`
}) (*struct{}, error) {
h, err := s.ruleHost()
if err != nil {
return nil, err
}
if _, err := s.customRule(ctx, s.Store, in.ID); err != nil {
return nil, err
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
cur, err := tx.GetAlertRule(ctx, in.ID)
if err != nil {
return err
}
names, err := referencing(ctx, tx, cur.Name)
if err != nil {
return err
}
if len(names) > 0 {
return &ruleBusy{fmt.Sprintf("alert policies %s still bind rule %q; delete or change them first", strings.Join(names, ", "), cur.Name)}
}
if err := tx.DeleteAlertRule(ctx, in.ID); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "alert_rule.delete", in.ID, cur, nil)
})
if err != nil {
return nil, s.ruleStoreErr(err)
}
h.Changed()
return nil, nil
}
func (s *server) copyAlertRule(ctx context.Context, in *struct {
ID string `path:"id" doc:"A built-in rule's name."`
Body AlertRuleCopyBody
}) (*struct{ Body AlertRuleInfo }, error) {
h, err := s.ruleHost()
if err != nil {
return nil, err
}
src, err := s.ruleInfo(ctx, in.ID)
if err != nil {
return nil, err
}
if src.Editable {
return nil, huma.Error409Conflict("only a built-in rule can be copied; this one is already custom")
}
d, reason := h.Copy(ctx, src.Name)
if reason != "" {
return nil, huma.Error422UnprocessableEntity(fmt.Sprintf("%s cannot be copied to the builder: %s", src.Name, reason))
}
if in.Body.Name != "" {
d.Name = in.Body.Name
}
return s.storeNewRule(ctx, h, bodyOf(d), "alert_rule.copy")
}
// bodyOf converts a draft back to a request body.
func bodyOf(d alertrule.Draft) AlertRuleBody {
f := d.For.String()
return AlertRuleBody{Name: d.Name, Kind: d.Kind, Severity: d.Severity, For: &f, Summary: d.Summary,
Description: d.Description, Spec: d.Spec}
}