feat(alerts): resolve alert policy through one declarative fold

Phase 3 of docs/ALERT_ENGINE_EVOLUTION.md: the effective alert policy for
a resource — type default thresholds, the type's DisableAll switches,
custom rules, the per-resource override — is now answered by one ordered
fold (effectiveAlertPolicyNoLock in alert_policy.go), translated from the
persisted AlertConfig. The config keeps its shape; the engine stops
reading it piecemeal.

Before this, every check path read its own DisableAll* boolean and picked
its own override lookup, and the scattered reads drifted (#1738 was an
override lookup that existed on some paths and not others;
connection.go hand-rolled its own type-to-switch mapping). Now:

- resolveResourceThresholds and getGuestThresholds delegate to the fold.
- All 40+ DisableAll* reads across the check paths, the config-change
  reconciliation, and the connection detector go through
  alertPolicyTypeSwitchesNoLock — the single place those booleans are
  read on behalf of evaluation.

Characterization first: alert_policy_test.go pins the fold against the
legacy resolution paths (per-type defaults, overrides, storage aliases,
guest custom rules by priority, every type's switch pair) before any
call site moved.
This commit is contained in:
rcourtman 2026-08-27 01:10:26 +01:00
parent 90cd0a57af
commit 8827defb63
15 changed files with 378 additions and 97 deletions

View file

@ -0,0 +1,130 @@
package alerts
// Alert policy resolution: the declarative rule model scoped by
// docs/ALERT_ENGINE_EVOLUTION.md, Phase 3. The persisted AlertConfig keeps
// its shape — per-type default blocks, per-resource overrides, custom
// rules, DisableAll* switches — and this file is the translator: one
// ordered fold from that config to the effective policy for one resource.
//
// The point is a single resolution surface. Before this, every check path
// read its own DisableAll* boolean and picked its own override lookup, and
// the scattered reads drifted (#1738: overrides saved under registry IDs
// silently never applying on legacy-ID paths; connection.go hand-rolling
// its own type→switch mapping). Callers now ask one question — "what is
// the effective alert policy for this resource?" — and the tiers are:
//
// 1. the type's default threshold block
// 2. the type's DisableAll switches (all alerts / offline family)
// 3. custom rules (guest kinds, live-filter scoped, priority order)
// 4. the per-resource override, through the identity-aware lookup for
// the kind (guest stable keys, storage aliases, canonical registry
// translation)
// alertPolicyQuery identifies one resource asking for its effective
// alert policy.
type alertPolicyQuery struct {
// TypeKey is the canonical resource type key ("vm", "node", "agent",
// "storage", "pbs", "pmg", "docker-host", "k8s-node", ...).
TypeKey string
ResourceID string
// Guest carries the live guest model when the query is for a guest
// kind: filter-scoped custom rules and the clustered override
// identity need it. Nil is valid — filter rules then never match and
// override lookup falls back to the raw resource ID.
Guest any
// StorageAliases are the storage resource's alias IDs, honored by the
// storage override lookup.
StorageAliases []string
}
// EffectiveAlertPolicy is the resolved policy for one resource.
type EffectiveAlertPolicy struct {
// AllDisabled reports the type's every-alert switch
// (DisableAllNodes, DisableAllGuests, ...).
AllDisabled bool
// OfflineDisabled reports the type's connectivity/powered-state
// family switch (DisableAllNodesOffline, ...). Per-resource
// connectivity opt-outs are Thresholds.DisableConnectivity.
OfflineDisabled bool
// Thresholds is the folded threshold block: type defaults, then
// custom rules, then the per-resource override.
Thresholds ThresholdConfig
}
// alertPolicyTypeSwitches maps a resource type key to its global disable
// switches. This is the one place the DisableAll* booleans are read on
// behalf of evaluation paths.
func (m *Manager) alertPolicyTypeSwitchesNoLock(typeKey string) (allDisabled, offlineDisabled bool) {
switch typeKey {
case "vm", "system-container":
return m.config.DisableAllGuests, m.config.DisableAllGuestsOffline
case "app-container":
return m.config.DisableAllDockerContainers, false
case "docker-service":
return m.config.DisableAllDockerServices, false
case "docker-host":
return m.config.DisableAllDockerHosts, m.config.DisableAllDockerHostsOffline
case "node":
return m.config.DisableAllNodes, m.config.DisableAllNodesOffline
case "agent":
return m.config.DisableAllAgents, m.config.DisableAllAgentsOffline
case "storage":
return m.config.DisableAllStorage, false
case "pbs":
return m.config.DisableAllPBS, m.config.DisableAllPBSOffline
case "pmg":
return m.config.DisableAllPMG, m.config.DisableAllPMGOffline
default:
if isUnifiedModernPlatformAlertType(typeKey) {
return m.unifiedPlatformAlertsDisabledNoLock(typeKey), false
}
return false, false
}
}
// customRuleThresholdsNoLock folds the highest-priority matching enabled
// custom rule onto base. Custom rules are guest-scoped: they match on
// live guest state, so a query without a guest never matches one.
func (m *Manager) customRuleThresholdsNoLock(base ThresholdConfig, guest any) ThresholdConfig {
if guest == nil {
return base
}
var applicable *CustomAlertRule
highestPriority := -1
for i := range m.config.CustomRules {
rule := &m.config.CustomRules[i]
if !rule.Enabled {
continue
}
if m.evaluateFilterStack(guest, rule.FilterConditions) {
if rule.Priority > highestPriority {
applicable = rule
highestPriority = rule.Priority
}
}
}
if applicable == nil {
return base
}
return m.applyThresholdOverride(base, applicable.Thresholds)
}
// effectiveAlertPolicyNoLock resolves the alert policy for one resource.
// Callers must hold m.mu.
func (m *Manager) effectiveAlertPolicyNoLock(q alertPolicyQuery) EffectiveAlertPolicy {
policy := EffectiveAlertPolicy{}
policy.AllDisabled, policy.OfflineDisabled = m.alertPolicyTypeSwitchesNoLock(q.TypeKey)
thresholds := m.defaultThresholdsForResourceType(q.TypeKey)
switch {
case isGuestThresholdResourceType(q.TypeKey):
thresholds = m.customRuleThresholdsNoLock(thresholds, q.Guest)
thresholds = m.resolveGuestThresholdOverride(thresholds, q.Guest, q.ResourceID)
case q.TypeKey == "storage":
thresholds = m.resolveStorageThresholdOverride(thresholds, q.ResourceID, q.StorageAliases)
default:
thresholds = m.resolveThresholdOverride(thresholds, q.ResourceID)
}
policy.Thresholds = thresholds
return policy
}

View file

@ -0,0 +1,193 @@
package alerts
// Characterization for the Phase 3 policy resolution
// (docs/ALERT_ENGINE_EVOLUTION.md): effectiveAlertPolicyNoLock must answer
// exactly what the scattered legacy reads answered — the per-type
// resolveResourceThresholds / getGuestThresholds paths and the DisableAll*
// booleans — across the config surface. Any divergence is a bug in the
// fold, never an improvement.
import (
"reflect"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func testPolicyConfigMatrix() AlertConfig {
trig := func(t, c float64) *HysteresisThreshold {
return &HysteresisThreshold{Trigger: t, Clear: c}
}
return AlertConfig{
Enabled: true,
GuestDefaults: ThresholdConfig{CPU: trig(80, 75), Memory: trig(85, 80)},
NodeDefaults: ThresholdConfig{CPU: trig(85, 80), Temperature: trig(75, 70)},
AgentDefaults: ThresholdConfig{CPU: trig(90, 85), DiskTemperature: trig(60, 55)},
PBSDefaults: ThresholdConfig{CPU: trig(70, 65)},
StorageDefault: HysteresisThreshold{Trigger: 88, Clear: 83},
KubernetesDefaults: ThresholdConfig{
CPU: trig(75, 70), Memory: trig(80, 75),
},
TrueNASDefaults: ThresholdConfig{CPU: trig(65, 60), Usage: trig(82, 77)},
TrueNASDiskDefaults: ThresholdConfig{DiskTemperature: trig(55, 50)},
VMwareDefaults: ThresholdConfig{CPU: trig(78, 73), Usage: trig(84, 79)},
Overrides: map[string]ThresholdConfig{
"node-2": {CPU: trig(95, 90)},
"pbs-quiet": {Disabled: true},
"storage-full": {Usage: trig(97, 94)},
"storage-alias": {
Usage: trig(93, 91),
},
"vm-override": {Memory: trig(99, 97), DisableConnectivity: true},
},
CustomRules: []CustomAlertRule{
{
Name: "named-guests",
Enabled: true,
Priority: 10,
FilterConditions: FilterStack{
LogicalOperator: "AND",
Filters: []FilterCondition{
{Type: "text", Field: "name", Value: "web"},
},
},
Thresholds: ThresholdConfig{CPU: trig(60, 55)},
},
{
Name: "disabled-rule",
Enabled: false,
Priority: 99,
FilterConditions: FilterStack{
LogicalOperator: "AND",
Filters: []FilterCondition{
{Type: "text", Field: "name", Value: "web"},
},
},
Thresholds: ThresholdConfig{CPU: trig(10, 5)},
},
},
DisableAllNodes: true,
DisableAllGuestsOffline: true,
DisableAllPBS: true,
DisableAllPBSOffline: true,
DisableAllDockerHostsOffline: true,
DisableAllTrueNAS: true,
}
}
func TestEffectiveAlertPolicyMatchesLegacyThresholdResolution(t *testing.T) {
m := newTestManager(t)
m.mu.Lock()
m.config = testPolicyConfigMatrix()
m.mu.Unlock()
cases := []struct {
name string
query alertPolicyQuery
}{
{"node default", alertPolicyQuery{TypeKey: "node", ResourceID: "node-1"}},
{"node override", alertPolicyQuery{TypeKey: "node", ResourceID: "node-2"}},
{"agent", alertPolicyQuery{TypeKey: "agent", ResourceID: "agent:host-1"}},
{"pbs disabled override", alertPolicyQuery{TypeKey: "pbs", ResourceID: "pbs-quiet"}},
{"storage default", alertPolicyQuery{TypeKey: "storage", ResourceID: "storage-empty"}},
{"storage override", alertPolicyQuery{TypeKey: "storage", ResourceID: "storage-full"}},
{"k8s node", alertPolicyQuery{TypeKey: "k8s-node", ResourceID: "k8s-node-1"}},
{"truenas pool", alertPolicyQuery{TypeKey: "truenas-pool", ResourceID: "pool-1"}},
{"vmware datastore", alertPolicyQuery{TypeKey: "vmware-datastore", ResourceID: "ds-1"}},
{"vm without live guest", alertPolicyQuery{TypeKey: "vm", ResourceID: "vm-override"}},
{"unknown type", alertPolicyQuery{TypeKey: "mystery", ResourceID: "x"}},
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
legacy := m.resolveResourceThresholds(tc.query.TypeKey, tc.query.ResourceID)
policy := m.effectiveAlertPolicyNoLock(tc.query)
if !reflect.DeepEqual(policy.Thresholds, legacy) {
t.Fatalf("thresholds diverge from legacy resolution:\n policy: %+v\n legacy: %+v", policy.Thresholds, legacy)
}
})
}
}
func TestEffectiveAlertPolicyMatchesGuestResolutionWithCustomRules(t *testing.T) {
m := newTestManager(t)
m.mu.Lock()
m.config = testPolicyConfigMatrix()
m.mu.Unlock()
guests := []struct {
name string
guest models.VM
id string
}{
{"custom rule matches", models.VM{Name: "web-1", Node: "pve1", Instance: "pve1", VMID: 100}, "pve1-100"},
{"custom rule misses", models.VM{Name: "db-1", Node: "pve1", Instance: "pve1", VMID: 101}, "pve1-101"},
{"override wins over rule", models.VM{Name: "web-2", Node: "pve1", Instance: "pve1", VMID: 102}, "vm-override"},
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, tc := range guests {
t.Run(tc.name, func(t *testing.T) {
legacy := m.getGuestThresholds(tc.guest, tc.id)
policy := m.effectiveAlertPolicyNoLock(alertPolicyQuery{TypeKey: "vm", ResourceID: tc.id, Guest: tc.guest})
if !reflect.DeepEqual(policy.Thresholds, legacy) {
t.Fatalf("guest thresholds diverge from legacy resolution:\n policy: %+v\n legacy: %+v", policy.Thresholds, legacy)
}
})
}
}
func TestEffectiveAlertPolicyMatchesStorageAliasResolution(t *testing.T) {
m := newTestManager(t)
m.mu.Lock()
m.config = testPolicyConfigMatrix()
m.mu.Unlock()
m.mu.RLock()
defer m.mu.RUnlock()
legacy := m.resolveStorageThresholdOverride(m.defaultThresholdsForResourceType("storage"), "storage-unknown", []string{"storage-alias"})
policy := m.effectiveAlertPolicyNoLock(alertPolicyQuery{TypeKey: "storage", ResourceID: "storage-unknown", StorageAliases: []string{"storage-alias"}})
if !reflect.DeepEqual(policy.Thresholds, legacy) {
t.Fatalf("alias storage thresholds diverge:\n policy: %+v\n legacy: %+v", policy.Thresholds, legacy)
}
}
func TestEffectiveAlertPolicySwitchesMatchLegacyBooleans(t *testing.T) {
m := newTestManager(t)
m.mu.Lock()
m.config = testPolicyConfigMatrix()
m.mu.Unlock()
m.mu.RLock()
defer m.mu.RUnlock()
cases := []struct {
typeKey string
wantAll bool
wantOffline bool
}{
{"node", m.config.DisableAllNodes, m.config.DisableAllNodesOffline},
{"vm", m.config.DisableAllGuests, m.config.DisableAllGuestsOffline},
{"system-container", m.config.DisableAllGuests, m.config.DisableAllGuestsOffline},
{"app-container", m.config.DisableAllDockerContainers, false},
{"docker-host", m.config.DisableAllDockerHosts, m.config.DisableAllDockerHostsOffline},
{"docker-service", m.config.DisableAllDockerServices, false},
{"agent", m.config.DisableAllAgents, m.config.DisableAllAgentsOffline},
{"storage", m.config.DisableAllStorage, false},
{"pbs", m.config.DisableAllPBS, m.config.DisableAllPBSOffline},
{"pmg", m.config.DisableAllPMG, m.config.DisableAllPMGOffline},
{"k8s-node", m.config.DisableAllKubernetes, false},
{"truenas-pool", m.config.DisableAllTrueNAS, false},
{"vmware-vm", m.config.DisableAllVMware, false},
{"mystery", false, false},
}
for _, tc := range cases {
policy := m.effectiveAlertPolicyNoLock(alertPolicyQuery{TypeKey: tc.typeKey, ResourceID: "r"})
if policy.AllDisabled != tc.wantAll || policy.OfflineDisabled != tc.wantOffline {
t.Errorf("%s: switches = (all=%v offline=%v), want (all=%v offline=%v)",
tc.typeKey, policy.AllDisabled, policy.OfflineDisabled, tc.wantAll, tc.wantOffline)
}
}
}

View file

@ -154,7 +154,17 @@ func normalizeOverrides(overrides map[string]ThresholdConfig) {
// applyGlobalOfflineSettingsLocked clears tracking and active alerts for globally disabled offline detectors.
// Caller must hold m.mu.
func (m *Manager) applyGlobalOfflineSettingsLocked() {
if m.config.DisableAllNodesOffline {
_, nodesOfflineDisabled := m.alertPolicyTypeSwitchesNoLock("node")
_, pbsOfflineDisabled := m.alertPolicyTypeSwitchesNoLock("pbs")
_, guestsOfflineDisabled := m.alertPolicyTypeSwitchesNoLock("vm")
_, dockerHostsOfflineDisabled := m.alertPolicyTypeSwitchesNoLock("docker-host")
containersDisabled, _ := m.alertPolicyTypeSwitchesNoLock("app-container")
servicesDisabled, _ := m.alertPolicyTypeSwitchesNoLock("docker-service")
kubernetesDisabled, _ := m.alertPolicyTypeSwitchesNoLock("k8s-node")
truenasDisabled, _ := m.alertPolicyTypeSwitchesNoLock("truenas-system")
vmwareDisabled, _ := m.alertPolicyTypeSwitchesNoLock("vmware-host")
if nodesOfflineDisabled {
var nodeAlerts []string
for storageKey, alert := range m.activeAlerts {
if alert == nil {
@ -172,7 +182,7 @@ func (m *Manager) applyGlobalOfflineSettingsLocked() {
}
}
if m.config.DisableAllPBSOffline {
if pbsOfflineDisabled {
var pbsAlerts []string
for storageKey, alert := range m.activeAlerts {
if alert != nil && alert.CanonicalKind == string(alertspecs.AlertSpecKindConnectivity) {
@ -186,7 +196,7 @@ func (m *Manager) applyGlobalOfflineSettingsLocked() {
}
}
if m.config.DisableAllGuestsOffline {
if guestsOfflineDisabled {
var guestAlerts []string
for storageKey, alert := range m.activeAlerts {
if alert != nil && alert.CanonicalKind == string(alertspecs.AlertSpecKindPoweredState) {
@ -198,7 +208,7 @@ func (m *Manager) applyGlobalOfflineSettingsLocked() {
}
}
if m.config.DisableAllDockerHostsOffline {
if dockerHostsOfflineDisabled {
var hostAlerts []string
for storageKey, alert := range m.activeAlerts {
if alert != nil && alert.CanonicalKind == string(alertspecs.AlertSpecKindConnectivity) {
@ -212,7 +222,7 @@ func (m *Manager) applyGlobalOfflineSettingsLocked() {
}
}
if m.config.DisableAllDockerContainers {
if containersDisabled {
var containerAlerts []string
for storageKey, alert := range m.activeAlerts {
id := effectiveAlertID(alert, storageKey)
@ -227,12 +237,12 @@ func (m *Manager) applyGlobalOfflineSettingsLocked() {
m.dockerUpdateFirstSeen = make(map[string]time.Time)
m.dockerUpdateFirstSeenByIdentity = make(map[string]time.Time)
}
if m.config.DockerDefaults.UpdateAlertDelayHours < 0 && !m.config.DisableAllDockerContainers {
if m.config.DockerDefaults.UpdateAlertDelayHours < 0 && !containersDisabled {
m.clearDockerContainerUpdateAlertsLocked()
m.dockerUpdateFirstSeen = make(map[string]time.Time)
m.dockerUpdateFirstSeenByIdentity = make(map[string]time.Time)
}
if m.config.DisableAllDockerServices {
if servicesDisabled {
var serviceAlerts []string
for storageKey, alert := range m.activeAlerts {
id := effectiveAlertID(alert, storageKey)
@ -245,7 +255,7 @@ func (m *Manager) applyGlobalOfflineSettingsLocked() {
}
}
if m.config.DisableAllKubernetes || m.config.DisableAllTrueNAS || m.config.DisableAllVMware {
if kubernetesDisabled || truenasDisabled || vmwareDisabled {
var platformAlerts []string
for storageKey, alert := range m.activeAlerts {
primaryType := alertPrimaryResourceType(alert)
@ -253,11 +263,11 @@ func (m *Manager) applyGlobalOfflineSettingsLocked() {
continue
}
switch {
case m.config.DisableAllKubernetes && isUnifiedKubernetesAlertType(primaryType):
case kubernetesDisabled && isUnifiedKubernetesAlertType(primaryType):
platformAlerts = append(platformAlerts, effectiveAlertID(alert, storageKey))
case m.config.DisableAllTrueNAS && isUnifiedTrueNASAlertType(primaryType):
case truenasDisabled && isUnifiedTrueNASAlertType(primaryType):
platformAlerts = append(platformAlerts, effectiveAlertID(alert, storageKey))
case m.config.DisableAllVMware && isUnifiedVMwareAlertType(primaryType):
case vmwareDisabled && isUnifiedVMwareAlertType(primaryType):
platformAlerts = append(platformAlerts, effectiveAlertID(alert, storageKey))
}
}
@ -381,7 +391,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
}
if alert.Type == "queue-depth" || alert.Type == "queue-deferred" || alert.Type == "queue-hold" || alert.Type == "message-age" {
if m.config.DisableAllPMG {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("pmg"); allDisabled {
alertsToResolve = append(alertsToResolve, alertID)
continue
}
@ -404,7 +414,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
isAgentResource := alertResourceTypeKeysContain(resourceTypeKeys, "agent")
if !handledModernPlatformType && isAgentResource {
if m.config.DisableAllAgents {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("agent"); allDisabled {
alertsToResolve = append(alertsToResolve, alertID)
continue
}
@ -426,14 +436,14 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
}
if resourceTypeMeta == "docker-host" {
if m.config.DisableAllDockerHosts {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("docker-host"); allDisabled {
alertsToResolve = append(alertsToResolve, alertID)
continue
}
continue
}
if resourceTypeMeta == "app-container" {
if m.config.DisableAllDockerContainers {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("app-container"); allDisabled {
alertsToResolve = append(alertsToResolve, alertID)
continue
}
@ -473,7 +483,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
isNodeResource := primaryResourceType == "" || primaryResourceType == "node"
isStorageResource := alertResourceTypeKeysContain(resourceTypeKeys, "storage")
if threshold == nil && !handledModernPlatformType && isNodeResource && !strings.Contains(resourceID, ":") && (alert.Instance == "Node" || alert.Instance == alert.Node) {
if m.config.DisableAllNodes {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("node"); allDisabled {
alertsToResolve = append(alertsToResolve, alertID)
continue
}
@ -484,7 +494,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
}
threshold = getThresholdForMetric(thresholds, metricType)
} else if threshold == nil && !handledModernPlatformType && (isStorageResource || alert.Instance == "Storage" || strings.Contains(alert.ResourceID, ":storage/")) {
if m.config.DisableAllStorage {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("storage"); allDisabled {
alertsToResolve = append(alertsToResolve, alertID)
continue
}
@ -495,7 +505,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
}
threshold = getThresholdForMetric(thresholds, metricType)
} else if threshold == nil && !handledModernPlatformType && (resourceTypeMeta == "pbs" || alert.Instance == "PBS") {
if m.config.DisableAllPBS {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("pbs"); allDisabled {
alertsToResolve = append(alertsToResolve, alertID)
continue
}
@ -508,7 +518,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
}
if threshold == nil && !handledModernPlatformType {
if m.config.DisableAllGuests {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("vm"); allDisabled {
alertsToResolve = append(alertsToResolve, alertID)
continue
}

View file

@ -97,33 +97,21 @@ func (m *Manager) connectionDegradedPolicyDisabledNoLock(resourceID, policyResou
thresholdType := ""
switch connectionType {
case ConnectionTypePVE:
if m.config.DisableAllNodes || m.config.DisableAllNodesOffline {
return true
}
thresholdType = "node"
case ConnectionTypePBS:
if m.config.DisableAllPBS || m.config.DisableAllPBSOffline {
return true
}
thresholdType = "pbs"
case ConnectionTypePMG:
if m.config.DisableAllPMG || m.config.DisableAllPMGOffline {
return true
}
thresholdType = "pmg"
case ConnectionTypeVMware:
if m.config.DisableAllVMware {
return true
}
thresholdType = "vmware-host"
case ConnectionTypeTrueNAS:
if m.config.DisableAllTrueNAS {
return true
}
thresholdType = "truenas-system"
default:
return true
}
if allDisabled, offlineDisabled := m.alertPolicyTypeSwitchesNoLock(thresholdType); allDisabled || offlineDisabled {
return true
}
policyResourceID = strings.TrimSpace(policyResourceID)
if policyResourceID == "" {

View file

@ -259,7 +259,7 @@ func (m *Manager) CheckDockerHost(host models.DockerHost) {
m.mu.RLock()
alertsEnabled := m.config.Enabled
disableAllDockerHosts := m.config.DisableAllDockerHosts
disableAllDockerHosts, _ := m.alertPolicyTypeSwitchesNoLock("docker-host")
ignoredPrefixes := append([]string(nil), m.config.DockerIgnoredContainerPrefixes...)
m.mu.RUnlock()
if !alertsEnabled {
@ -310,7 +310,7 @@ func (m *Manager) CheckDockerHost(host models.DockerHost) {
func (m *Manager) evaluateDockerContainer(host models.DockerHost, container models.DockerContainer, resourceID string) {
m.mu.RLock()
disableAllContainers := m.config.DisableAllDockerContainers
disableAllContainers, _ := m.alertPolicyTypeSwitchesNoLock("app-container")
m.mu.RUnlock()
if disableAllContainers {
return
@ -475,7 +475,7 @@ func (m *Manager) evaluateDockerContainer(host models.DockerHost, container mode
func (m *Manager) evaluateDockerService(host models.DockerHost, service models.DockerService, resourceID string) {
m.mu.RLock()
disableAllServices := m.config.DisableAllDockerServices
disableAllServices, _ := m.alertPolicyTypeSwitchesNoLock("docker-service")
warnPct := m.config.DockerDefaults.ServiceWarnGapPct
critPct := m.config.DockerDefaults.ServiceCritGapPct
overrideConfig, hasOverride := m.config.Overrides[resourceID]
@ -694,7 +694,7 @@ func (m *Manager) HandleDockerHostOffline(host models.DockerHost) {
m.mu.RUnlock()
return
}
disableDockerHostsOffline := m.config.DisableAllDockerHostsOffline
_, disableDockerHostsOffline := m.alertPolicyTypeSwitchesNoLock("docker-host")
m.mu.RUnlock()
resourceID := fmt.Sprintf("docker:%s", strings.TrimSpace(host.ID))
@ -1280,7 +1280,7 @@ func (m *Manager) shouldResolveDockerContainerUpdateAlertLocked(alert *Alert) bo
return false
}
if m.config.DisableAllDockerContainers || m.config.DockerDefaults.UpdateAlertDelayHours < 0 {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("app-container"); allDisabled || m.config.DockerDefaults.UpdateAlertDelayHours < 0 {
m.clearDockerContainerUpdateStateLocked(alert)
return true
}

View file

@ -89,7 +89,7 @@ func (m *Manager) SyncExternalProbes(snapshots []ExternalProbeSnapshot) {
func (m *Manager) checkExternalProbe(snapshot ExternalProbeSnapshot) {
m.mu.RLock()
enabled := m.config.Enabled
disabled := m.config.DisableAllAgentsOffline
_, disabled := m.alertPolicyTypeSwitchesNoLock("agent")
thresholds := m.resolveHostThresholdsNoLock(snapshot.AgentID, "", "", "")
m.mu.RUnlock()
if !enabled || disabled || thresholds.Disabled || thresholds.DisableConnectivity {

View file

@ -6,7 +6,6 @@ import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rs/zerolog/log"
)
// evaluateFilterCondition evaluates a single filter condition against a guest
@ -211,37 +210,5 @@ func (m *Manager) evaluateFilterStack(guest any, stack FilterStack) bool {
// getGuestThresholds returns the appropriate thresholds for a guest
// Priority: Guest-specific overrides > Custom rules (by priority) > Global defaults
func (m *Manager) getGuestThresholds(guest any, guestID string) ThresholdConfig {
thresholds := cloneThresholdConfig(m.config.GuestDefaults)
// Check custom rules (sorted by priority, highest first)
var applicableRule *CustomAlertRule
highestPriority := -1
for i := range m.config.CustomRules {
rule := &m.config.CustomRules[i]
if !rule.Enabled {
continue
}
// Check if this rule applies to the guest
if m.evaluateFilterStack(guest, rule.FilterConditions) {
if rule.Priority > highestPriority {
applicableRule = rule
highestPriority = rule.Priority
}
}
}
// Apply custom rule thresholds if found
if applicableRule != nil {
thresholds = m.applyThresholdOverride(thresholds, applicableRule.Thresholds)
log.Debug().
Str("guest", guestID).
Str("rule", applicableRule.Name).
Int("priority", applicableRule.Priority).
Msg("Applied custom alert rule")
}
return m.resolveGuestThresholdOverride(thresholds, guest, guestID)
return m.effectiveAlertPolicyNoLock(alertPolicyQuery{TypeKey: "vm", ResourceID: guestID, Guest: guest}).Thresholds
}

View file

@ -90,8 +90,7 @@ func (m *Manager) ReevaluateGuestAlert(guest any, guestID string) {
func (m *Manager) CheckGuest(guest any, instanceName string) {
m.mu.RLock()
enabled := m.config.Enabled
disableAllGuests := m.config.DisableAllGuests
disableAllGuestsOffline := m.config.DisableAllGuestsOffline
disableAllGuests, disableAllGuestsOffline := m.alertPolicyTypeSwitchesNoLock("vm")
m.mu.RUnlock()
if !enabled {

View file

@ -229,7 +229,7 @@ func (m *Manager) CheckHost(host models.Host) {
m.mu.RLock()
alertsEnabled := m.config.Enabled
disableAllAgents := m.config.DisableAllAgents
disableAllAgents, _ := m.alertPolicyTypeSwitchesNoLock("agent")
thresholds := m.resolveHostThresholdsNoLock(host.ID, host.LinkedNodeID, host.LinkedVMID, host.LinkedContainerID)
// An explicit disk temperature override (host or inherited linked-resource)
// beats the per-type defaults in DiskTempByType.
@ -648,7 +648,7 @@ func (m *Manager) HandleHostTelemetryExpired(host models.Host) {
m.mu.RLock()
alertsEnabled := m.config.Enabled
disableAllAgents := m.config.DisableAllAgents
disableAllAgents, _ := m.alertPolicyTypeSwitchesNoLock("agent")
thresholds := m.resolveHostThresholdsNoLock(host.ID, host.LinkedNodeID, host.LinkedVMID, host.LinkedContainerID)
m.mu.RUnlock()
if !alertsEnabled || disableAllAgents || thresholds.Disabled {
@ -726,7 +726,7 @@ func (m *Manager) HandleHostOffline(host models.Host) {
m.mu.RUnlock()
return
}
disableHostsOffline := m.config.DisableAllAgentsOffline
_, disableHostsOffline := m.alertPolicyTypeSwitchesNoLock("agent")
thresholds := m.resolveHostThresholdsNoLock(host.ID, host.LinkedNodeID, host.LinkedVMID, host.LinkedContainerID)
m.mu.RUnlock()

View file

@ -24,7 +24,7 @@ func (m *Manager) CheckNode(node models.Node) {
m.mu.RUnlock()
return
}
if m.config.DisableAllNodes {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("node"); allDisabled {
m.mu.RUnlock()
// Clear any existing node alerts when all node alerts are disabled
m.mu.Lock()
@ -53,7 +53,7 @@ func (m *Manager) CheckNode(node models.Node) {
m.mu.Unlock()
return
}
disableNodesOffline := m.config.DisableAllNodesOffline
_, disableNodesOffline := m.alertPolicyTypeSwitchesNoLock("node")
thresholds := m.resolveResourceThresholds("node", node.ID)
m.mu.RUnlock()

View file

@ -33,7 +33,7 @@ func (m *Manager) CheckPBS(pbs models.PBSInstance) {
m.mu.RUnlock()
return
}
if m.config.DisableAllPBS {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("pbs"); allDisabled {
m.mu.RUnlock()
// Clear any existing PBS alerts when all PBS alerts are disabled
m.mu.Lock()
@ -67,7 +67,7 @@ func (m *Manager) CheckPBS(pbs models.PBSInstance) {
}
thresholds := m.resolveResourceThresholds("pbs", pbs.ID)
disablePBSOffline := m.config.DisableAllPBSOffline
_, disablePBSOffline := m.alertPolicyTypeSwitchesNoLock("pbs")
m.mu.RUnlock()
// Check override disable BEFORE offline detection to prevent spurious notifications

View file

@ -74,7 +74,7 @@ func (m *Manager) CheckPMG(pmg models.PMGInstance) {
m.mu.RUnlock()
return
}
if m.config.DisableAllPMG {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("pmg"); allDisabled {
m.mu.RUnlock()
// Clear any existing PMG alerts when all PMG alerts are disabled.
m.mu.Lock()
@ -86,7 +86,7 @@ func (m *Manager) CheckPMG(pmg models.PMGInstance) {
// Check if there's an override for this PMG instance
override, hasOverride := m.config.Overrides[pmg.ID]
disablePMGOffline := m.config.DisableAllPMGOffline
_, disablePMGOffline := m.alertPolicyTypeSwitchesNoLock("pmg")
pmgDefaults := m.config.PMGDefaults
m.mu.RUnlock()

View file

@ -21,7 +21,7 @@ func (m *Manager) CheckStorage(storage models.Storage) {
return
}
resourceIDs := storageAlertResourceIDs(storage)
if m.config.DisableAllStorage {
if allDisabled, _ := m.alertPolicyTypeSwitchesNoLock("storage"); allDisabled {
m.mu.RUnlock()
// Clear any existing storage alerts when all storage alerts are disabled
m.mu.Lock()

View file

@ -249,13 +249,7 @@ func (m *Manager) resolveGuestThresholdOverride(base ThresholdConfig, guest any,
// resolveResourceThresholds builds the effective thresholds for a resource type and ID.
// Callers must hold m.mu when reading config through this helper.
func (m *Manager) resolveResourceThresholds(typeKey, resourceID string) ThresholdConfig {
if isGuestThresholdResourceType(typeKey) {
return m.resolveGuestThresholdOverride(m.defaultThresholdsForResourceType(typeKey), nil, resourceID)
}
if typeKey == "storage" {
return m.resolveStorageThresholdOverride(m.defaultThresholdsForResourceType(typeKey), resourceID, nil)
}
return m.resolveThresholdOverride(m.defaultThresholdsForResourceType(typeKey), resourceID)
return m.effectiveAlertPolicyNoLock(alertPolicyQuery{TypeKey: typeKey, ResourceID: resourceID}).Thresholds
}
// evaluateUnifiedMetrics runs the common metric dispatch path for unified resources.

View file

@ -51,10 +51,10 @@ func (m *Manager) SyncUnifiedResourceIncidents(resources []unifiedresources.Reso
m.mu.RLock()
enabled := m.config.Enabled
disableAllStorage := m.config.DisableAllStorage
disableAllKubernetes := m.config.DisableAllKubernetes
disableAllTrueNAS := m.config.DisableAllTrueNAS
disableAllVMware := m.config.DisableAllVMware
disableAllStorage, _ := m.alertPolicyTypeSwitchesNoLock("storage")
disableAllKubernetes, _ := m.alertPolicyTypeSwitchesNoLock("k8s-node")
disableAllTrueNAS, _ := m.alertPolicyTypeSwitchesNoLock("truenas-system")
disableAllVMware, _ := m.alertPolicyTypeSwitchesNoLock("vmware-host")
overrides := m.config.Overrides
m.mu.RUnlock()