Skip to content

Commit 17db7e2

Browse files
committed
Merge branch 'master' into WIP-add-restify-api - Resolve merge conflicts
2 parents db07dff + 196f695 commit 17db7e2

194 files changed

Lines changed: 1313801 additions & 2213 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/lint.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ jobs:
5353
- name: Checkout Code
5454
uses: actions/checkout@v6
5555
with:
56+
fetch-depth: 2
5657
sparse-checkout-cone-mode: false
5758
sparse-checkout: |
5859
/*
@@ -78,6 +79,21 @@ jobs:
7879
- name: Composer dump
7980
run: composer dump --strict-psr --strict-ambiguous --no-interaction --no-scripts
8081

82+
- name: Find new PHP files
83+
if: github.event_name == 'pull_request'
84+
id: new-php-files
85+
run: |
86+
# HEAD is the PR merge commit, HEAD~1 is its first parent (base branch tip)
87+
NEW_PHP_FILES=$(git diff -z --diff-filter=A --name-only HEAD~1 HEAD -- 'app/**.php' 'bootstrap/**.php' 'config/**.php' 'database/**.php' 'LibreNMS/**.php' 'resources/**.php' 'routes/**.php' 'tests/**.php' | xargs -0)
88+
echo "Discovered new PHP files: $NEW_PHP_FILES"
89+
echo "files=$NEW_PHP_FILES" >> "$GITHUB_OUTPUT"
90+
91+
- name: Run PHPStan (New files, level 6)
92+
if: github.event_name == 'pull_request' && steps.new-php-files.outputs.files != ''
93+
env:
94+
NEW_PHP_FILES: ${{ steps.new-php-files.outputs.files }}
95+
run: ./vendor/bin/phpstan analyze --no-interaction --memory-limit=4G --level=6 -- $NEW_PHP_FILES
96+
8197
- name: Run PHPStan
8298
run: ./vendor/bin/phpstan analyze --no-interaction --memory-limit=4G
8399

LibreNMS/Alert/AlertUtil.php

Lines changed: 193 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,18 @@
2727
namespace LibreNMS\Alert;
2828

2929
use App\Facades\LibrenmsConfig;
30+
use App\Models\Alert;
31+
use App\Models\AlertOperationSegment;
32+
use App\Models\AlertRule;
3033
use App\Models\Device;
3134
use App\Models\DeviceGroup;
3235
use App\Models\User;
3336
use DeviceCache;
3437
use Illuminate\Database\Eloquent\Builder;
3538
use Illuminate\Support\Arr;
39+
use Illuminate\Support\Collection;
40+
use Illuminate\Support\Facades\DB;
41+
use LibreNMS\Enum\AlertRuleOperationPhase;
3642
use LibreNMS\Enum\MaintenanceStatus;
3743
use 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
/**

LibreNMS/Alert/RunAlerts.php

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@
3535
use App\Facades\LibrenmsConfig;
3636
use App\Facades\Rrd;
3737
use App\Models\Alert;
38+
use App\Models\AlertLog;
3839
use App\Models\AlertTransport;
3940
use App\Models\ApplicationMetric;
4041
use App\Models\Eventlog;
4142
use Illuminate\Support\Facades\DB;
4243
use Illuminate\Support\Facades\Log;
4344
use LibreNMS\Alerting\QueryBuilderParser;
45+
use LibreNMS\Enum\AlertRuleOperationPhase;
4446
use LibreNMS\Enum\AlertState;
4547
use LibreNMS\Enum\MaintenanceStatus;
4648
use LibreNMS\Enum\Severity;
@@ -228,6 +230,13 @@ public function describeAlert($alert)
228230
$obj['alerted'] = $alert['alerted'];
229231
$obj['template'] = $template;
230232

233+
$obj['operation_phase'] = AlertUtil::mapAlertStateToOperationPhase((int) $alert['state']);
234+
$detailCount = (int) ($extra['count'] ?? 0);
235+
$obj['escalation_step'] = max(1, $detailCount);
236+
if ($obj['operation_phase'] !== AlertRuleOperationPhase::PROBLEM) {
237+
$obj['escalation_step'] = 1;
238+
}
239+
231240
return $obj;
232241
}
233242

@@ -507,6 +516,17 @@ public function runAlerts()
507516
$rextra['recovery'] = true;
508517
}
509518

519+
if (in_array($alert['state'], [AlertState::ACTIVE, AlertState::WORSE, AlertState::BETTER, AlertState::CHANGED], true)) {
520+
AlertUtil::mergeProblemPhaseTimingFromOperations((int) $alert['rule_id'], $alert['details'], $rextra);
521+
}
522+
523+
if (! empty($rextra['_stop_notifications'])) {
524+
unset($rextra['_stop_notifications']);
525+
echo 'Max escalation steps reached for Alert-UID #' . $alert['id'] . "\r\n";
526+
527+
continue;
528+
}
529+
510530
if (! isset($alert['details']['count'])) {
511531
// make sure count is set for below code, in legacy code null would get type juggled to 0
512532
$alert['details']['count'] = 0;
@@ -518,7 +538,7 @@ public function runAlerts()
518538

519539
if ($status_check === null) {
520540
Log::warning("Alert #{$alert['id']} references non-existent device {$alert['device_id']}, cleaning up");
521-
Alert::query()->where('id', $alert['id'])->delete();
541+
AlertLog::query()->where('id', $alert['id'])->delete();
522542

523543
continue;
524544
}
@@ -633,10 +653,23 @@ public function extTransports($obj)
633653
$type = new Template;
634654

635655
// If alert transport mapping exists, override the default transports
636-
$transport_maps = AlertUtil::getAlertTransports($obj['alert_id']);
656+
$transport_maps = AlertUtil::getAlertTransports(
657+
$obj['alert_id'],
658+
$obj['operation_phase'] ?? null,
659+
(int) ($obj['escalation_step'] ?? 1)
660+
);
637661

662+
$ruleId = (int) ($obj['rule_id'] ?? 0);
638663
if (! $transport_maps) {
639-
$transport_maps = AlertUtil::getDefaultAlertTransports();
664+
$reason = 'No mapped transport for this operation';
665+
if ($ruleId > 0 && ! AlertUtil::ruleHasAlertOperations($ruleId)) {
666+
$reason = 'No operations configured for this rule';
667+
}
668+
669+
Eventlog::log($reason . ' (notification skipped)', $obj['device_id'], 'alert', Severity::Notice);
670+
c_echo(" :: Skipped => $reason");
671+
672+
return;
640673
}
641674

642675
// alerting for default contacts, etc

LibreNMS/ComposerHelper.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ private static function execComposerCommand(array $command, array $env = []): in
183183
$cli[] = "$key=$value";
184184
}
185185
$cli[] = PHP_BINARY;
186-
$cli[] = realpath(__DIR__ . '/../../scripts/composer_wrapper.php');
186+
$cli[] = realpath(__DIR__ . '/../scripts/composer_wrapper.php');
187187
foreach ($command as $word) {
188188
$cli[] = escapeshellarg($word);
189189
}

LibreNMS/Discovery/Yaml/YamlDiscoveryField.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public function calculateValue(array $yaml, array $data, string $index, int $cou
5151
if (array_key_exists($this->key, $yaml)) {
5252
$key = $this->key;
5353
} else {
54-
$key = $this->default;
54+
$key = is_int($this->default) ? $this->default : (string) $this->default;
5555
$yaml = [$key => $this->default];
5656
}
5757

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/**
4+
* Operation lists: Operations (problem), Recovery operations, Update operations.
5+
*/
6+
7+
namespace LibreNMS\Enum;
8+
9+
abstract class AlertRuleOperationPhase
10+
{
11+
/** Problem escalations” */
12+
public const PROBLEM = 'problem';
13+
14+
/** “Recovery operations” */
15+
public const RECOVERY = 'recovery';
16+
17+
/** “Update operations” (acknowledgements, etc.) */
18+
public const UPDATE = 'update';
19+
}

0 commit comments

Comments
 (0)