diff --git a/application/controllers/IncidentsController.php b/application/controllers/IncidentsController.php index af0d338e6..5022a2611 100644 --- a/application/controllers/IncidentsController.php +++ b/application/controllers/IncidentsController.php @@ -7,24 +7,25 @@ use Icinga\Module\Notifications\Common\Auth; use Icinga\Module\Notifications\Common\Database; -use Icinga\Module\Notifications\Hook\ObjectsRendererHook; use Icinga\Module\Notifications\Model\Incident; use Icinga\Module\Notifications\View\IncidentRenderer; use Icinga\Module\Notifications\Web\Control\SearchBar\ObjectSuggestions; use Icinga\Module\Notifications\Widget\ItemList\ObjectList; +use ipl\Web\Common\Controls; use ipl\Web\Compat\CompatController; use ipl\Web\Compat\SearchControls; use ipl\Web\Control\LimitControl; use ipl\Web\Control\SortControl; use ipl\Web\Filter\QueryString; +use ipl\Web\Layout\DetailedItemLayout; +use ipl\Web\Layout\ItemLayout; use ipl\Web\Layout\MinimalItemLayout; -use ipl\Web\Widget\ItemList; -use ipl\Web\Widget\ListItem; class IncidentsController extends CompatController { use Auth; use SearchControls; + use Controls; public function indexAction(): void { @@ -45,11 +46,15 @@ public function indexAction(): void ); $paginationControl = $this->createPaginationControl($incidents); + $viewModeSwitcher = $this->createViewModeSwitcher($this->params); $searchBar = $this->createSearchBar($incidents, [ $limitControl->getLimitParam(), $sortControl->getSortParam(), ]); + $this->applyViewModeLimit($limitControl, $paginationControl); + $this->handleControls($this->getServerRequest()); + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { if ($searchBar->hasBeenSubmitted()) { $filter = QueryString::parse((string) $this->params); @@ -68,18 +73,17 @@ public function indexAction(): void $this->addControl($paginationControl); $this->addControl($sortControl); $this->addControl($limitControl); + $this->addControl($viewModeSwitcher); $this->addControl($searchBar); - $incidentList = (new ObjectList($incidents, new IncidentRenderer())) - ->setItemLayoutClass(MinimalItemLayout::class) - ->on(ItemList::ON_ITEM_ADD, function (ListItem $item, Incident $data) { - ObjectsRendererHook::register($data->object); - }) - ->on(ItemList::ON_ASSEMBLED, function () { - ObjectsRendererHook::load(); - }); - - $this->addContent($incidentList); + $this->addContent( + (new ObjectList($incidents, new IncidentRenderer())) + ->setItemLayoutClass(match ($viewModeSwitcher->getViewMode()) { + 'minimal' => MinimalItemLayout::class, + 'detailed' => DetailedItemLayout::class, + 'common' => ItemLayout::class + }) + ); if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { $this->sendMultipartUpdate(); diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md index 2569551c8..8341b14d4 100644 --- a/doc/03-Configuration.md +++ b/doc/03-Configuration.md @@ -36,6 +36,21 @@ If you just installed Icinga Notifications Web, remember to activate it on your | notifications/view/contacts | Allow to view contacts | | notifications/api | Allow to modify configuration via API | +## General Configuration + +There is currently one configurable option: the maximum number of characters of the incident +message shown in the incident detail view (default: 10000). It cannot be adjusted in the UI +directly, but has to be set in the configuration file `/etc/icingaweb2/modules/notifications/config.ini`. + +### Example + +Setting the incident message character limit to 2500. + +``` +[settings] +incident_message_character_limit = 2500 +``` + ## Database Configuration Connection configuration for the database, which both, diff --git a/library/Notifications/Daemon/Daemon.php b/library/Notifications/Daemon/Daemon.php index 0c0a59942..ea5670b95 100644 --- a/library/Notifications/Daemon/Daemon.php +++ b/library/Notifications/Daemon/Daemon.php @@ -8,7 +8,6 @@ use Evenement\EventEmitter; use Icinga\Application\Logger; use Icinga\Module\Notifications\Common\Database; -use Icinga\Module\Notifications\Hook\ObjectsRendererHook; use Icinga\Module\Notifications\Model\BrowserSession; use Icinga\Module\Notifications\Model\Daemon\Connection; use Icinga\Module\Notifications\Model\Daemon\Event; @@ -263,7 +262,6 @@ protected function processNotifications(): void $notificationsToProcess = []; foreach ($notifications as $notification) { if ($notification->contact_id !== null && isset($connections[$notification->contact_id])) { - ObjectsRendererHook::register($notification->incident->object); $notificationsToProcess[] = $notification; ++$numOfNotifications; @@ -273,8 +271,6 @@ protected function processNotifications(): void } if ($numOfNotifications > 0) { - ObjectsRendererHook::load(false); - foreach ($notificationsToProcess as $notification) { /** @var Incident $incident */ $incident = $notification->incident; @@ -286,7 +282,7 @@ protected function processNotifications(): void (object) [ 'incident_id' => $notification->incident_id, 'severity' => $incident->severity->getValue(), - 'title' => ObjectsRendererHook::getObjectNameAsString($incident->object), + 'title' => $incident->object->name, 'message' => $notification->message ] ) diff --git a/library/Notifications/Hook/ObjectsRendererHook.php b/library/Notifications/Hook/ObjectsRendererHook.php index 0b343900d..6e94270b1 100644 --- a/library/Notifications/Hook/ObjectsRendererHook.php +++ b/library/Notifications/Hook/ObjectsRendererHook.php @@ -8,6 +8,7 @@ use Generator; use Icinga\Application\Hook; use Icinga\Application\Logger; +use Icinga\Module\Notifications\Hook\V2\SourceHook; use Icinga\Module\Notifications\Model\Objects; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; @@ -20,6 +21,9 @@ /** * Base hook to prepare and render objects + * + * @deprecated For object name, use the db column `object.name`. To create object link, use + * {@see SourceHook::createObjectLink()} instead. */ abstract class ObjectsRendererHook { diff --git a/library/Notifications/Hook/V2/SourceHook.php b/library/Notifications/Hook/V2/SourceHook.php index 0e3c08219..937fb6cef 100644 --- a/library/Notifications/Hook/V2/SourceHook.php +++ b/library/Notifications/Hook/V2/SourceHook.php @@ -5,6 +5,7 @@ namespace Icinga\Module\Notifications\Hook\V2; +use ipl\Html\ValidHtml; use ipl\Stdlib\Filter\Chain; use ipl\Stdlib\Filter\Condition; use ipl\Web\Control\SearchBar\SearchException; @@ -79,4 +80,13 @@ public function getValueSuggestions(string $column, string $searchTerm, Chain $s * @return Traversable Provider for {@see SearchSuggestions::__construct} */ public function getColumnSuggestions(string $searchTerm): Traversable; + + /** + * Create the object link using the given ID tags + * + * @param array $idTags + * + * @return ?ValidHtml + */ + public function createObjectLink(array $idTags): ?ValidHtml; } diff --git a/library/Notifications/Model/Incident.php b/library/Notifications/Model/Incident.php index 425931910..01267d8b5 100644 --- a/library/Notifications/Model/Incident.php +++ b/library/Notifications/Model/Incident.php @@ -27,6 +27,7 @@ * @property ?DateTime $recovered_at * @property Severity $severity * @property ?string $mute_reason + * @property ?string $message * * @property Query|Objects $object * @property Query|Contact $contact @@ -54,7 +55,8 @@ public function getColumns(): array 'started_at', 'recovered_at', 'severity', - 'mute_reason' + 'mute_reason', + 'message' ]; } @@ -65,7 +67,8 @@ public function getColumnDefinitions(): array 'started_at' => t('Started At'), 'recovered_at' => t('Recovered At'), 'severity' => t('Severity'), - 'mute_reason' => t('Mute Reason') + 'mute_reason' => t('Mute Reason'), + 'message' => t('Message') ]; } diff --git a/library/Notifications/Model/Objects.php b/library/Notifications/Model/Objects.php index 6b14a4ff4..521f12d66 100644 --- a/library/Notifications/Model/Objects.php +++ b/library/Notifications/Model/Objects.php @@ -6,9 +6,7 @@ namespace Icinga\Module\Notifications\Model; use Icinga\Module\Notifications\Common\Model; -use Icinga\Module\Notifications\Hook\ObjectsRendererHook; use Icinga\Module\Notifications\Model\Behavior\IdTagAggregator; -use ipl\Html\ValidHtml; use ipl\Orm\Behavior\Binary; use ipl\Orm\Behaviors; use ipl\Orm\Query; @@ -79,9 +77,4 @@ public function createRelations(Relations $relations): void $relations->belongsTo('source', Source::class)->setJoinType('LEFT'); } - - public function getName(): ValidHtml - { - return ObjectsRendererHook::getObjectName($this); - } } diff --git a/library/Notifications/ProvidedHook/Notifications/ObjectsRenderer.php b/library/Notifications/ProvidedHook/Notifications/ObjectsRenderer.php deleted file mode 100644 index 6fccae343..000000000 --- a/library/Notifications/ProvidedHook/Notifications/ObjectsRenderer.php +++ /dev/null @@ -1,189 +0,0 @@ - -// SPDX-License-Identifier: GPL-3.0-or-later - -namespace Icinga\Module\Notifications\ProvidedHook\Notifications; - -use Generator; -use Icinga\Module\Icingadb\Common\Database; -use Icinga\Module\Icingadb\Model\Host; -use Icinga\Module\Icingadb\Model\Service; -use Icinga\Module\Icingadb\Redis\VolatileStateResults; -use Icinga\Module\Icingadb\View\HostRenderer; -use Icinga\Module\Icingadb\View\ServiceRenderer; -use Icinga\Module\Notifications\Hook\ObjectsRendererHook; -use ipl\Html\Attributes; -use ipl\Html\Html; -use ipl\Html\HtmlElement; -use ipl\Html\Text; -use ipl\Html\ValidHtml; -use ipl\Orm\Query; -use ipl\Stdlib\Filter; -use ipl\Web\Layout\MinimalItemLayout; -use ipl\Web\Widget\StateBall; - -class ObjectsRenderer extends ObjectsRendererHook -{ - use Database; - - public function getHtmlForObjectNames(array $objectIdTags): Generator - { - [$hostsQuery, $servicesQuery] = $this->buildQueries($objectIdTags); - - if ($hostsQuery) { - foreach ($hostsQuery as $host) { - $element = new HtmlElement( - 'span', - Attributes::create(['class' => 'subject']), - Text::create($host->display_name) - ); - - yield ['host' => $host->name] => $element; - } - } - - if ($servicesQuery) { - $servicesQuery - ->with(['state', 'host.state']) - ->withColumns(['service.state.soft_state', 'host.state.soft_state']) - ->setResultSetClass(VolatileStateResults::class); - - foreach ($servicesQuery as $service) { - $hostElm = [ - new StateBall($service->host->state->getStateText(), StateBall::SIZE_MEDIUM), - Text::create(' '), - Text::create($service->host->display_name) - ]; - - $serviceElm = new HtmlElement( - 'span', - Attributes::create(['class' => 'subject']), - Text::create($service->display_name) - ); - - $element = Html::sprintf( - t('%s on %s', ' on '), - $serviceElm, - new HtmlElement('span', Attributes::create(['class' => 'subject']), ...$hostElm) - ); - - yield ['host' => $service->host->name, 'service' => $service->name] => $element; - } - } - } - - public function getSourceType(): string - { - if (class_exists('Icinga\Module\Icingadb\ProvidedHook\Notifications\ObjectsRenderer')) { - return 'use-icingadb-hook-implementation'; - } - - return 'icinga2'; - } - - public function createObjectLink(array $objectIdTag): ?ValidHtml - { - [$hostsQuery, $servicesQuery] = $this->buildQueries([$objectIdTag]); - if ($servicesQuery) { - $serviceStates = $servicesQuery - ->columns([]) - ->with(['state', 'host.state']) - ->setResultSetClass(VolatileStateResults::class) - ->first(); - - if ($serviceStates === null) { - return null; - } - - $item = new MinimalItemLayout($serviceStates, new ServiceRenderer()); - - return new HtmlElement('div', $item->getAttributes(), $item); - } - - $hostStates = $hostsQuery - ->columns([]) - ->with('state') - ->setResultSetClass(VolatileStateResults::class) - ->first(); - - if ($hostStates === null) { - return null; - } - - $item = new MinimalItemLayout($hostStates, new HostRenderer()); - - return new HtmlElement('div', $item->getAttributes(), $item); - } - - public function getObjectNames(array $objectIdTags): Generator - { - [$hostsQuery, $servicesQuery] = $this->buildQueries($objectIdTags); - - if ($hostsQuery) { - foreach ($hostsQuery as $host) { - yield ['host' => $host->name] => $host->display_name; - } - } - - if ($servicesQuery) { - foreach ($servicesQuery as $service) { - yield ['host' => $service->host->name, 'service' => $service->name] => sprintf( - t('%s on %s', ' on '), - $service->display_name, - $service->host->display_name - ); - } - } - } - - /** - * Build queries for hosts and services with columns `name` and `display_name` - * - * @param array $objectIdTags - * - * @return Query[] - */ - private function buildQueries(array $objectIdTags): array - { - $filterServices = Filter::any(); - $filterHosts = Filter::any(); - - foreach ($objectIdTags as $tags) { - if (isset($tags['service'])) { - $filterServices->add( - Filter::all( - Filter::equal('service.name', $tags['service']), - Filter::equal('host.name', $tags['host']) - ) - ); - } else { - $filterHosts->add(Filter::equal('host.name', $tags['host'])); - } - } - - $hostsQuery = null; - if (! $filterHosts->isEmpty()) { - $hostsQuery = Host::on($this->getDb()) - ->columns(['name', 'display_name']) - ->filter($filterHosts); - } - - $servicesQuery = null; - if (! $filterServices->isEmpty()) { - $servicesQuery = Service::on($this->getDb()) - ->with('host') - ->columns([ - 'service.id', - 'service.name', - 'service.display_name', - 'host.id', - 'host.name', - 'host.display_name', - ]) - ->filter($filterServices); - } - - return [$hostsQuery, $servicesQuery]; - } -} diff --git a/library/Notifications/View/IncidentRenderer.php b/library/Notifications/View/IncidentRenderer.php index a9d3654f1..7233cd2e6 100644 --- a/library/Notifications/View/IncidentRenderer.php +++ b/library/Notifications/View/IncidentRenderer.php @@ -9,13 +9,13 @@ use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Common\Severity; use Icinga\Module\Notifications\Model\Incident; -use Icinga\Module\Notifications\Model\Objects; use Icinga\Module\Notifications\Model\Source; use ipl\Html\Attributes; use ipl\Html\FormattedString; use ipl\Html\Html; use ipl\Html\HtmlDocument; use ipl\Html\HtmlElement; +use ipl\Html\Text; use ipl\I18n\Translation; use ipl\Web\Common\ItemRenderer; use ipl\Web\Widget\Ball; @@ -48,24 +48,16 @@ public function assembleTitle($item, HtmlDocument $title, string $layout): void { $title->addHtml(Html::tag('span', [], sprintf('#%d:', $item->id))); - if ($layout === 'header') { - $content = new HtmlElement('span', Attributes::create(['class' => 'subject'])); - } else { - $content = new Link(null, Links::incident($item->id), ['class' => 'subject']); - } - - /** @var Objects $obj */ - $obj = $item->object; - $name = $obj->getName(); - - $content->addAttributes($name->getAttributes()); - $content->addFrom($name); - - $title->addHtml($content); + $title->addHtml( + $layout === 'header' + ? new HtmlElement('span', Attributes::create(['class' => 'subject']), new Text($item->object->name)) + : new Link($item->object->name, Links::incident($item->id), ['class' => 'subject']) + ); } public function assembleCaption($item, HtmlDocument $caption, string $layout): void { + $caption->addHtml(new Text(substr($item->message ?? '', 0, 1000))); } public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void diff --git a/library/Notifications/Widget/Detail/IncidentDetail.php b/library/Notifications/Widget/Detail/IncidentDetail.php index 0d322b5ba..3699e889b 100644 --- a/library/Notifications/Widget/Detail/IncidentDetail.php +++ b/library/Notifications/Widget/Detail/IncidentDetail.php @@ -5,8 +5,9 @@ namespace Icinga\Module\Notifications\Widget\Detail; +use Icinga\Application\Config; use Icinga\Module\Notifications\Common\Auth; -use Icinga\Module\Notifications\Hook\ObjectsRendererHook; +use Icinga\Module\Notifications\Common\SourceHookLocator; use Icinga\Module\Notifications\Model\Incident; use Icinga\Module\Notifications\View\IncidentContactRenderer; use Icinga\Module\Notifications\View\IncidentHistoryRenderer; @@ -21,6 +22,10 @@ use ipl\I18n\Translation; use ipl\Stdlib\Filter; use ipl\Web\Layout\MinimalItemLayout; +use ipl\Web\Url; +use ipl\Web\Widget\CopyToClipboard; +use ipl\Web\Widget\EmptyState; +use ipl\Web\Widget\Link; class IncidentDetail extends BaseHtmlElement { @@ -71,10 +76,22 @@ protected function createContacts(): array /** @return ValidHtml[] */ protected function createRelatedObject(): array { - $objectUrl = ObjectsRendererHook::renderObjectLink($this->incident->object); + $object = $this->incident->object; + $objectUrl = SourceHookLocator::forType($object->source->type) + ?->createObjectLink($object->id_tags); if (! $objectUrl) { - return []; + if (! $object->url) { + return []; + } + + $objUrl = Url::fromPath($object->url); + + $objectUrl = new Link( + $object->name, + $objUrl->isExternal() ? $objUrl->getAbsoluteUrl() : $objUrl->getRelativeUrl(), + ['class' => 'subject', 'data-base-target' => '_next'] + ); } return [ @@ -83,6 +100,38 @@ protected function createRelatedObject(): array ]; } + protected function createMessage(): array + { + $isEmpty = $this->incident->message === null || $this->incident->message === ''; + $message = new HtmlElement( + 'div', + Attributes::create([ + 'class' => ['message', $isEmpty ? 'empty' : '', 'collapsible'], + 'id' => 'persist-collapse-state', + 'data-visible-height' => 100 + ]), + $isEmpty + ? new EmptyState($this->translate('No message available')) + : Text::create( + substr( + $this->incident->message, + 0, + (int) Config::module('notifications') + ->get('settings', 'incident_message_character_limit', 10000) + ) + ) + ); + + if (! $isEmpty) { + CopyToClipboard::attachTo($message); + } + + return [ + new HtmlElement('h2', content: Text::create($this->translate('Message'))), + $message + ]; + } + /** @return ValidHtml[] */ protected function createHistory(): array { @@ -122,6 +171,7 @@ protected function assemble(): void $this->createContacts(), $this->createHistory(), $this->createRelatedObject(), + $this->createMessage(), $this->createSource(), ]); } diff --git a/public/css/detail/incident-detail.less b/public/css/detail/incident-detail.less index 0a7a42294..720dcc3ed 100644 --- a/public/css/detail/incident-detail.less +++ b/public/css/detail/incident-detail.less @@ -37,4 +37,15 @@ color: @color-critical; } } + + .message { + padding: 0.5em; + word-break: break-word; + white-space: pre-wrap; + + &:not(.empty) { + background-color: @gray-lighter; + .rounded-corners(); + } + } } diff --git a/public/css/view-mode-switcher.less b/public/css/view-mode-switcher.less deleted file mode 100644 index 74dfb9855..000000000 --- a/public/css/view-mode-switcher.less +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Icinga GmbH -// SPDX-License-Identifier: GPL-3.0-or-later - -.view-mode-switcher { - border: none; - - list-style-type: none; - margin: 0 0 0.25em 0; - padding: 0; - display: inline-flex; - - input { - display: none; - } - - label { - color: @control-color; - background: @low-sat-blue; - padding: 14/16*.25em 14/16*.5em; - cursor: pointer; - - &:first-of-type { - border-top-left-radius: 0.25em; - border-bottom-left-radius: 0.25em; - } - - &:last-of-type { - border-top-right-radius: 0.25em; - border-bottom-right-radius: 0.25em; - } - - &:not(:last-of-type) { - border-right: 1px solid @low-sat-blue-dark; - } - - i { - // fix height for Chrome - display: block; - } - } - - input[checked] + label { - background-color: @control-color; - color: @text-color-on-icinga-blue; - cursor: default; - } -} diff --git a/test/php/library/Notifications/Common/SourceHookLocatorTest.php b/test/php/library/Notifications/Common/SourceHookLocatorTest.php index 6a4438ff3..347056761 100644 --- a/test/php/library/Notifications/Common/SourceHookLocatorTest.php +++ b/test/php/library/Notifications/Common/SourceHookLocatorTest.php @@ -9,6 +9,8 @@ use Icinga\Application\Hook; use Icinga\Module\Notifications\Common\SourceHookLocator; use Icinga\Module\Notifications\Hook\V2\SourceHook; +use ipl\Html\Text; +use ipl\Html\ValidHtml; use ipl\Stdlib\Filter\Chain; use ipl\Stdlib\Filter\Condition; use ipl\Web\Widget\Icon; @@ -49,6 +51,11 @@ public function getColumnSuggestions(string $searchTerm): Traversable { return new EmptyIterator(); } + + public function createObjectLink(array $idTags): ?ValidHtml + { + return new Text('test'); + } } // phpcs:ignore PSR1.Classes.ClassDeclaration.MultipleClasses diff --git a/test/php/library/Notifications/Integrations/IncidentTest.php b/test/php/library/Notifications/Integrations/IncidentTest.php index becabf853..1ffcdb84c 100644 --- a/test/php/library/Notifications/Integrations/IncidentTest.php +++ b/test/php/library/Notifications/Integrations/IncidentTest.php @@ -43,7 +43,8 @@ protected function setUp(): void $this->db = new RecordingConnection(['db' => 'sqlite', 'dbname' => ':memory:']); $this->db->exec( 'CREATE TABLE incident (id INTEGER PRIMARY KEY AUTOINCREMENT,' - . ' object_id BLOB, started_at INTEGER, recovered_at INTEGER, severity VARCHAR, mute_reason VARCHAR);' + . ' object_id BLOB, started_at INTEGER, recovered_at INTEGER, severity VARCHAR, mute_reason VARCHAR,' + . ' message VARCHAR);' . 'CREATE TABLE contact (id INTEGER PRIMARY KEY AUTOINCREMENT, full_name VARCHAR, username VARCHAR,' . ' default_channel_id INTEGER, changed_at INTEGER, deleted VARCHAR, external_uuid VARCHAR);' . 'CREATE TABLE contactgroup (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR, changed_at INTEGER,' diff --git a/test/php/library/Notifications/Integrations/IncidentsTest.php b/test/php/library/Notifications/Integrations/IncidentsTest.php index 4774a7fe9..2f3b36495 100644 --- a/test/php/library/Notifications/Integrations/IncidentsTest.php +++ b/test/php/library/Notifications/Integrations/IncidentsTest.php @@ -161,7 +161,7 @@ private function createDatabase(): RecordingConnection ); $db->exec( 'CREATE TABLE incident (id INTEGER PRIMARY KEY AUTOINCREMENT, object_id BLOB, started_at INTEGER,' - . ' recovered_at INTEGER, severity VARCHAR, mute_reason VARCHAR);' + . ' recovered_at INTEGER, severity VARCHAR, mute_reason VARCHAR, message VARCHAR);' ); return $db;