Files
nmon/internal/alertrule/fields.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

92 lines
3.7 KiB
Go

package alertrule
import "sort"
// Types of an inventory field.
const (
TypeNumber = "number"
TypeBool = "bool"
TypeString = "string"
TypeDuration = "duration"
)
// Entities an inventory rule can range over; they are also the rule's level.
const (
EntityDevice = "device"
EntityInterface = "interface"
)
// Ops by field type.
var typeOps = map[string][]string{
TypeNumber: {OpGT, OpGE, OpLT, OpLE, OpEQ, OpNE},
TypeBool: {"is_true", "is_false"},
TypeString: {"equals", "not_equals", "matches", "empty", "not_empty"},
TypeDuration: {"older_than", "newer_than"},
}
// OpsFor lists the ops a field type accepts.
func OpsFor(typ string) []string { return append([]string(nil), typeOps[typ]...) }
// InventoryField is one whitelisted inventory field.
type InventoryField struct {
Name string `json:"name"`
Label string `json:"label"`
Entity string `json:"entity" enum:"device,interface"`
Type string `json:"type" enum:"number,bool,string,duration"`
Ops []string `json:"ops"`
Enum map[int]string `json:"enum,omitempty" doc:"Names of a status number, for the UI; the value is still the number."`
}
type fieldDef struct {
name, label, entity, typ string
enum map[int]string
}
// The whitelist. Device rows that are retired, purged or disabled are never evaluated (their alerts close as
// retired), so there is no enabled field. snmp_failing is true while the poller reports the device degraded or
// backed off.
var fieldDefs = []fieldDef{
{"last_seen_age", "Time since last seen", EntityDevice, TypeDuration, nil},
{"profile", "Profile", EntityDevice, TypeString, nil},
{"profile_pinned", "Profile pinned", EntityDevice, TypeBool, nil},
{"site", "Site", EntityDevice, TypeString, nil},
{"role", "Role", EntityDevice, TypeString, nil},
{"kind", "Kind", EntityDevice, TypeString, nil},
{"sys_descr", "sysDescr", EntityDevice, TypeString, nil},
{"hw_serial", "Hardware serial", EntityDevice, TypeString, nil},
{"mgmt_ip", "Management address", EntityDevice, TypeString, nil},
{"parent_count", "Number of parents", EntityDevice, TypeNumber, nil},
{"snmp_failing", "SNMP failing", EntityDevice, TypeBool, nil},
{"if_speed_mbps", "Port speed (Mb/s)", EntityInterface, TypeNumber, nil},
{"admin_status", "Admin status", EntityInterface, TypeNumber, map[int]string{1: "up", 2: "down", 3: "testing"}},
{"oper_status", "Oper status", EntityInterface, TypeNumber, map[int]string{
1: "up", 2: "down", 3: "testing", 4: "unknown", 5: "dormant", 6: "notPresent", 7: "lowerLayerDown"}},
{"if_name", "Port name", EntityInterface, TypeString, nil},
{"if_alias", "Port alias", EntityInterface, TypeString, nil},
{"description", "Port description", EntityInterface, TypeString, nil},
{"unreliable", "Counters unreliable", EntityInterface, TypeBool, nil},
{"has_hc_counters", "Has 64-bit counters", EntityInterface, TypeBool, nil},
{"excluded", "Excluded from polling", EntityInterface, TypeBool, nil},
{"counter_discontinuity", "Counter discontinuities", EntityInterface, TypeNumber, nil},
{"last_discovered_age", "Time since last discovered", EntityInterface, TypeDuration, nil},
}
// Fields lists the whitelist, device fields first.
func Fields() []InventoryField {
out := make([]InventoryField, 0, len(fieldDefs))
for _, d := range fieldDefs {
out = append(out, InventoryField{Name: d.name, Label: d.label, Entity: d.entity, Type: d.typ, Ops: OpsFor(d.typ), Enum: d.enum})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Entity == EntityDevice && out[j].Entity != EntityDevice })
return out
}
func lookupField(name string) (fieldDef, bool) {
for _, d := range fieldDefs {
if d.name == name {
return d, true
}
}
return fieldDef{}, false
}