Files
pulse/internal/api/patrol_finding_notifications.go
T
rcourtman 23695681ac Route new Patrol findings to alert notification channels
Patrol findings previously reached operators only through the Relay
mobile push path. Anyone relying on the email, webhook, or Apprise
destinations they already configured for alerts got no proactive signal
when Patrol detected a problem and had to open /patrol to learn about
it. This is the delivery half of #1369.

Each newly stored warning or critical finding now also flows through a
FindingNotifyCallback wired in the router, which projects the finding
into the alert shape the notification manager delivers. The callback
fires only on the genuinely-new path in recordFindingWithInvestigation,
so a finding notifies at most once per lifetime regardless of how many
later runs re-detect it, and SendAlert's own per-ID cooldown backstops
that. Demo mode never notifies.

Gating lives in AIConfig via patrol_finding_notifications_enabled
(default on, matching the long-standing default for mobile push) and
patrol_finding_notify_min_severity (warning or critical, default
warning). The enabled flag persists without omitempty so an explicit
opt-out survives reload while pre-existing configs inherit the default.
The settings surface for these fields follows in a separate commit once
the AI settings handler is free.
2026-07-18 11:59:05 +01:00

62 lines
1.6 KiB
Go

package api
import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
)
// patrolFindingAlertIDPrefix namespaces synthetic notification alerts so
// their IDs can never collide with threshold alert IDs in the notification
// manager's cooldown map.
const patrolFindingAlertIDPrefix = "patrol-finding-"
// patrolFindingNotificationAlert projects a Patrol finding into the alert
// shape the notification manager delivers, so findings reach the operator's
// existing email, webhook, and Apprise destinations. The synthetic alert is
// never stored in the active-alert store; it exists only for delivery.
func patrolFindingNotificationAlert(f *ai.Finding) *alerts.Alert {
if f == nil {
return nil
}
level := alerts.AlertLevelWarning
if f.Severity == ai.FindingSeverityCritical {
level = alerts.AlertLevelCritical
}
startTime := f.DetectedAt
if startTime.IsZero() {
startTime = time.Now()
}
lastSeen := f.LastSeenAt
if lastSeen.IsZero() {
lastSeen = startTime
}
metadata := map[string]interface{}{
"findingId": f.ID,
"category": string(f.Category),
}
if f.Description != "" {
metadata["description"] = f.Description
}
if f.Recommendation != "" {
metadata["recommendation"] = f.Recommendation
}
return &alerts.Alert{
ID: patrolFindingAlertIDPrefix + f.ID,
Type: "patrol_finding",
Level: level,
ResourceID: f.ResourceID,
ResourceName: f.ResourceName,
Node: f.Node,
Message: f.Title,
StartTime: startTime,
LastSeen: lastSeen,
Metadata: metadata,
}
}