Skip to content

Commit a456c81

Browse files
BastianLedererIcingasukhwinder33445
authored andcommitted
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 e2bbeaa commit a456c81

5 files changed

Lines changed: 206 additions & 24 deletions

File tree

library/Notifications/Common/Icons.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55

66
namespace Icinga\Module\Notifications\Common;
77

8+
use Icinga\Module\Notifications\Widget\IconBall;
9+
use ipl\Orm\Exception\ValueConversionException;
10+
use UnhandledMatchError;
11+
use UnitEnum;
12+
813
final class Icons
914
{
1015
private function __construct()
@@ -58,4 +63,30 @@ private function __construct()
5863
public const MUTE = 'volume-xmark';
5964

6065
public const UNMUTE = 'volume-high';
66+
67+
public const CONTACTGROUP = 'users';
68+
69+
public const SCHEDULE = 'calendar';
70+
71+
/**
72+
* Get the icon for the given enum
73+
*
74+
* @param UnitEnum $enum
75+
*
76+
* @return IconBall
77+
*
78+
* @throws UnhandledMatchError If no icon is known for the given enum
79+
*/
80+
public static function forEnum(UnitEnum $enum): IconBall
81+
{
82+
return new IconBall(match ($enum) {
83+
IncidentHistoryType::OPENED => Icons::OPENED,
84+
IncidentHistoryType::MUTED => Icons::MUTE,
85+
IncidentHistoryType::UNMUTED => Icons::UNMUTE,
86+
IncidentHistoryType::CLOSED => Icons::CLOSED,
87+
IncidentHistoryType::RULE_MATCHED => Icons::RULE_MATCHED,
88+
IncidentHistoryType::ESCALATION_TRIGGERED => Icons::TRIGGERED,
89+
IncidentHistoryType::NOTIFIED => Icons::NOTIFIED
90+
});
91+
}
6192
}
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: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
namespace Icinga\Module\Notifications\View;
77

88
use Icinga\Module\Notifications\Common\Icons;
9+
use Icinga\Module\Notifications\Common\IncidentHistoryType;
910
use Icinga\Module\Notifications\Model\IncidentHistory;
1011
use Icinga\Module\Notifications\Widget\IconBall;
1112
use ipl\Html\Attributes;
@@ -17,6 +18,7 @@
1718
use ipl\I18n\Translation;
1819
use ipl\Web\Common\ItemRenderer;
1920
use ipl\Web\Widget\TimeAgo;
21+
use UnhandledMatchError;
2022

2123
/** @implements ItemRenderer<IncidentHistory> */
2224
class IncidentHistoryRenderer implements ItemRenderer
@@ -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,16 @@ 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();
47+
} elseif ($item->type === IncidentHistoryType::RECIPIENT_ROLE_CHANGED) {
48+
$content = new IconBall($this->getRoleIcon($item));
4549
} 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-
});
50+
try {
51+
$content = Icons::forEnum($item->type);
52+
} catch (UnhandledMatchError) {
53+
$content = new IconBall(Icons::UNDEFINED);
54+
}
5755
}
5856

5957
$visual->addHtml($content);
@@ -119,18 +117,18 @@ protected function getRoleIcon(IncidentHistory $item): string
119117
protected function buildMessage(IncidentHistory $item): ValidHtml
120118
{
121119
switch ($item->type) {
122-
case 'opened':
120+
case IncidentHistoryType::OPENED:
123121
$message = sprintf(
124122
$this->translate('Incident opened at severity %s'),
125123
$item->new_severity->getLabel()
126124
);
127125

128126
break;
129-
case 'closed':
127+
case IncidentHistoryType::CLOSED:
130128
$message = $this->translate('Incident closed');
131129

132130
break;
133-
case "notified":
131+
case IncidentHistoryType::NOTIFIED:
134132
if (isset($item->contactgroup->name) && isset($item->contact->full_name)) {
135133
if (isset($item->channel->type)) {
136134
$message = sprintf(
@@ -200,15 +198,15 @@ protected function buildMessage(IncidentHistory $item): ValidHtml
200198
}
201199

202200
break;
203-
case 'incident_severity_changed':
201+
case IncidentHistoryType::INCIDENT_SEVERITY_CHANGED:
204202
$message = sprintf(
205203
$this->translate('Incident severity changed from %s to %s'),
206204
$item->old_severity->getLabel(),
207205
$item->new_severity->getLabel()
208206
);
209207

210208
break;
211-
case 'recipient_role_changed':
209+
case IncidentHistoryType::RECIPIENT_ROLE_CHANGED:
212210
$newRole = $item->new_recipient_role;
213211
$message = '';
214212
if ($newRole === 'manager' || (! $newRole && $item->old_recipient_role === 'manager')) {
@@ -251,16 +249,17 @@ protected function buildMessage(IncidentHistory $item): ValidHtml
251249
}
252250

253251
break;
254-
case 'rule_matched':
252+
case IncidentHistoryType::RULE_MATCHED:
255253
if (isset($item->rule->name)) {
256254
$message = sprintf($this->translate('Rule %s matched on this incident'), $item->rule->name);
257255
} else {
258256
$message = $this->translate('Unknown rule matched on this incident');
259257
}
260258

261259
break;
262-
case 'escalation_triggered':
260+
case IncidentHistoryType::ESCALATION_TRIGGERED:
263261
if (isset($item->rule->name)) {
262+
// TODO: No name is no reason to claim an unknown escalation, describe conditions instead
264263
if (isset($item->rule_escalation->name)) {
265264
$message = sprintf(
266265
$this->translate('Rule %s reached escalation %s'),
@@ -278,11 +277,11 @@ protected function buildMessage(IncidentHistory $item): ValidHtml
278277
}
279278

280279
break;
281-
case 'muted':
280+
case IncidentHistoryType::MUTED:
282281
$message = $this->translate('Notifications for this incident have been muted');
283282

284283
break;
285-
case 'unmuted':
284+
case IncidentHistoryType::UNMUTED:
286285
$message = $this->translate('Notifications for this incident have been unmuted');
287286

288287
break;
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)