2727namespace LibreNMS \Alert ;
2828
2929use App \Facades \LibrenmsConfig ;
30+ use App \Models \Alert ;
31+ use App \Models \AlertOperationSegment ;
32+ use App \Models \AlertRule ;
3033use App \Models \Device ;
3134use App \Models \DeviceGroup ;
3235use App \Models \User ;
3336use DeviceCache ;
3437use Illuminate \Database \Eloquent \Builder ;
3538use Illuminate \Support \Arr ;
39+ use Illuminate \Support \Collection ;
40+ use Illuminate \Support \Facades \DB ;
41+ use LibreNMS \Enum \AlertRuleOperationPhase ;
3642use LibreNMS \Enum \MaintenanceStatus ;
3743use PHPMailer \PHPMailer \PHPMailer ;
3844
@@ -46,23 +52,203 @@ class AlertUtil
4652 */
4753 private static function getRuleId ($ alert_id )
4854 {
49- $ query = 'SELECT `rule_id` FROM `alerts` WHERE `id`=? ' ;
55+ return Alert::find ($ alert_id )?->rule_id;
56+ }
57+
58+ /**
59+ * Map the alert state to the operation phase
60+ *
61+ * @param int $state The alert state
62+ * @return string The operation phase
63+ */
64+ public static function mapAlertStateToOperationPhase (int $ state ): string
65+ {
66+ // UI-defined operations currently use problem phase only.
67+ // Keep all states mapped to problem until dedicated phase config is reintroduced.
68+ unset($ state );
69+
70+ return AlertRuleOperationPhase::PROBLEM ;
71+ }
72+
73+ /**
74+ * True when the rule has at least one operation row (notifications are configured at the operation level).
75+ */
76+ public static function ruleHasAlertOperations (int $ ruleId ): bool
77+ {
78+ return AlertRule::find ($ ruleId )?->alert_operation_id !== null ;
79+ }
80+
81+ /**
82+ * Merge timing for the problem phase from alert_rule_operations into $rextra
83+ * (delay, interval, count) so RunAlerts can reuse existing scheduling logic.
84+ * When the rule has no operations at all, notifications are treated as suppressed (mute).
85+ *
86+ * @param array<string, mixed> $details alert_log details (decoded)
87+ * @param array<string, mixed> $rextra rule extra (decoded)
88+ */
89+ public static function mergeProblemPhaseTimingFromOperations (int $ ruleId , array &$ details , array &$ rextra ): void
90+ {
91+ unset($ rextra ['_stop_notifications ' ]);
92+
93+ $ ruleRow = AlertRule::query ()
94+ ->with ('alertOperation:id,default_operation_step_duration_seconds,notifications_suppressed ' )
95+ ->whereKey ($ ruleId )
96+ ->first (['id ' , 'alert_operation_id ' ]);
97+ if ($ ruleRow === null || $ ruleRow ->alert_operation_id === null ) {
98+ $ rextra ['mute ' ] = true ;
99+
100+ return ;
101+ }
102+
103+ $ ops = AlertOperationSegment::where ('alert_operation_id ' , $ ruleRow ->alert_operation_id )
104+ ->where ('operation_phase ' , AlertRuleOperationPhase::PROBLEM )
105+ ->orderBy ('position ' )
106+ ->orderBy ('id ' )
107+ ->get ();
108+
109+ if ($ ops ->isEmpty ()) {
110+ return ;
111+ }
112+
113+ $ opDefault = $ ruleRow ->alertOperation ?->default_operation_step_duration_seconds;
114+ if ($ opDefault === null ) {
115+ $ opDefault = max (0 , 60 * (int ) LibrenmsConfig::get ('alert_rule.default_operation_step_duration ' , LibrenmsConfig::get ('alert_rule.interval ' )));
116+ }
117+ $ defaultStep = max (0 , (int ) $ opDefault );
118+
119+ $ notificationCount = (int ) ($ details ['count ' ] ?? 0 );
120+ $ nextStep = $ notificationCount + 1 ;
121+
122+ $ op = self ::selectOperationForEscalationStep ($ ops , $ nextStep );
123+ if ($ op === null ) {
124+ $ rextra ['_stop_notifications ' ] = true ;
50125
51- return dbFetchCell ($ query , [$ alert_id ]);
126+ return ;
127+ }
128+
129+ if ($ ruleRow ->alertOperation === null || (bool ) $ ruleRow ->alertOperation ->notifications_suppressed ) {
130+ $ rextra ['mute ' ] = true ;
131+
132+ return ;
133+ }
134+
135+ $ rextra ['delay ' ] = $ notificationCount === 0 ? (int ) $ op ->start_in_seconds : 0 ;
136+ $ stepDur = (int ) $ op ->step_duration_seconds ;
137+ $ rextra ['interval ' ] = $ stepDur > 0 ? $ stepDur : $ defaultStep ;
138+ // Keep legacy runAlerts() counter incrementing for escalation tracking.
139+ $ rextra ['count ' ] = PHP_INT_MAX ;
140+ }
141+
142+ public static function selectOperationForEscalationStep (Collection $ operations , int $ escalationStep ): ?AlertOperationSegment
143+ {
144+ foreach ($ operations as $ op ) {
145+ $ from = (int ) $ op ->escalation_step_from ;
146+ $ to = $ op ->escalation_step_to === null ? null : (int ) $ op ->escalation_step_to ;
147+ if ($ escalationStep < $ from ) {
148+ continue ;
149+ }
150+ if ($ to !== null && $ escalationStep > $ to ) {
151+ continue ;
152+ }
153+
154+ if ($ op instanceof AlertOperationSegment) {
155+ return $ op ;
156+ }
157+ }
158+
159+ return null ;
52160 }
53161
54162 /**
55- * Get the transport for a given alert_id
163+ * Get transports for a given alert (per operation phase and escalation step).
56164 *
57165 * @param int $alert_id
58- * @return array
166+ * @param string|null $operation_phase problem|recovery|update; inferred from alerts.state if null
167+ * @param int $escalation_step 1-based escalation step for problem operations
168+ * @return array<int, array<string, mixed>>
59169 */
60- public static function getAlertTransports ($ alert_id )
170+ public static function getAlertTransports ($ alert_id, ? string $ operation_phase = null , int $ escalation_step = 1 )
61171 {
62- $ query = "SELECT b.transport_id, b.transport_type, b.transport_name FROM alert_transport_map AS a LEFT JOIN alert_transports AS b ON b.transport_id=a.transport_or_group_id WHERE a.target_type='single' AND a.rule_id=? UNION DISTINCT SELECT d.transport_id, d.transport_type, d.transport_name FROM alert_transport_map AS a LEFT JOIN alert_transport_groups AS b ON a.transport_or_group_id=b.transport_group_id LEFT JOIN transport_group_transport AS c ON b.transport_group_id=c.transport_group_id LEFT JOIN alert_transports AS d ON c.transport_id=d.transport_id WHERE a.target_type='group' AND a.rule_id=? " ;
63172 $ rule_id = self ::getRuleId ($ alert_id );
173+ if ($ rule_id === null ) {
174+ return [];
175+ }
176+
177+ if ($ operation_phase === null ) {
178+ // Use Eloquent instead of raw SQL for the alert state lookup.
179+ $ state = (int ) (Alert::query ()->where ('id ' , $ alert_id )->value ('state ' ) ?? 0 );
180+ $ operation_phase = self ::mapAlertStateToOperationPhase ($ state );
181+ }
182+
183+ if (! self ::ruleHasAlertOperations ((int ) $ rule_id )) {
184+ return [];
185+ }
186+
187+ $ rule = AlertRule::query ()->whereKey ($ rule_id )->first (['alert_operation_id ' ]);
188+ if ($ rule === null || $ rule ->alert_operation_id === null ) {
189+ return [];
190+ }
191+
192+ $ operationId = $ rule ->alert_operation_id ;
193+ // Prefer the alert's phase; if no segments exist for recovery/update, fall back to problem
194+ // (UI-defined operations store segments as problem-only).
195+ $ phasesToTry = $ operation_phase === AlertRuleOperationPhase::PROBLEM
196+ ? [AlertRuleOperationPhase::PROBLEM ]
197+ : [$ operation_phase , AlertRuleOperationPhase::PROBLEM ];
198+
199+ $ single = collect ();
200+ $ group = collect ();
201+ foreach ($ phasesToTry as $ phase ) {
202+ // “Single” transports mapped per segment.
203+ $ single = DB ::table ('alert_operation_segments ' )
204+ ->join ('alert_operation_transport_map as m ' , 'm.segment_id ' , '= ' , 'alert_operation_segments.id ' )
205+ ->join ('alert_transports as b ' , 'b.transport_id ' , '= ' , 'm.transport_or_group_id ' )
206+ ->where ('m.target_type ' , '= ' , 'single ' )
207+ ->where ('alert_operation_segments.alert_operation_id ' , '= ' , $ operationId )
208+ ->where ('alert_operation_segments.operation_phase ' , '= ' , $ phase )
209+ ->where ('alert_operation_segments.escalation_step_from ' , '<= ' , $ escalation_step )
210+ ->where (function ($ q ) use ($ escalation_step ): void {
211+ $ q ->whereNull ('alert_operation_segments.escalation_step_to ' )
212+ ->orWhereRaw ('? <= alert_operation_segments.escalation_step_to ' , [$ escalation_step ]);
213+ })
214+ ->select (['b.transport_id ' , 'b.transport_type ' , 'b.transport_name ' ])
215+ ->distinct ()
216+ ->get ();
217+
218+ // Transport groups: expand group membership via transport_group_transport.
219+ $ group = DB ::table ('alert_operation_segments ' )
220+ ->join ('alert_operation_transport_map as m ' , 'm.segment_id ' , '= ' , 'alert_operation_segments.id ' )
221+ ->join ('alert_transport_groups as g ' , 'g.transport_group_id ' , '= ' , 'm.transport_or_group_id ' )
222+ ->join ('transport_group_transport as c ' , 'c.transport_group_id ' , '= ' , 'g.transport_group_id ' )
223+ ->join ('alert_transports as d ' , 'd.transport_id ' , '= ' , 'c.transport_id ' )
224+ ->where ('m.target_type ' , '= ' , 'group ' )
225+ ->where ('alert_operation_segments.alert_operation_id ' , '= ' , $ operationId )
226+ ->where ('alert_operation_segments.operation_phase ' , '= ' , $ phase )
227+ ->where ('alert_operation_segments.escalation_step_from ' , '<= ' , $ escalation_step )
228+ ->where (function ($ q ) use ($ escalation_step ): void {
229+ $ q ->whereNull ('alert_operation_segments.escalation_step_to ' )
230+ ->orWhereRaw ('? <= alert_operation_segments.escalation_step_to ' , [$ escalation_step ]);
231+ })
232+ ->select (['d.transport_id ' , 'd.transport_type ' , 'd.transport_name ' ])
233+ ->distinct ()
234+ ->get ();
235+
236+ if ($ single ->isNotEmpty () || $ group ->isNotEmpty ()) {
237+ break ;
238+ }
239+ }
64240
65- return dbFetchRows ($ query , [$ rule_id , $ rule_id ]);
241+ return $ single
242+ ->concat ($ group )
243+ // Keep result stable and prevent duplicates when the same transport appears via both mappings.
244+ ->unique ('transport_id ' )
245+ ->values ()
246+ ->map (static fn ($ row ) => [
247+ 'transport_id ' => (int ) $ row ->transport_id ,
248+ 'transport_type ' => (string ) $ row ->transport_type ,
249+ 'transport_name ' => (string ) $ row ->transport_name ,
250+ ])
251+ ->all ();
66252 }
67253
68254 /**
0 commit comments