@@ -9,63 +9,25 @@ import (
99 "github.com/pocketbase/pocketbase/core"
1010)
1111
12- type alertTask struct {
13- action string // "schedule" or "cancel"
14- systemName string
15- alertRecord * core.Record
16- delay time.Duration
17- }
18-
1912type alertInfo struct {
2013 systemName string
2114 alertRecord * core.Record
2215 expireTime time.Time
16+ timer * time.Timer
2317}
2418
25- // startWorker is a long-running goroutine that processes alert tasks
26- // every x seconds. It must be running to process status alerts.
27- func (am * AlertManager ) startWorker () {
28- processPendingAlerts := time .Tick (15 * time .Second )
29-
30- // check for status alerts that are not resolved when system comes up
31- // (can be removed if we figure out core bug in #1052)
32- checkStatusAlerts := time .Tick (561 * time .Second )
33-
34- for {
35- select {
36- case <- am .stopChan :
37- return
38- case task := <- am .alertQueue :
39- switch task .action {
40- case "schedule" :
41- am .pendingAlerts .Store (task .alertRecord .Id , & alertInfo {
42- systemName : task .systemName ,
43- alertRecord : task .alertRecord ,
44- expireTime : time .Now ().Add (task .delay ),
45- })
46- case "cancel" :
47- am .pendingAlerts .Delete (task .alertRecord .Id )
19+ // Stop cancels all pending status alert timers.
20+ func (am * AlertManager ) Stop () {
21+ am .stopOnce .Do (func () {
22+ am .pendingAlerts .Range (func (key , value any ) bool {
23+ info := value .(* alertInfo )
24+ if info .timer != nil {
25+ info .timer .Stop ()
4826 }
49- case <- checkStatusAlerts :
50- resolveStatusAlerts (am .hub )
51- case <- processPendingAlerts :
52- // Check for expired alerts every tick
53- now := time .Now ()
54- for key , value := range am .pendingAlerts .Range {
55- info := value .(* alertInfo )
56- if now .After (info .expireTime ) {
57- // Downtime delay has passed, process alert
58- am .sendStatusAlert ("down" , info .systemName , info .alertRecord )
59- am .pendingAlerts .Delete (key )
60- }
61- }
62- }
63- }
64- }
65-
66- // StopWorker shuts down the AlertManager.worker goroutine
67- func (am * AlertManager ) StopWorker () {
68- close (am .stopChan )
27+ am .pendingAlerts .Delete (key )
28+ return true
29+ })
30+ })
6931}
7032
7133// HandleStatusAlerts manages the logic when system status changes.
@@ -103,38 +65,43 @@ func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record,
10365 return alertRecords , nil
10466}
10567
106- // Schedules delayed "down" alerts for each alert record.
68+ // handleSystemDown manages the logic when a system status changes to "down". It schedules pending alerts for each alert record.
10769func (am * AlertManager ) handleSystemDown (systemName string , alertRecords []* core.Record ) {
10870 for _ , alertRecord := range alertRecords {
109- // Continue if alert is already scheduled
110- if _ , exists := am .pendingAlerts .Load (alertRecord .Id ); exists {
111- continue
112- }
113- // Schedule by adding to queue
11471 min := max (1 , alertRecord .GetInt ("min" ))
115- am .alertQueue <- alertTask {
116- action : "schedule" ,
117- systemName : systemName ,
118- alertRecord : alertRecord ,
119- delay : time .Duration (min ) * time .Minute ,
120- }
72+ am .schedulePendingStatusAlert (systemName , alertRecord , time .Duration (min )* time .Minute )
12173 }
12274}
12375
76+ // schedulePendingStatusAlert sets up a timer to send a "down" alert after the specified delay if the system is still down.
77+ // It returns true if the alert was scheduled, or false if an alert was already pending for the given alert record.
78+ func (am * AlertManager ) schedulePendingStatusAlert (systemName string , alertRecord * core.Record , delay time.Duration ) bool {
79+ alert := & alertInfo {
80+ systemName : systemName ,
81+ alertRecord : alertRecord ,
82+ expireTime : time .Now ().Add (delay ),
83+ }
84+
85+ storedAlert , loaded := am .pendingAlerts .LoadOrStore (alertRecord .Id , alert )
86+ if loaded {
87+ return false
88+ }
89+
90+ stored := storedAlert .(* alertInfo )
91+ stored .timer = time .AfterFunc (time .Until (stored .expireTime ), func () {
92+ am .processPendingAlert (alertRecord .Id )
93+ })
94+ return true
95+ }
96+
12497// handleSystemUp manages the logic when a system status changes to "up".
12598// It cancels any pending alerts and sends "up" alerts.
12699func (am * AlertManager ) handleSystemUp (systemName string , alertRecords []* core.Record ) {
127100 for _ , alertRecord := range alertRecords {
128- alertRecordID := alertRecord .Id
129101 // If alert exists for record, delete and continue (down alert not sent)
130- if _ , exists := am .pendingAlerts .Load (alertRecordID ); exists {
131- am .alertQueue <- alertTask {
132- action : "cancel" ,
133- alertRecord : alertRecord ,
134- }
102+ if am .cancelPendingAlert (alertRecord .Id ) {
135103 continue
136104 }
137- // No alert scheduled for this record, send "up" alert only if "down" was triggered
138105 if ! alertRecord .GetBool ("triggered" ) {
139106 continue
140107 }
@@ -144,6 +111,36 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
144111 }
145112}
146113
114+ // cancelPendingAlert stops the timer and removes the pending alert for the given alert ID. Returns true if a pending alert was found and cancelled.
115+ func (am * AlertManager ) cancelPendingAlert (alertID string ) bool {
116+ value , loaded := am .pendingAlerts .LoadAndDelete (alertID )
117+ if ! loaded {
118+ return false
119+ }
120+
121+ info := value .(* alertInfo )
122+ if info .timer != nil {
123+ info .timer .Stop ()
124+ }
125+ return true
126+ }
127+
128+ // processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
129+ func (am * AlertManager ) processPendingAlert (alertID string ) {
130+ value , loaded := am .pendingAlerts .LoadAndDelete (alertID )
131+ if ! loaded {
132+ return
133+ }
134+
135+ info := value .(* alertInfo )
136+ if info .alertRecord .GetBool ("triggered" ) {
137+ return
138+ }
139+ if err := am .sendStatusAlert ("down" , info .systemName , info .alertRecord ); err != nil {
140+ am .hub .Logger ().Error ("Failed to send alert" , "err" , err )
141+ }
142+ }
143+
147144// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
148145func (am * AlertManager ) sendStatusAlert (alertStatus string , systemName string , alertRecord * core.Record ) error {
149146 switch alertStatus {
@@ -177,8 +174,8 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
177174 })
178175}
179176
180- // resolveStatusAlerts resolves any status alerts that weren't resolved
181- // when system came up (https://github.com/henrygd/beszel/issues/1052)
177+ // resolveStatusAlerts resolves any triggered status alerts that weren't resolved
178+ // when system came up (https://github.com/henrygd/beszel/issues/1052).
182179func resolveStatusAlerts (app core.App ) error {
183180 db := app .DB ()
184181 // Find all active status alerts where the system is actually up
@@ -208,3 +205,36 @@ func resolveStatusAlerts(app core.App) error {
208205 }
209206 return nil
210207}
208+
209+ // restorePendingStatusAlerts re-queues untriggered status alerts for systems that
210+ // are still down after a hub restart. This rebuilds the lost in-memory timer state.
211+ func (am * AlertManager ) restorePendingStatusAlerts () error {
212+ type pendingStatusAlert struct {
213+ AlertID string `db:"alert_id"`
214+ SystemName string `db:"system_name"`
215+ }
216+
217+ var pending []pendingStatusAlert
218+ err := am .hub .DB ().NewQuery (`
219+ SELECT a.id AS alert_id, s.name AS system_name
220+ FROM alerts a
221+ JOIN systems s ON a.system = s.id
222+ WHERE a.name = 'Status'
223+ AND a.triggered = false
224+ AND s.status = 'down'
225+ ` ).All (& pending )
226+ if err != nil {
227+ return err
228+ }
229+
230+ for _ , item := range pending {
231+ alertRecord , err := am .hub .FindRecordById ("alerts" , item .AlertID )
232+ if err != nil {
233+ return err
234+ }
235+ min := max (1 , alertRecord .GetInt ("min" ))
236+ am .schedulePendingStatusAlert (item .SystemName , alertRecord , time .Duration (min )* time .Minute )
237+ }
238+
239+ return nil
240+ }
0 commit comments