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
92 lines
3.7 KiB
Go
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
|
|
}
|