Skip to content

Commit 7d54b1c

Browse files
authored
Add list and detail view for sent notifications (#512)
This PR implements the Notification History list and per-notification detail view. Changes: - Added `NotificationHistory` model + renderer + detail widget, with new controllers for list/detail views. - Extended incident detail with a `Notification History` tab and scoped search suggestions. - Added supporting enums, condition description helper, styling, and PHPUnit tests. resolves #475 ### require: - Icinga/icinga-notifications#472 - Icinga/ipl-web#392
2 parents 2526c40 + a243b9d commit 7d54b1c

26 files changed

Lines changed: 1156 additions & 38 deletions
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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\Controllers;
7+
8+
use Icinga\Module\Notifications\Common\Auth;
9+
use Icinga\Module\Notifications\Common\Database;
10+
use Icinga\Module\Notifications\Model\NotificationHistory;
11+
use Icinga\Module\Notifications\View\NotificationRenderer;
12+
use Icinga\Module\Notifications\Web\Control\SearchBar\ObjectSuggestions;
13+
use Icinga\Module\Notifications\Widget\ItemList\ObjectList;
14+
use ipl\Web\Common\Controls;
15+
use ipl\Web\Compat\CompatController;
16+
use ipl\Web\Compat\SearchControls;
17+
use ipl\Web\Control\LimitControl;
18+
use ipl\Web\Control\SortControl;
19+
use ipl\Web\Control\ViewModeSwitcher;
20+
use ipl\Web\Filter\QueryString;
21+
use ipl\Web\Layout\DetailedItemLayout;
22+
use ipl\Web\Layout\ItemLayout;
23+
use ipl\Web\Layout\MinimalItemLayout;
24+
25+
class HistoryController extends CompatController
26+
{
27+
use Auth;
28+
use SearchControls;
29+
use Controls;
30+
31+
public function indexAction(): void
32+
{
33+
$this->addTitleTab(t('Notification History'));
34+
35+
$notificationHistory = NotificationHistory::on(Database::get())
36+
->with(['channel.available_channel_type', 'contact', 'contactgroup', 'schedule']);
37+
38+
$limitControl = $this->createLimitControl();
39+
$sortControl = $this->createSortControl(
40+
$notificationHistory,
41+
[
42+
'notification_history.triggered_at desc' => t('Triggered At'),
43+
'notification_history.state, notification_history.triggered_at desc' => t('State')
44+
]
45+
);
46+
47+
$paginationControl = $this->createPaginationControl($notificationHistory);
48+
$viewModeSwitcher = $this->createViewModeSwitcher();
49+
$searchBar = $this->createSearchBar($notificationHistory, [
50+
$limitControl->getLimitParam(),
51+
$sortControl->getSortParam(),
52+
$viewModeSwitcher->getViewModeParam()
53+
]);
54+
55+
$this->handleControls($this->getServerRequest());
56+
57+
if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
58+
if ($searchBar->hasBeenSubmitted()) {
59+
$filter = QueryString::parse((string) $this->params);
60+
} else {
61+
$this->addControl($searchBar);
62+
$this->sendMultipartUpdate();
63+
return;
64+
}
65+
} else {
66+
$filter = $searchBar->getFilter();
67+
}
68+
69+
$this->applyRestrictions($notificationHistory);
70+
$notificationHistory->filter($filter);
71+
72+
$this->addControl($paginationControl);
73+
$this->addControl($sortControl);
74+
$this->addControl($limitControl);
75+
$this->addControl($viewModeSwitcher);
76+
$this->addControl($searchBar);
77+
78+
$this->addContent(
79+
(new ObjectList($notificationHistory, new NotificationRenderer()))
80+
->setItemLayoutClass(match ($viewModeSwitcher->getViewMode()->getName()) {
81+
'minimal' => MinimalItemLayout::class,
82+
'detailed' => DetailedItemLayout::class,
83+
'common' => ItemLayout::class
84+
})
85+
);
86+
87+
if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
88+
$this->sendMultipartUpdate();
89+
}
90+
91+
$this->setAutorefreshInterval(10);
92+
}
93+
94+
public function completeAction(): void
95+
{
96+
$suggestions = new ObjectSuggestions();
97+
$suggestions->setModel(NotificationHistory::class);
98+
$suggestions->forRequest($this->getServerRequest());
99+
$this->getDocument()->add($suggestions);
100+
}
101+
102+
public function searchEditorAction(): void
103+
{
104+
$editor = $this->createSearchEditor(NotificationHistory::on(Database::get()), [
105+
LimitControl::DEFAULT_LIMIT_PARAM,
106+
SortControl::DEFAULT_SORT_PARAM,
107+
ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
108+
]);
109+
110+
$this->getDocument()->add($editor);
111+
$this->setTitle(t('Adjust Filter'));
112+
}
113+
}

application/controllers/IncidentController.php

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,42 @@
55

66
namespace Icinga\Module\Notifications\Controllers;
77

8+
use ipl\Web\Common\Controls;
9+
use ipl\Web\Compat\SearchControls;
810
use Icinga\Module\Notifications\Common\Auth;
911
use Icinga\Module\Notifications\Common\Database;
1012
use Icinga\Module\Notifications\Common\Links;
1113
use Icinga\Module\Notifications\Model\Contact;
1214
use Icinga\Module\Notifications\Model\Incident;
15+
use Icinga\Module\Notifications\Model\NotificationHistory;
16+
use Icinga\Module\Notifications\View\NotificationRenderer;
17+
use Icinga\Module\Notifications\Web\Control\SearchBar\ObjectSuggestions;
1318
use Icinga\Module\Notifications\Widget\Detail\IncidentDetail;
1419
use Icinga\Module\Notifications\Widget\Detail\IncidentQuickActions;
1520
use Icinga\Module\Notifications\Widget\Detail\ObjectHeader;
21+
use Icinga\Module\Notifications\Widget\ItemList\ObjectList;
1622
use ipl\Html\Attributes;
1723
use ipl\Html\Contract\Form;
1824
use ipl\Stdlib\Filter;
1925
use ipl\Web\Compat\CompatController;
26+
use ipl\Web\Control\LimitControl;
27+
use ipl\Web\Control\SortControl;
28+
use ipl\Web\Filter\QueryString;
29+
use ipl\Web\Layout\DetailedItemLayout;
30+
use ipl\Web\Layout\ItemLayout;
31+
use ipl\Web\Layout\MinimalItemLayout;
32+
use ipl\Web\Url;
2033

2134
class IncidentController extends CompatController
2235
{
2336
use Auth;
37+
use SearchControls;
38+
use Controls;
2439

2540
public function indexAction(): void
2641
{
27-
$this->addTitleTab(t('Incident'));
42+
$this->setTitle(t('Incident'));
43+
$this->setTitleTab($this->getRequest()->getActionName());
2844

2945
$id = $this->params->getRequired('id');
3046

@@ -63,4 +79,123 @@ public function indexAction(): void
6379

6480
$this->addContent(new IncidentDetail($incident));
6581
}
82+
83+
public function notificationHistoryAction(): void
84+
{
85+
$this->setTitle(t('Notification History'));
86+
$this->setTitleTab($this->getRequest()->getActionName());
87+
88+
$notificationHistory = NotificationHistory::on(Database::get())
89+
->with(['channel', 'contact', 'contactgroup', 'schedule'])
90+
->filter(Filter::equal('notification_history.incident_id', $this->params->shiftRequired('id')));
91+
$this->applyRestrictions($notificationHistory);
92+
93+
$limitControl = $this->createLimitControl();
94+
$sortControl = $this->createSortControl(
95+
$notificationHistory,
96+
[
97+
'notification_history.triggered_at desc' => t('Triggered At'),
98+
'notification_history.state, notification_history.triggered_at desc' => t('State')
99+
]
100+
);
101+
102+
$paginationControl = $this->createPaginationControl($notificationHistory);
103+
$viewModeSwitcher = $this->createViewModeSwitcher();
104+
105+
$searchBar = $this->createSearchBar(
106+
$notificationHistory,
107+
[
108+
$limitControl->getLimitParam(),
109+
$sortControl->getSortParam(),
110+
$viewModeSwitcher->getViewModeParam(),
111+
'id'
112+
]
113+
);
114+
115+
$this->handleControls($this->getServerRequest());
116+
117+
if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
118+
if ($searchBar->hasBeenSubmitted()) {
119+
$filter = QueryString::parse((string) $this->params);
120+
} else {
121+
$this->addControl($searchBar);
122+
$this->sendMultipartUpdate();
123+
return;
124+
}
125+
} else {
126+
$filter = $searchBar->getFilter();
127+
}
128+
129+
$notificationHistory->filter($filter);
130+
131+
$this->addControl($paginationControl);
132+
$this->addControl($sortControl);
133+
$this->addControl($limitControl);
134+
$this->addControl($viewModeSwitcher);
135+
$this->addControl($searchBar);
136+
137+
$this->addContent(
138+
(new ObjectList($notificationHistory, new NotificationRenderer()))
139+
->setItemLayoutClass(match ($viewModeSwitcher->getViewMode()->getName()) {
140+
'minimal' => MinimalItemLayout::class,
141+
'detailed' => DetailedItemLayout::class,
142+
'common' => ItemLayout::class
143+
})
144+
);
145+
146+
if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
147+
$this->sendMultipartUpdate();
148+
}
149+
150+
$this->setAutorefreshInterval(30);
151+
}
152+
153+
public function completeAction(): void
154+
{
155+
$suggestions = (new ObjectSuggestions())
156+
->setModel(NotificationHistory::class)
157+
->setBaseFilter(
158+
Filter::equal('notification_history.incident_id', $this->params->getRequired('id'))
159+
)
160+
->forRequest($this->getServerRequest());
161+
162+
$this->getDocument()->addHtml($suggestions);
163+
}
164+
165+
public function searchEditorAction(): void
166+
{
167+
$preserveParams = [
168+
LimitControl::DEFAULT_LIMIT_PARAM,
169+
SortControl::DEFAULT_SORT_PARAM,
170+
'id'
171+
];
172+
173+
$editor = $this->createSearchEditor(
174+
NotificationHistory::on(Database::get()),
175+
Url::fromPath('notifications/incident/notification-history', ['id' => $this->params->getRequired('id')]),
176+
$preserveParams
177+
);
178+
$editor->setSuggestionUrl(
179+
Url::fromPath('notifications/incident/complete')
180+
->setParams(Url::fromRequest()->onlyWith($preserveParams)->getParams())
181+
);
182+
183+
$this->getDocument()->addHtml($editor);
184+
$this->setTitle($this->translate('Adjust Filter'));
185+
}
186+
187+
protected function setTitleTab(string $name): void
188+
{
189+
$id = $this->params->getRequired('id');
190+
$this->getTabs()
191+
->add('index', [
192+
'label' => $this->translate('Incident'),
193+
'url' => Links::incident($id)
194+
])
195+
->add('notification-history', [
196+
'label' => $this->translate('Notification History'),
197+
'url' => Url::fromPath('notifications/incident/notification-history', ['id' => $id])
198+
])
199+
->activate($name);
200+
}
66201
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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\Controllers;
7+
8+
use Icinga\Module\Notifications\Common\Auth;
9+
use Icinga\Module\Notifications\Common\Database;
10+
use Icinga\Module\Notifications\Model\NotificationHistory;
11+
use Icinga\Module\Notifications\Widget\Detail\ObjectHeader;
12+
use Icinga\Module\Notifications\Widget\Detail\NotificationDetail;
13+
use ipl\Html\Attributes;
14+
use ipl\Stdlib\Filter;
15+
use ipl\Web\Compat\CompatController;
16+
17+
class NotificationController extends CompatController
18+
{
19+
use Auth;
20+
21+
public function indexAction(): void
22+
{
23+
$this->addTitleTab(t('Notification'));
24+
25+
$id = $this->params->getRequired('id');
26+
27+
$query = NotificationHistory::on(Database::get())
28+
->with([
29+
'contact',
30+
'contactgroup',
31+
'schedule',
32+
'channel',
33+
'incident',
34+
'incident.object',
35+
])
36+
->withColumns(['incident.object.sources'])
37+
->filter(Filter::equal('id', $id));
38+
39+
$this->applyRestrictions($query);
40+
41+
/** @var NotificationHistory $notificationHistory */
42+
$notificationHistory = $query->first();
43+
if ($notificationHistory === null) {
44+
$this->httpNotFound(t('Notification not found'));
45+
}
46+
47+
$this->controls->addAttributes(Attributes::create(['class' => 'notification-history-detail']));
48+
$this->addControl(new ObjectHeader($notificationHistory));
49+
$this->addContent(new NotificationDetail($notificationHistory));
50+
}
51+
}

configuration.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@
4040
]
4141
);
4242

43+
$section->add(
44+
N_('History'),
45+
[
46+
'icon' => 'paper-plane',
47+
'description' => $this->translate('History'),
48+
'url' => 'notifications/history',
49+
'priority' => 20
50+
]
51+
);
52+
4353
if ($configLandingPage !== null) {
4454
$section->add(
4555
N_('Configuration'),

library/Notifications/Common/Icons.php

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

66
namespace Icinga\Module\Notifications\Common;
77

8+
use Icinga\Module\Notifications\Widget\IconBall;
9+
use UnhandledMatchError;
10+
use UnitEnum;
11+
812
final class Icons
913
{
1014
private function __construct()
@@ -58,4 +62,30 @@ private function __construct()
5862
public const MUTE = 'volume-xmark';
5963

6064
public const UNMUTE = 'volume-high';
65+
66+
public const CONTACTGROUP = 'users';
67+
68+
public const SCHEDULE = 'calendar';
69+
70+
/**
71+
* Get the icon for the given enum
72+
*
73+
* @param UnitEnum $enum
74+
*
75+
* @return IconBall
76+
*
77+
* @throws UnhandledMatchError If no icon is known for the given enum
78+
*/
79+
public static function forEnum(UnitEnum $enum): IconBall
80+
{
81+
return new IconBall(match ($enum) {
82+
IncidentHistoryType::OPENED => Icons::OPENED,
83+
IncidentHistoryType::MUTED => Icons::MUTE,
84+
IncidentHistoryType::UNMUTED => Icons::UNMUTE,
85+
IncidentHistoryType::CLOSED => Icons::CLOSED,
86+
IncidentHistoryType::RULE_MATCHED => Icons::RULE_MATCHED,
87+
IncidentHistoryType::ESCALATION_TRIGGERED => Icons::TRIGGERED,
88+
IncidentHistoryType::NOTIFIED => Icons::NOTIFIED
89+
});
90+
}
6191
}

0 commit comments

Comments
 (0)