Skip to content

Commit a3c57a3

Browse files
IncidentHistory: cast type and relate notification history
Replaces the string comparisons in `IncidentHistoryRenderer` with the new `IncidentHistoryType` enum and describes unnamed escalations by their condition. Co-Authored-By: Sukhwinder Dhillon <sukhwinder.dhillon@icinga.com>
1 parent 904e8fb commit a3c57a3

4 files changed

Lines changed: 182 additions & 27 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
4+
// SPDX-License-Identifier: GPL-3.0-or-later
5+
6+
namespace Icinga\Module\Notifications\Common;
7+
8+
/**
9+
* Incident history entry types
10+
*
11+
* Each case maps to the backing string stored in the `type` column of the `incident_history` table.
12+
* Register {@see \ipl\Orm\Behavior\EnumCast} on a model to have those columns hydrated automatically as enum instances.
13+
*/
14+
enum IncidentHistoryType: string
15+
{
16+
case INCIDENT_SEVERITY_CHANGED = 'incident_severity_changed';
17+
case ESCALATION_TRIGGERED = 'escalation_triggered';
18+
case CLOSED = 'closed';
19+
case OPENED = 'opened';
20+
case MUTED = 'muted';
21+
case UNMUTED = 'unmuted';
22+
case RECIPIENT_ROLE_CHANGED = 'recipient_role_changed';
23+
case NOTIFIED = 'notified';
24+
case RULE_MATCHED = 'rule_matched';
25+
26+
/**
27+
* Get the backing string value
28+
*
29+
* @return string
30+
*/
31+
public function getValue(): string
32+
{
33+
return $this->value;
34+
}
35+
36+
/**
37+
* Get the translated label
38+
*
39+
* @return string
40+
*/
41+
public function getLabel(): string
42+
{
43+
return match ($this) {
44+
self::OPENED => t('Incident opened'),
45+
self::INCIDENT_SEVERITY_CHANGED => t('Incident severity changed'),
46+
self::ESCALATION_TRIGGERED => t('Escalation triggered'),
47+
self::MUTED => t('Incident muted'),
48+
self::UNMUTED => t('Incident unmuted'),
49+
self::CLOSED => t('Incident closed'),
50+
self::RECIPIENT_ROLE_CHANGED => t('Recipient role changed'),
51+
self::NOTIFIED => t('Notified'),
52+
self::RULE_MATCHED => t('Rule matched')
53+
};
54+
}
55+
}

library/Notifications/Model/IncidentHistory.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
namespace Icinga\Module\Notifications\Model;
77

88
use DateTime;
9+
use Icinga\Module\Notifications\Common\Collection;
910
use Icinga\Module\Notifications\Common\Database;
11+
use Icinga\Module\Notifications\Common\IncidentHistoryType;
1012
use Icinga\Module\Notifications\Common\Model;
1113
use Icinga\Module\Notifications\Common\Severity;
1214
use ipl\Orm\Behavior\EnumCast;
@@ -25,7 +27,7 @@
2527
* @property ?int $rule_id
2628
* @property ?int $rule_escalation_id
2729
* @property DateTime $time
28-
* @property string $type
30+
* @property IncidentHistoryType $type
2931
* @property ?int $contact_id
3032
* @property ?int $schedule_id
3133
* @property ?int $contactgroup_id
@@ -37,6 +39,7 @@
3739
* @property ?string $message
3840
* @property ?string $notification_state
3941
* @property ?DateTime $sent_at
42+
* @property ?string $event_id
4043
*
4144
* @property Query<Incident>|Incident $incident
4245
* @property Query<Contact>|Contact $contact
@@ -45,6 +48,7 @@
4548
* @property Query<Rule>|Rule $rule
4649
* @property Query<RuleEscalation>|RuleEscalation $rule_escalation
4750
* @property Query<Channel>|Channel $channel
51+
* @property Query<NotificationHistory>|Collection<NotificationHistory> $notification_history
4852
*/
4953
class IncidentHistory extends Model
5054
{
@@ -76,7 +80,8 @@ public function getColumns(): array
7680
'old_recipient_role',
7781
'message',
7882
'notification_state',
79-
'sent_at'
83+
'sent_at',
84+
'event_id'
8085
];
8186
}
8287

@@ -103,6 +108,7 @@ public function createBehaviors(Behaviors $behaviors): void
103108
{
104109
$behaviors->add(new MillisecondTimestamp(['time', 'sent_at']));
105110
$behaviors->add(new EnumCast(Severity::class, ['new_severity', 'old_severity']));
111+
$behaviors->add(new EnumCast(IncidentHistoryType::class, ['type']));
106112
}
107113

108114
public function getDefaultSort(): array
@@ -133,6 +139,7 @@ public function createRelations(Relations $relations): void
133139
$relations->belongsTo('rule', Rule::class)->setJoinType('LEFT');
134140
$relations->belongsTo('rule_escalation', RuleEscalation::class)->setJoinType('LEFT');
135141
$relations->belongsTo('channel', Channel::class)->setJoinType('LEFT');
142+
$relations->hasMany('notification_history', NotificationHistory::class)->setJoinType('LEFT');
136143
}
137144

138145
/**

library/Notifications/View/IncidentHistoryRenderer.php

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
namespace Icinga\Module\Notifications\View;
77

8+
use Icinga\Module\Notifications\Common\EscalationConditionDescriber;
89
use Icinga\Module\Notifications\Common\Icons;
10+
use Icinga\Module\Notifications\Common\IncidentHistoryType;
911
use Icinga\Module\Notifications\Model\IncidentHistory;
1012
use Icinga\Module\Notifications\Widget\IconBall;
1113
use ipl\Html\Attributes;
@@ -26,7 +28,7 @@ class IncidentHistoryRenderer implements ItemRenderer
2628
public function assembleAttributes($item, Attributes $attributes, string $layout): void
2729
{
2830
$classes = ['incident-history'];
29-
if ($item->type === 'notified') {
31+
if ($item->type === IncidentHistoryType::NOTIFIED) {
3032
$classes[] = 'notification-state';
3133
if ($item->notification_state === 'suppressed') {
3234
$classes[] = 'suppressed';
@@ -40,20 +42,21 @@ public function assembleAttributes($item, Attributes $attributes, string $layout
4042

4143
public function assembleVisual($item, HtmlDocument $visual, string $layout): void
4244
{
43-
if ($item->type === 'incident_severity_changed') {
45+
if ($item->type === IncidentHistoryType::INCIDENT_SEVERITY_CHANGED) {
4446
$content = $item->new_severity->getIcon();
4547
} else {
46-
$content = new IconBall(match ($item->type) {
47-
'opened' => Icons::OPENED,
48-
'muted' => Icons::MUTE,
49-
'unmuted' => Icons::UNMUTE,
50-
'recipient_role_changed' => $this->getRoleIcon($item),
51-
'closed' => Icons::CLOSED,
52-
'rule_matched' => Icons::RULE_MATCHED,
53-
'escalation_triggered' => Icons::TRIGGERED,
54-
'notified' => Icons::NOTIFIED,
55-
default => Icons::UNDEFINED
56-
});
48+
$content = new IconBall(
49+
match ($item->type) {
50+
IncidentHistoryType::OPENED => Icons::OPENED,
51+
IncidentHistoryType::MUTED => Icons::MUTE,
52+
IncidentHistoryType::UNMUTED => Icons::UNMUTE,
53+
IncidentHistoryType::RECIPIENT_ROLE_CHANGED => $this->getRoleIcon($item),
54+
IncidentHistoryType::CLOSED => Icons::CLOSED,
55+
IncidentHistoryType::RULE_MATCHED => Icons::RULE_MATCHED,
56+
IncidentHistoryType::ESCALATION_TRIGGERED => Icons::TRIGGERED,
57+
IncidentHistoryType::NOTIFIED => Icons::NOTIFIED
58+
}
59+
);
5760
}
5861

5962
$visual->addHtml($content);
@@ -119,18 +122,18 @@ protected function getRoleIcon(IncidentHistory $item): string
119122
protected function buildMessage(IncidentHistory $item): ValidHtml
120123
{
121124
switch ($item->type) {
122-
case 'opened':
125+
case IncidentHistoryType::OPENED:
123126
$message = sprintf(
124127
$this->translate('Incident opened at severity %s'),
125128
$item->new_severity->getLabel()
126129
);
127130

128131
break;
129-
case 'closed':
132+
case IncidentHistoryType::CLOSED:
130133
$message = $this->translate('Incident closed');
131134

132135
break;
133-
case "notified":
136+
case IncidentHistoryType::NOTIFIED:
134137
if (isset($item->contactgroup->name) && isset($item->contact->full_name)) {
135138
if (isset($item->channel->type)) {
136139
$message = sprintf(
@@ -200,15 +203,15 @@ protected function buildMessage(IncidentHistory $item): ValidHtml
200203
}
201204

202205
break;
203-
case 'incident_severity_changed':
206+
case IncidentHistoryType::INCIDENT_SEVERITY_CHANGED:
204207
$message = sprintf(
205208
$this->translate('Incident severity changed from %s to %s'),
206209
$item->old_severity->getLabel(),
207210
$item->new_severity->getLabel()
208211
);
209212

210213
break;
211-
case 'recipient_role_changed':
214+
case IncidentHistoryType::RECIPIENT_ROLE_CHANGED:
212215
$newRole = $item->new_recipient_role;
213216
$message = '';
214217
if ($newRole === 'manager' || (! $newRole && $item->old_recipient_role === 'manager')) {
@@ -251,21 +254,23 @@ protected function buildMessage(IncidentHistory $item): ValidHtml
251254
}
252255

253256
break;
254-
case 'rule_matched':
257+
case IncidentHistoryType::RULE_MATCHED:
255258
if (isset($item->rule->name)) {
256259
$message = sprintf($this->translate('Rule %s matched on this incident'), $item->rule->name);
257260
} else {
258261
$message = $this->translate('Unknown rule matched on this incident');
259262
}
260263

261264
break;
262-
case 'escalation_triggered':
265+
case IncidentHistoryType::ESCALATION_TRIGGERED:
263266
if (isset($item->rule->name)) {
264-
if (isset($item->rule_escalation->name)) {
267+
if (isset($item->rule_escalation->id)) {
265268
$message = sprintf(
266269
$this->translate('Rule %s reached escalation %s'),
267270
$item->rule->name,
271+
// An escalation is only named optionally, its condition describes it otherwise
268272
$item->rule_escalation->name
273+
?? EscalationConditionDescriber::describe($item->rule_escalation->condition)
269274
);
270275
} else {
271276
$message = sprintf(
@@ -278,16 +283,14 @@ protected function buildMessage(IncidentHistory $item): ValidHtml
278283
}
279284

280285
break;
281-
case 'muted':
286+
case IncidentHistoryType::MUTED:
282287
$message = $this->translate('Notifications for this incident have been muted');
283288

284289
break;
285-
case 'unmuted':
290+
case IncidentHistoryType::UNMUTED:
286291
$message = $this->translate('Notifications for this incident have been unmuted');
287292

288293
break;
289-
default:
290-
$message = '';
291294
}
292295

293296
$messageFromDb = $item->message ? ': ' . $item->message : '';
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
4+
// SPDX-License-Identifier: GPL-3.0-or-later
5+
6+
namespace Tests\Icinga\Module\Notifications\Common;
7+
8+
use Icinga\Module\Notifications\Common\IncidentHistoryType;
9+
use PHPUnit\Framework\Attributes\DataProvider;
10+
use PHPUnit\Framework\TestCase;
11+
12+
class IncidentHistoryTypeTest extends TestCase
13+
{
14+
public static function backingValueProvider(): array
15+
{
16+
return [
17+
'incident_severity_changed' => [
18+
IncidentHistoryType::INCIDENT_SEVERITY_CHANGED,
19+
'incident_severity_changed'
20+
],
21+
'escalation_triggered' => [IncidentHistoryType::ESCALATION_TRIGGERED, 'escalation_triggered'],
22+
'opened' => [IncidentHistoryType::OPENED, 'opened'],
23+
'closed' => [IncidentHistoryType::CLOSED, 'closed'],
24+
'muted' => [IncidentHistoryType::MUTED, 'muted'],
25+
'unmuted' => [IncidentHistoryType::UNMUTED, 'unmuted'],
26+
'recipient_role_changed' => [
27+
IncidentHistoryType::RECIPIENT_ROLE_CHANGED,
28+
'recipient_role_changed'
29+
],
30+
'notified' => [IncidentHistoryType::NOTIFIED, 'notified'],
31+
'rule_matched' => [IncidentHistoryType::RULE_MATCHED, 'rule_matched']
32+
];
33+
}
34+
35+
#[DataProvider('backingValueProvider')]
36+
public function testBackingValueIsTheDbString(IncidentHistoryType $type, string $expected): void
37+
{
38+
$this->assertSame($expected, $type->value);
39+
}
40+
41+
#[DataProvider('backingValueProvider')]
42+
public function testFromParsesDbString(IncidentHistoryType $expected, string $dbValue): void
43+
{
44+
$this->assertSame($expected, IncidentHistoryType::from($dbValue));
45+
}
46+
47+
#[DataProvider('backingValueProvider')]
48+
public function testGetValueReturnsBackingValue(IncidentHistoryType $type, string $expected): void
49+
{
50+
$this->assertSame($expected, $type->getValue());
51+
}
52+
53+
public function testGetLabelCoversAllCases(): void
54+
{
55+
$this->assertSame(
56+
'Incident severity changed',
57+
IncidentHistoryType::INCIDENT_SEVERITY_CHANGED->getLabel()
58+
);
59+
$this->assertSame('Escalation triggered', IncidentHistoryType::ESCALATION_TRIGGERED->getLabel());
60+
$this->assertSame('Incident opened', IncidentHistoryType::OPENED->getLabel());
61+
$this->assertSame('Incident closed', IncidentHistoryType::CLOSED->getLabel());
62+
$this->assertSame('Incident muted', IncidentHistoryType::MUTED->getLabel());
63+
$this->assertSame('Incident unmuted', IncidentHistoryType::UNMUTED->getLabel());
64+
$this->assertSame(
65+
'Recipient role changed',
66+
IncidentHistoryType::RECIPIENT_ROLE_CHANGED->getLabel()
67+
);
68+
$this->assertSame('Notified', IncidentHistoryType::NOTIFIED->getLabel());
69+
$this->assertSame('Rule matched', IncidentHistoryType::RULE_MATCHED->getLabel());
70+
}
71+
72+
public function testAllCasesAreCovered(): void
73+
{
74+
$this->assertSame(
75+
[
76+
IncidentHistoryType::INCIDENT_SEVERITY_CHANGED,
77+
IncidentHistoryType::ESCALATION_TRIGGERED,
78+
IncidentHistoryType::CLOSED,
79+
IncidentHistoryType::OPENED,
80+
IncidentHistoryType::MUTED,
81+
IncidentHistoryType::UNMUTED,
82+
IncidentHistoryType::RECIPIENT_ROLE_CHANGED,
83+
IncidentHistoryType::NOTIFIED,
84+
IncidentHistoryType::RULE_MATCHED
85+
],
86+
IncidentHistoryType::cases(),
87+
'An IncidentHistoryType case was added or removed — update the test providers and this assertion'
88+
);
89+
}
90+
}

0 commit comments

Comments
 (0)