├── doc ├── res │ ├── notifications-preview.png │ └── notifications-architecture.png ├── api │ └── v1.md ├── 02-Installation.md.d │ └── From-Source.md ├── 20-REST-API.md ├── 02-Installation.md ├── 05-Upgrading.md └── 01-About.md ├── public ├── img │ ├── pictogram │ │ ├── 24-7-dark.jpg │ │ ├── 24-7-light.jpg │ │ ├── multi-dark.jpg │ │ ├── multi-light.jpg │ │ ├── partial-dark.jpg │ │ └── partial-light.jpg │ ├── icinga-notifications-ok.webp │ ├── icinga-notifications-critical.webp │ ├── icinga-notifications-unknown.webp │ └── icinga-notifications-warning.webp ├── css │ ├── list │ │ ├── action-list.less │ │ └── schedule-list.less │ ├── quick-actions.less │ ├── event-source-badge.less │ ├── detail │ │ └── incident-detail.less │ ├── load-more.less │ ├── mixins.less │ ├── view-mode-switcher.less │ ├── checkbox-icon.less │ ├── common.less │ └── schedule.less └── js │ ├── doc │ ├── notifications-arch.puml │ └── NOTIFICATIONS.md │ └── module.js ├── module.info ├── config └── systemd │ └── icinga-desktop-notifications.service ├── library └── Notifications │ ├── Api │ ├── EndpointInterface.php │ ├── Exception │ │ └── InvalidFilterParameterException.php │ ├── OpenApiPreprocessor │ │ ├── ConsistentOrder.php │ │ └── AddGlobal401Response.php │ ├── OpenApiDescriptionElement │ │ ├── Schema │ │ │ └── SchemaUUID.php │ │ ├── Response │ │ │ ├── Error404Response.php │ │ │ ├── SuccessResponse.php │ │ │ ├── ErrorResponse.php │ │ │ ├── SuccessDataResponse.php │ │ │ └── Example │ │ │ │ └── ResponseExample.php │ │ ├── Parameter │ │ │ ├── QueryParameter.php │ │ │ └── PathParameter.php │ │ ├── OadV1Get.php │ │ ├── OadV1GetPlural.php │ │ └── OadV1Delete.php │ └── Middleware │ │ ├── EndpointExecutionMiddleware.php │ │ ├── RoutingMiddleware.php │ │ ├── DispatchMiddleware.php │ │ ├── ErrorHandlingMiddleware.php │ │ ├── LegacyRequestConversionMiddleware.php │ │ └── MiddlewarePipeline.php │ ├── Model │ ├── Daemon │ │ ├── EventIdentifier.php │ │ ├── User.php │ │ └── Event.php │ ├── Tag.php │ ├── ExtraTag.php │ ├── AvailableChannelType.php │ ├── ObjectIdTag.php │ ├── ObjectExtraTag.php │ ├── IncidentContact.php │ ├── ContactAddress.php │ ├── ContactgroupMember.php │ ├── Timeperiod.php │ ├── BrowserSession.php │ ├── RotationMember.php │ ├── Schedule.php │ ├── Behavior │ │ ├── IcingaCustomVars.php │ │ └── ObjectTags.php │ ├── Contactgroup.php │ ├── Rule.php │ ├── TimeperiodEntry.php │ ├── Objects.php │ └── RuleEscalation.php │ ├── Widget │ ├── IconBall.php │ ├── Timeline │ │ ├── FakeEntry.php │ │ ├── Member.php │ │ ├── FutureEntry.php │ │ └── MinimalGrid.php │ ├── ItemList │ │ ├── PageSeparatorItem.php │ │ ├── LoadMoreObjectList.php │ │ └── ObjectList.php │ ├── TimeGrid │ │ ├── EntryProvider.php │ │ ├── ExtraEntryCount.php │ │ ├── GridStep.php │ │ └── Timescale.php │ ├── Calendar │ │ └── Attendee.php │ ├── EventSourceBadge.php │ ├── ShowMore.php │ ├── TimezoneWarning.php │ ├── RuleEscalationRecipientBadge.php │ └── Detail │ │ └── ObjectHeader.php │ ├── Common │ ├── NoSubjectLink.php │ ├── HttpMethod.php │ ├── Icons.php │ ├── PsrLogger.php │ ├── DetailActions.php │ ├── Auth.php │ ├── ConfigurationTabs.php │ └── LoadMore.php │ ├── Util │ └── ObjectSuggestionsCursor.php │ ├── Web │ ├── Control │ │ └── SearchBar │ │ │ └── ExtraTagSuggestions.php │ ├── Form │ │ └── EventRuleDecorator.php │ └── FilterRenderer.php │ ├── View │ ├── ChannelRenderer.php │ ├── SourceRenderer.php │ ├── ScheduleRenderer.php │ ├── ContactRenderer.php │ ├── ContactgroupRenderer.php │ ├── EventRuleRenderer.php │ └── IncidentContactRenderer.php │ └── Hook │ └── V1 │ └── SourceHook.php ├── AUTHORS ├── test └── php │ ├── application │ └── forms │ │ └── SourceFormTest.php │ └── library │ └── Notifications │ └── Widget │ └── CalendarTest.php ├── phpstan.neon ├── application ├── clicommands │ └── DaemonCommand.php ├── forms │ ├── EventRuleConfigElements │ │ ├── ConfigProvider.php │ │ ├── ConfigProviderInterface.php │ │ ├── NotificationConfigProvider.php │ │ ├── Escalations.php │ │ ├── EscalationRecipients.php │ │ └── EscalationConditions.php │ ├── DatabaseConfigForm.php │ └── EventRuleForm.php └── controllers │ ├── EventController.php │ ├── ChannelController.php │ ├── ConfigController.php │ ├── IncidentController.php │ ├── SourceController.php │ └── ApiController.php ├── run.php ├── CHANGELOG.md └── README.md /doc/res/notifications-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/doc/res/notifications-preview.png -------------------------------------------------------------------------------- /public/img/pictogram/24-7-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/pictogram/24-7-dark.jpg -------------------------------------------------------------------------------- /public/img/pictogram/24-7-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/pictogram/24-7-light.jpg -------------------------------------------------------------------------------- /public/img/pictogram/multi-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/pictogram/multi-dark.jpg -------------------------------------------------------------------------------- /doc/res/notifications-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/doc/res/notifications-architecture.png -------------------------------------------------------------------------------- /public/img/pictogram/multi-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/pictogram/multi-light.jpg -------------------------------------------------------------------------------- /public/img/pictogram/partial-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/pictogram/partial-dark.jpg -------------------------------------------------------------------------------- /public/img/pictogram/partial-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/pictogram/partial-light.jpg -------------------------------------------------------------------------------- /public/img/icinga-notifications-ok.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/icinga-notifications-ok.webp -------------------------------------------------------------------------------- /public/img/icinga-notifications-critical.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/icinga-notifications-critical.webp -------------------------------------------------------------------------------- /public/img/icinga-notifications-unknown.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/icinga-notifications-unknown.webp -------------------------------------------------------------------------------- /public/img/icinga-notifications-warning.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/HEAD/public/img/icinga-notifications-warning.webp -------------------------------------------------------------------------------- /doc/api/v1.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | --- 5 | # API V1 6 | 7 | Refer to the OpenAPI specification below for detailed information on each endpoint. 8 | 9 | !!swagger api-v1-public.json!! 10 | -------------------------------------------------------------------------------- /module.info: -------------------------------------------------------------------------------- 1 | Module: notifications 2 | Version: 0.2.0 3 | Requires: 4 | Libraries: icinga-php-library (>=0.18.0), icinga-php-thirdparty (>=0.14.0) 5 | Description: Icinga Notifications Web 6 | Manage incidents and who gets notified about them how and when 7 | -------------------------------------------------------------------------------- /public/css/list/action-list.less: -------------------------------------------------------------------------------- 1 | .action-list { 2 | [data-action-item]:hover { 3 | background-color: @tr-hover-color; 4 | cursor: pointer; 5 | } 6 | 7 | [data-action-item].active { 8 | background-color: @tr-active-color; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /config/systemd/icinga-desktop-notifications.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Icinga Desktop Notifications Daemon 3 | 4 | [Service] 5 | Type=simple 6 | ExecStart=/usr/bin/icingacli notifications daemon run 7 | Restart=on-success 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /public/css/quick-actions.less: -------------------------------------------------------------------------------- 1 | .quick-actions { 2 | .control-button { 3 | // These buttons also contain text, not just an icon 4 | 5 | display: inline-flex; 6 | align-items: baseline; 7 | 8 | i.icon { 9 | margin-right: .2em; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /library/Notifications/Api/EndpointInterface.php: -------------------------------------------------------------------------------- 1 | 2 | Alvar Penning 3 | Bastian Lederer 4 | Florian Strohmaier 5 | Jan Schuppik 6 | Johannes Meyer 7 | Johannes Rauh 8 | Julian Brost 9 | Noé Costa 10 | Ravi Kumar Kempapura Srinivasa 11 | Sukhwinder Dhillon 12 | Yonas Habteab 13 | -------------------------------------------------------------------------------- /test/php/application/forms/SourceFormTest.php: -------------------------------------------------------------------------------- 1 | assertSame( 15 | PASSWORD_DEFAULT, 16 | SourceForm::HASH_ALGORITHM, 17 | 'PHP\'s default password hash algorithm changed. Consider adding support for it' 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /library/Notifications/Widget/IconBall.php: -------------------------------------------------------------------------------- 1 | ['icon-ball']]; 15 | 16 | public function __construct(string $name, ?string $style = 'fa-solid') 17 | { 18 | $icon = new Icon($name); 19 | if ($style !== null) { 20 | $icon->setStyle($style); 21 | } 22 | 23 | $this->addHtml($icon); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /public/css/detail/incident-detail.less: -------------------------------------------------------------------------------- 1 | .incident-detail { 2 | ul.source-list { 3 | display: flex; 4 | 5 | list-style-type: none; 6 | margin: 0; 7 | padding: 0; 8 | 9 | > li:not(:last-of-type) { 10 | margin-right: 1em; 11 | } 12 | } 13 | 14 | .list-item.incident-contact { 15 | &:not(:first-child) > .main { 16 | border-top: 0; 17 | } 18 | 19 | .visual { 20 | align-self: center; 21 | 22 | i.icon { 23 | font-size: 1em; 24 | } 25 | } 26 | } 27 | 28 | .list-item.incident-history.notification-state { 29 | &.suppressed { 30 | opacity: .75; 31 | } 32 | 33 | &.failed .state-text { 34 | color: @color-critical; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline-standard.neon 3 | 4 | parameters: 5 | level: max 6 | 7 | checkFunctionNameCase: true 8 | checkInternalClassCaseSensitivity: true 9 | treatPhpDocTypesAsCertain: false 10 | 11 | paths: 12 | - application 13 | - library 14 | 15 | scanDirectories: 16 | - /icingaweb2 17 | - /usr/share/icinga-php 18 | - /usr/share/icingaweb2-modules 19 | 20 | ignoreErrors: 21 | - 22 | messages: 23 | - '#Unsafe usage of new static\(\)#' 24 | - '#. but return statement is missing#' 25 | reportUnmatched: false 26 | 27 | universalObjectCratesClasses: 28 | - ipl\Orm\Model 29 | - Icinga\Web\View 30 | -------------------------------------------------------------------------------- /public/js/doc/notifications-arch.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | skinparam componentStyle rectangle 4 | 5 | package "Browser" as B { 6 | [Tab ..] <--> [ServiceWorker] 7 | [Tab y] <--> [ServiceWorker] 8 | [Tab x] <--> [ServiceWorker] 9 | } 10 | 11 | package "Server" as S { 12 | [Daemon] 13 | } 14 | 15 | [ServiceWorker] <.. [Daemon] : event-stream 16 | 17 | note left of S 18 | The daemon communicates with the forwarded event-stream requests in an unidirectional way. 19 | end note 20 | 21 | note as NB 22 | Browser consists of n amount of tabs. 23 | The service worker communicates with the tabs in a bidirectional way. 24 | It also forwards event-stream request towards the daemon 25 | (but limits it to two concurrent event-stream connections). 26 | end note 27 | 28 | NB .. B 29 | @enduml 30 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Timeline/FakeEntry.php: -------------------------------------------------------------------------------- 1 | noSubjectLink = $state; 22 | 23 | return $this; 24 | } 25 | 26 | /** 27 | * Get whether a list item's subject should be a link 28 | * 29 | * @return bool 30 | */ 31 | public function getNoSubjectLink(): bool 32 | { 33 | return $this->noSubjectLink; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /library/Notifications/Model/Tag.php: -------------------------------------------------------------------------------- 1 | add(new ObjectTags()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiPreprocessor/ConsistentOrder.php: -------------------------------------------------------------------------------- 1 | openapi->components) && is_iterable($analysis->openapi->components->schemas)) { 14 | usort($analysis->openapi->components->schemas, function ($a, $b) { 15 | return $a->schema <=> $b->schema; 16 | }); 17 | } 18 | 19 | usort($analysis->openapi->paths, function ($a, $b) { 20 | return $a->path <=> $b->path; 21 | }); 22 | 23 | usort($analysis->openapi->tags, function ($a, $b) { 24 | return $a->name <=> $b->name; 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /library/Notifications/Model/ExtraTag.php: -------------------------------------------------------------------------------- 1 | add(new ObjectTags()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /public/css/load-more.less: -------------------------------------------------------------------------------- 1 | .item-list { 2 | > .show-more a { 3 | .rounded-corners(.25em); 4 | background: @low-sat-blue; 5 | text-align: center; 6 | 7 | &:hover { 8 | opacity: .8; 9 | text-decoration: none; 10 | } 11 | } 12 | 13 | > .page-separator:after { 14 | content: ""; 15 | display: block; 16 | width: 100%; 17 | height: 1px; 18 | background: @gray; 19 | align-self: center; 20 | margin-left: .25em; 21 | } 22 | 23 | > .page-separator a { 24 | color: @gray; 25 | font-weight: bold; 26 | 27 | &:hover { 28 | text-decoration: none; 29 | } 30 | } 31 | 32 | > .page-separator + .list-item .main { 33 | border-top: none; 34 | } 35 | } 36 | 37 | //layout 38 | .item-list .list-item.show-more { 39 | display: flex; 40 | 41 | .action-link { 42 | flex: 1 1 auto; 43 | margin: 1.5em 0; 44 | padding: .5em 0; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /library/Notifications/Util/ObjectSuggestionsCursor.php: -------------------------------------------------------------------------------- 1 | $value) { 14 | // TODO(lippserd): This is a quick and dirty fix for PostgreSQL binary datatypes for which PDO returns 15 | // PHP resources that would cause exceptions since resources are not a valid type for attribute values. 16 | // We need to do it this way as the suggestion implementation bypasses ORM behaviors here and there. 17 | if (is_resource($value)) { 18 | $value = stream_get_contents($value); 19 | } 20 | 21 | yield $key => $value; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /library/Notifications/Model/Daemon/User.php: -------------------------------------------------------------------------------- 1 | username = null; 18 | $this->contactId = null; 19 | } 20 | 21 | public function getUsername(): ?string 22 | { 23 | return $this->username; 24 | } 25 | 26 | public function getContactId(): ?int 27 | { 28 | return $this->contactId; 29 | } 30 | 31 | public function setUsername(string $username): void 32 | { 33 | $this->username = $username; 34 | } 35 | 36 | public function setContactId(int $contactId): void 37 | { 38 | $this->contactId = $contactId; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /library/Notifications/Widget/ItemList/PageSeparatorItem.php: -------------------------------------------------------------------------------- 1 | 'list-item page-separator']; 13 | 14 | /** @var int */ 15 | protected $pageNumber; 16 | 17 | /** @var string */ 18 | protected $tag = 'li'; 19 | 20 | public function __construct(int $pageNumber) 21 | { 22 | $this->pageNumber = $pageNumber; 23 | } 24 | 25 | protected function assemble() 26 | { 27 | $this->add(Html::tag( 28 | 'a', 29 | [ 30 | 'id' => 'page-' . $this->pageNumber, 31 | 'data-icinga-no-scroll-on-focus' => true 32 | ], 33 | $this->pageNumber 34 | )); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /doc/02-Installation.md.d/From-Source.md: -------------------------------------------------------------------------------- 1 | # Installing Icinga Notifications Web from Source 2 | 3 | Please see the Icinga Web documentation on 4 | [how to install modules](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation) from source. 5 | Make sure you use `notifications` as the module name. The following requirements must also be met. 6 | 7 | ### Requirements 8 | 9 | - PHP (≥8.2) 10 | - PHP needs the following extensions to be installed and activated: 11 | - `json` 12 | - [MySQL](https://www.php.net/manual/en/ref.pdo-mysql.php) 13 | or [PostgreSQL](https://www.php.net/manual/en/ref.pdo-pgsql.php) PDO PHP libraries 14 | - [Icinga Notifications](https://github.com/Icinga/icinga-notifications) 15 | - [Icinga Web](https://github.com/Icinga/icingaweb2) (≥2.12) 16 | - [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) (≥0.18) 17 | - [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (≥0.14) 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/css/mixins.less: -------------------------------------------------------------------------------- 1 | .event-rule-button(@disabled: false) { 2 | text-align: center; 3 | line-height: 1.5; 4 | display: flex; 5 | align-items: center; 6 | padding: .5em 1em; 7 | .rounded-corners(3px); 8 | 9 | &:is(a) { 10 | text-decoration: none; 11 | } 12 | 13 | & when (@disabled = false) { 14 | border: none; 15 | color: @icinga-blue; 16 | background: @low-sat-blue; 17 | 18 | &:hover, 19 | &:focus { 20 | background: @low-sat-blue-dark; 21 | color: @icinga-blue; 22 | } 23 | 24 | &:focus { 25 | outline: 3px solid fade(@icinga-blue, 50%); 26 | outline-offset: 1px; 27 | } 28 | } 29 | 30 | & when (@disabled = true) { 31 | padding-left: ~"calc(1em - 1px)"; 32 | padding-right: ~"calc(1em - 1px)"; 33 | border: 1px solid @control-disabled-color; 34 | color: @control-disabled-color; 35 | cursor: not-allowed; 36 | 37 | .user-select(none); 38 | } 39 | 40 | .icon::before { 41 | margin-right: 0; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/css/view-mode-switcher.less: -------------------------------------------------------------------------------- 1 | .view-mode-switcher { 2 | border: none; 3 | 4 | list-style-type: none; 5 | margin: 0 0 0.25em 0; 6 | padding: 0; 7 | display: inline-flex; 8 | 9 | input { 10 | display: none; 11 | } 12 | 13 | label { 14 | color: @control-color; 15 | background: @low-sat-blue; 16 | padding: 14/16*.25em 14/16*.5em; 17 | cursor: pointer; 18 | 19 | &:first-of-type { 20 | border-top-left-radius: 0.25em; 21 | border-bottom-left-radius: 0.25em; 22 | } 23 | 24 | &:last-of-type { 25 | border-top-right-radius: 0.25em; 26 | border-bottom-right-radius: 0.25em; 27 | } 28 | 29 | &:not(:last-of-type) { 30 | border-right: 1px solid @low-sat-blue-dark; 31 | } 32 | 33 | i { 34 | // fix height for Chrome 35 | display: block; 36 | } 37 | } 38 | 39 | input[checked] + label { 40 | background-color: @control-color; 41 | color: @text-color-on-icinga-blue; 42 | cursor: default; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiPreprocessor/AddGlobal401Response.php: -------------------------------------------------------------------------------- 1 | openapi->paths as $path) { 15 | foreach ($path->operations() as $operation) { 16 | // Avoid duplicates 17 | $already = array_filter( 18 | $operation->responses, 19 | fn($resp) => $resp->response === 401 20 | ); 21 | 22 | if (! $already) { 23 | $operation->responses[] = new OA\Response([ 24 | 'response' => 401, 25 | 'description' => 'Unauthorized', 26 | ]); 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/Schema/SchemaUUID.php: -------------------------------------------------------------------------------- 1 | provider = $provider; 27 | } 28 | 29 | protected function registerAttributeCallbacks(Attributes $attributes): void 30 | { 31 | $attributes->registerAttributeCallback('provider', null, $this->setProvider(...)); 32 | 33 | parent::registerAttributeCallbacks($attributes); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /doc/20-REST-API.md: -------------------------------------------------------------------------------- 1 | # REST API 2 | 3 | Icinga Notifications Web provides a REST API that allows you to manage notification-related resources programmatically. 4 | 5 | With this API, you can: 6 | - Manage **contacts** and **contact groups** 7 | - Read available **notification channels** 8 | 9 | This API enables easy integration with external tools, automation workflows, and configuration management systems. 10 | 11 | ## API Versioning 12 | 13 | The API follows a **versioned** structure to ensure backward compatibility and predictable upgrades. 14 | 15 | The current and first stable version is: /icingaweb2/notifications/api/v1 16 | 17 | Future versions will be accessible under corresponding paths (for example, `/api/v2`), allowing you to migrate at your own pace. 18 | 19 | ## API Description 20 | 21 | The complete API reference for version `v1` is available in [`api/v1.md`](api/v1.md). 22 | 23 | It contains an OpenAPI v3.1 description with detailed information about all endpoints, including: 24 | - Request and response schemas 25 | - Example payloads 26 | - Authentication requirements 27 | - Error handling 28 | -------------------------------------------------------------------------------- /public/css/checkbox-icon.less: -------------------------------------------------------------------------------- 1 | .checkbox-icon { 2 | position: relative; 3 | display: inline-block; 4 | height: 1.5em; 5 | width: 2.625em; 6 | } 7 | 8 | .inner-slider { 9 | position: absolute; 10 | display: inline-block; 11 | background: @gray-light; 12 | border: 1px solid; 13 | border-color: @gray-light; 14 | box-sizing: content-box; 15 | border-radius: 1em; 16 | height: 4/3em; 17 | width: 8/3em; 18 | vertical-align: middle; 19 | 20 | &:before { 21 | content: ""; 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | 26 | border-radius: 1em; 27 | border: 1px solid; 28 | border-color: @gray-light; 29 | box-sizing: border-box; 30 | background-color: @gray-lighter; 31 | 32 | display: block; 33 | height: 4/3em; 34 | margin-left: 0; 35 | width: 4/3em; 36 | } 37 | } 38 | 39 | .checkbox-icon.checked { 40 | .inner-slider { 41 | background-color: @icinga-blue; 42 | border-color: @icinga-blue; 43 | 44 | &:before { 45 | border-color: @icinga-blue; 46 | left: 100%; 47 | margin-left: -4/3em; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /doc/02-Installation.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Installing Icinga Notifications Web 4 | 5 | The recommended way to install Icinga Notifications Web is to use prebuilt packages for 6 | all supported platforms from our official release repository. 7 | 8 | Please follow the steps listed for your target operating system, 9 | which guide you through setting up the repository and installing Icinga Notifications Web. 10 | 11 | Before installing Icinga Notifications Web, make sure you have installed 12 | [Icinga Notifications](https://icinga.com/docs/icinga-notifications/latest/doc/02-Installation). 13 | 14 | 15 | 16 | 17 | 18 | ## Installing the Package 19 | 20 | If the [repository](https://packages.icinga.com) is not configured yet, please add it first. 21 | Then use your distribution's package manager to install the `icinga-notifications-web` package 22 | or install [from source](02-Installation.md.d/From-Source.md). 23 | 24 | 25 | This concludes the installation. Now proceed with the [configuration](03-Configuration.md). 26 | 27 | -------------------------------------------------------------------------------- /library/Notifications/Widget/ItemList/LoadMoreObjectList.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class LoadMoreObjectList extends ObjectList 24 | { 25 | use LoadMore; 26 | 27 | public function __construct(ResultSet $data) 28 | { 29 | parent::__construct($data, function (Model $item) { 30 | if ($item instanceof Event) { 31 | return new EventRenderer(); 32 | } 33 | 34 | throw new NotImplementedError('Not implemented'); 35 | }); 36 | 37 | $this->data = $this->getIterator($data); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /library/Notifications/Api/Middleware/EndpointExecutionMiddleware.php: -------------------------------------------------------------------------------- 1 | getAttribute('endpointHandler'); 22 | 23 | if (! $endpointHandler instanceof RequestHandlerInterface) { 24 | return $handler->handle($request); 25 | } 26 | return $request->getAttribute('endpointHandler')->handle($request); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /library/Notifications/Model/AvailableChannelType.php: -------------------------------------------------------------------------------- 1 | hasMany('channel', Channel::class) 45 | ->setForeignKey('type'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/Response/Error404Response.php: -------------------------------------------------------------------------------- 1 | $endpointName . ' not found'], 27 | ) 28 | ], 29 | ref: '#/components/schemas/ErrorResponse' 30 | ) 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /library/Notifications/Model/ObjectIdTag.php: -------------------------------------------------------------------------------- 1 | add(new Binary(['object_id'])); 43 | } 44 | 45 | public function createRelations(Relations $relations): void 46 | { 47 | $relations->belongsTo('object', Objects::class); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /application/forms/DatabaseConfigForm.php: -------------------------------------------------------------------------------- 1 | keys(); 15 | 16 | $this->addElement( 17 | 'select', 18 | 'resource', 19 | [ 20 | 'label' => $this->translate('Database'), 21 | 'options' => array_merge( 22 | ['' => sprintf(' - %s - ', $this->translate('Please choose'))], 23 | array_combine($dbResources, $dbResources) 24 | ), 25 | 'disable' => [''], 26 | 'required' => true, 27 | 'value' => '' 28 | ] 29 | ); 30 | 31 | $this->addElement( 32 | 'submit', 33 | 'submit', 34 | [ 35 | 'label' => $this->translate('Save Changes') 36 | ] 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Timeline/Member.php: -------------------------------------------------------------------------------- 1 | name = $name; 26 | } 27 | 28 | /** 29 | * Get the name of the member 30 | * 31 | * @return string 32 | */ 33 | public function getName(): string 34 | { 35 | return $this->name; 36 | } 37 | 38 | /** 39 | * Set the icon 40 | * 41 | * @param string $icon 42 | * 43 | * @return $this 44 | */ 45 | public function setIcon(string $icon): self 46 | { 47 | $this->icon = $icon; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Get the icon 54 | * 55 | * @return string 56 | */ 57 | public function getIcon(): string 58 | { 59 | return $this->icon; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /library/Notifications/Model/ObjectExtraTag.php: -------------------------------------------------------------------------------- 1 | add(new Binary(['object_id'])); 46 | } 47 | 48 | public function createRelations(Relations $relations): void 49 | { 50 | $relations->belongsTo('object', Objects::class); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /library/Notifications/Widget/TimeGrid/EntryProvider.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function getEntries(): Traversable; 16 | 17 | /** 18 | * Get the URL to use for the given grid step 19 | * 20 | * @param GridStep $step A step, as calculated by the grid 21 | * 22 | * @return ?Url 23 | */ 24 | public function getStepUrl(GridStep $step): ?Url; 25 | 26 | /** 27 | * Get the URL to show any extraneous entries which don't fit onto the given grid step 28 | * 29 | * This is called each time an entire day has passed on the grid and a step represents the end of the day, even 30 | * if there are no extraneous entries to show. Depending on the structure of the grid, and the flow of steps, 31 | * it might be necessary to conditionally return a URL here, to avoid showing the same URL multiple times. 32 | * 33 | * @param GridStep $step A step, as calculated by the grid 34 | * 35 | * @return ?Url 36 | */ 37 | public function getExtraEntryUrl(GridStep $step): ?Url; 38 | } 39 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Calendar/Attendee.php: -------------------------------------------------------------------------------- 1 | name = $name; 28 | } 29 | 30 | public function getName(): string 31 | { 32 | return $this->name; 33 | } 34 | 35 | public function setIcon($icon): self 36 | { 37 | if ($icon === null) { 38 | throw new InvalidArgumentException('Cannot unset icon'); 39 | } 40 | 41 | $this->icon = $icon; 42 | 43 | return $this; 44 | } 45 | 46 | public function getIcon(): ValidHtml 47 | { 48 | if (is_string($this->icon)) { 49 | $icon = new Icon($this->icon); 50 | } else { 51 | $icon = $this->icon; 52 | } 53 | 54 | return $icon; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /run.php: -------------------------------------------------------------------------------- 1 | provideHook('Notifications/ObjectsRenderer'); 9 | } 10 | 11 | $this->provideHook('authentication', 'SessionStorage', true); 12 | $this->addRoute( 13 | 'static-file', 14 | new Zend_Controller_Router_Route_Regex( 15 | 'notifications-(.[^.]*)(\..*)', 16 | [ 17 | 'controller' => 'daemon', 18 | 'action' => 'script', 19 | 'module' => 'notifications' 20 | ], 21 | [ 22 | 1 => 'file', 23 | 2 => 'extension' 24 | ] 25 | ) 26 | ); 27 | 28 | $this->addRoute('notifications/api-plural', new Zend_Controller_Router_Route( 29 | 'notifications/api/:version/:endpoint', 30 | [ 31 | 'module' => 'notifications', 32 | 'controller' => 'api', 33 | 'action' => 'index' 34 | ] 35 | )); 36 | $this->addRoute('notifications/api-single', new Zend_Controller_Router_Route( 37 | 'notifications/api/:version/:endpoint/:identifier', 38 | [ 39 | 'module' => 'notifications', 40 | 'controller' => 'api', 41 | 'action' => 'index' 42 | ] 43 | )); 44 | -------------------------------------------------------------------------------- /library/Notifications/Web/Control/SearchBar/ExtraTagSuggestions.php: -------------------------------------------------------------------------------- 1 | columns(['tag']) 32 | ->assembleSelect() 33 | ->distinct() 34 | )); 35 | 36 | // Object Extra Tags 37 | foreach ($searchColumns as $column) { 38 | yield $column->tag => $column->tag; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /library/Notifications/Api/Middleware/RoutingMiddleware.php: -------------------------------------------------------------------------------- 1 | getAttribute('route_params'); 22 | $version = ucfirst($params['version']); 23 | $endpoint = ucfirst(Str::camel($params['endpoint'])); 24 | $identifier = $params['identifier'] ?? null; 25 | 26 | return $handler->handle( 27 | $request 28 | ->withAttribute('version', ucfirst($version)) 29 | ->withAttribute('endpoint', ucfirst($endpoint)) 30 | ->withAttribute('identifier', $identifier !== null ? strtolower($identifier) : null) 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /library/Notifications/Common/HttpMethod.php: -------------------------------------------------------------------------------- 1 | name; 25 | } 26 | 27 | /** 28 | * Returns the current enum case as string in lowercase. 29 | * 30 | * @return string 31 | */ 32 | public function lowercase(): string 33 | { 34 | return $this->value; 35 | } 36 | 37 | /** 38 | * Retrieves an enum instance from a ServerRequestInterface by extracting the HTTP method. 39 | * 40 | * @param ServerRequestInterface $request The server request containing the HTTP method. 41 | * 42 | * @return HttpMethod The enum instance corresponding to the provided method. 43 | */ 44 | public static function fromRequest(ServerRequestInterface $request): self 45 | { 46 | return self::from(strtolower($request->getMethod())); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/Parameter/QueryParameter.php: -------------------------------------------------------------------------------- 1 | $parameter, 28 | 'name' => $name, 29 | 'description' => $description, 30 | 'in' => 'query', 31 | 'required' => $required ?? false, 32 | 'schema' => $schema, 33 | ]; 34 | 35 | $params = $example !== null ? array_merge($params, ['example' => $example]) : $params; 36 | 37 | parent::__construct(...$params); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /library/Notifications/Model/IncidentContact.php: -------------------------------------------------------------------------------- 1 | t('Incident Id'), 44 | 'contact_id' => t('Contact Id'), 45 | 'role' => t('Role') 46 | ]; 47 | } 48 | 49 | public function createRelations(Relations $relations): void 50 | { 51 | $relations->belongsTo('incident', Incident::class); 52 | $relations->belongsTo('contact', Contact::class); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /library/Notifications/Web/Form/EventRuleDecorator.php: -------------------------------------------------------------------------------- 1 | element = $formElement; 23 | $formElement->prependWrapper($me); 24 | } 25 | 26 | protected function assemble() 27 | { 28 | $this->addHtml($this->element); 29 | 30 | if ($this->element->hasBeenValidated() && ! $this->element->isValid()) { 31 | $errors = new HtmlElement('ul', Attributes::create(['class' => 'errors'])); 32 | foreach ($this->element->getMessages() as $message) { 33 | $errors->addHtml(new HtmlElement( 34 | 'li', 35 | null, 36 | new Icon('circle-exclamation', [ 37 | 'title' => $message 38 | ]) 39 | )); 40 | } 41 | 42 | $this->addHtml($errors); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /library/Notifications/Widget/EventSourceBadge.php: -------------------------------------------------------------------------------- 1 | 'event-source-badge']; 20 | 21 | /** 22 | * Create an event source badge with source icon 23 | * 24 | * @param Source $source 25 | */ 26 | public function __construct(Source $source) 27 | { 28 | $this->source = $source; 29 | } 30 | 31 | protected function assemble() 32 | { 33 | if ($this->source->name === null) { 34 | $title = $this->source->type; 35 | } else { 36 | $title = sprintf('%s (%s)', $this->source->name, $this->source->type); 37 | } 38 | 39 | $this 40 | ->getAttributes() 41 | ->add('title', $title); 42 | 43 | $this->addHtml((new Ball(Ball::SIZE_LARGE)) 44 | ->addAttributes(['class' => 'source-icon']) 45 | ->addHtml($this->source->getIcon())); 46 | $this->add(Html::tag('span', ['class' => 'name'], $this->source->name ?? $this->source->type)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/Parameter/PathParameter.php: -------------------------------------------------------------------------------- 1 | $parameter ?? Generator::UNDEFINED, 29 | 'name' => $name ?? Generator::UNDEFINED, 30 | 'description' => $description ?? Generator::UNDEFINED, 31 | 'in' => 'path', 32 | 'required' => $required ?? true, 33 | 'schema' => $schema, 34 | ]; 35 | 36 | $params = $example !== null ? array_merge($params, ['example' => $example]) : $params; 37 | 38 | parent::__construct(...$params); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Timeline/FutureEntry.php: -------------------------------------------------------------------------------- 1 | 'future-entry']; 21 | 22 | protected $continuationType = Entry::TO_NEXT_GRID; 23 | 24 | public function __construct() 25 | { 26 | parent::__construct(0); 27 | } 28 | 29 | public function getColor(int $transparency): string 30 | { 31 | return ''; // No user, no color, CSS will handle it 32 | } 33 | 34 | protected function assembleContainer(BaseHtmlElement $container): void 35 | { 36 | $container->addHtml(new HtmlElement( 37 | 'div', 38 | new Attributes([ 39 | 'title' => $this->translate('Rotation starts in the future') 40 | ]), 41 | new Icon('angle-right'), 42 | new HtmlElement('span', new Attributes(['class' => 'outline'])), 43 | new HtmlElement('span', new Attributes(['class' => 'outline'])), 44 | new HtmlElement('span', new Attributes(['class' => 'outline'])) 45 | )); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /library/Notifications/Model/ContactAddress.php: -------------------------------------------------------------------------------- 1 | add(new MillisecondTimestamp(['changed_at'])); 51 | $behaviors->add(new BoolCast(['deleted'])); 52 | } 53 | 54 | public function createRelations(Relations $relations): void 55 | { 56 | $relations->belongsTo('contact', Contact::class); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /library/Notifications/Widget/ShowMore.php: -------------------------------------------------------------------------------- 1 | 'show-more']; 18 | 19 | protected $tag = 'div'; 20 | 21 | protected $resultSet; 22 | 23 | protected $url; 24 | 25 | protected $label; 26 | 27 | public function __construct(ResultSet $resultSet, Url $url, string $label = null) 28 | { 29 | $this->label = $label; 30 | $this->resultSet = $resultSet; 31 | $this->url = $url; 32 | } 33 | 34 | public function setLabel(string $label): self 35 | { 36 | $this->label = $label; 37 | 38 | return $this; 39 | } 40 | 41 | public function getLabel(): string 42 | { 43 | return $this->label ?: t('Show More'); 44 | } 45 | 46 | public function renderUnwrapped(): string 47 | { 48 | if ($this->resultSet->hasMore()) { 49 | return parent::renderUnwrapped(); 50 | } 51 | 52 | return ''; 53 | } 54 | 55 | protected function assemble() 56 | { 57 | if ($this->resultSet->hasMore()) { 58 | $this->add(new ActionLink($this->getLabel(), $this->url)); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /public/css/list/schedule-list.less: -------------------------------------------------------------------------------- 1 | // Header 2 | .schedules-header { 3 | margin-left: 12em; 4 | margin-right: 1em; 5 | width: ~"calc(100% - 13em)"; // margin-left + margin-right 6 | 7 | .days-header { 8 | display: grid; 9 | grid-template-columns: repeat(7, minmax(2em, 1fr)); 10 | border-left: 2px solid @gray-lighter; 11 | 12 | .column-title { 13 | border-right: 1px solid @gray-lighter; 14 | } 15 | } 16 | } 17 | 18 | // Layout 19 | .item-list .schedule { 20 | .main { 21 | display: flex; 22 | align-items: center; 23 | header .title { 24 | width: 10em; 25 | display: inline-flex; 26 | } 27 | 28 | .caption { 29 | display: flex; 30 | width: 100%; 31 | height: 2em; 32 | align-items: center; 33 | 34 | > .timeline { 35 | flex-grow: 1; 36 | } 37 | } 38 | } 39 | 40 | .timeline .time-grid { 41 | grid-template-columns: minmax(0, 1fr); 42 | grid-template-rows: minmax(0, 1fr); 43 | 44 | .grid { 45 | border-width: 0 0 0 2px; 46 | } 47 | 48 | .grid, 49 | .overlay { 50 | grid-area: ~"1 / 1 / 2 / 2"; 51 | 52 | .entry { 53 | margin: 0; 54 | 55 | .title { 56 | align-items: center; 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | // Design 64 | .item-list .schedule .timeline { 65 | .time-grid { 66 | .grid { 67 | .step { 68 | border-bottom: unset; 69 | } 70 | } 71 | } 72 | 73 | .time-grid:after { 74 | display: none; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /library/Notifications/Widget/TimezoneWarning.php: -------------------------------------------------------------------------------- 1 | 'timezone-warning']; 25 | 26 | /** @var string The schedule timezone */ 27 | protected string $timezone; 28 | 29 | /** 30 | * @param string $timezone The schedule timezone 31 | */ 32 | public function __construct(string $timezone) 33 | { 34 | $this->timezone = $timezone; 35 | } 36 | 37 | public function assemble(): void 38 | { 39 | $this->addHtml(new Icon('warning')); 40 | $this->addHtml(new HtmlElement( 41 | 'p', 42 | null, 43 | new FormattedString( 44 | $this->translate( 45 | 'The schedule uses the %s timezone. All options you select below are based on this timezone.' 46 | ), 47 | [new HtmlElement('strong', null, new Text($this->timezone))] 48 | ), 49 | )); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /library/Notifications/Api/Middleware/DispatchMiddleware.php: -------------------------------------------------------------------------------- 1 | getAttribute('version'); 26 | $endpoint = $request->getAttribute('endpoint'); 27 | $class = sprintf('Icinga\\Module\\Notifications\\Api\\%s\\%s', $version, $endpoint); 28 | 29 | if (! class_exists($class) || ! is_subclass_of($class, RequestHandlerInterface::class)) { 30 | throw new HttpNotFoundException("Endpoint $endpoint not found"); 31 | } 32 | 33 | $endpointHandler = new $class(); 34 | 35 | return $handler->handle($request->withAttribute('endpointHandler', $endpointHandler)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /library/Notifications/View/ChannelRenderer.php: -------------------------------------------------------------------------------- 1 | */ 15 | class ChannelRenderer implements ItemRenderer 16 | { 17 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 18 | { 19 | $attributes->get('class')->addValue('channel'); 20 | } 21 | 22 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 23 | { 24 | $visual->addHtml($item->getIcon()); 25 | } 26 | 27 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 28 | { 29 | $title->addHtml(new Link($item->name, Links::channel($item->id), ['class' => 'subject'])); 30 | } 31 | 32 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 33 | { 34 | } 35 | 36 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 37 | { 38 | } 39 | 40 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 41 | { 42 | } 43 | 44 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 45 | { 46 | return false; // no custom sections 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /library/Notifications/Model/ContactgroupMember.php: -------------------------------------------------------------------------------- 1 | add(new MillisecondTimestamp(['changed_at'])); 51 | $behaviors->add(new BoolCast(['deleted'])); 52 | } 53 | 54 | public function createRelations(Relations $relations): void 55 | { 56 | $relations->belongsTo('contactgroup', Contactgroup::class); 57 | $relations->belongsTo('contact', Contact::class); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /application/forms/EventRuleConfigElements/ConfigProviderInterface.php: -------------------------------------------------------------------------------- 1 | Properties {@see Contact::$id} and {@see Contact::$full_name} are required. 18 | */ 19 | public function fetchContacts(): iterable; 20 | 21 | /** 22 | * Get a list of contact groups to choose as part of a {@see EscalationRecipient} 23 | * 24 | * @return iterable Properties {@see Contactgroup::$id} and {@see Contactgroup::$name} are required. 25 | */ 26 | public function fetchContactGroups(): iterable; 27 | 28 | /** 29 | * Get a list of schedules to choose as part of a {@see EscalationRecipient} 30 | * 31 | * @return iterable Properties {@see Schedule::$id} and {@see Schedule::$name} are required. 32 | */ 33 | public function fetchSchedules(): iterable; 34 | 35 | /** 36 | * Get a list of channels to choose as part of a {@see EscalationRecipient} 37 | * 38 | * @return iterable Properties {@see Channel::$id} and {@see Channel::$name} are required. 39 | */ 40 | public function fetchChannels(): iterable; 41 | } 42 | -------------------------------------------------------------------------------- /application/controllers/EventController.php: -------------------------------------------------------------------------------- 1 | addTitleTab(t('Event')); 22 | 23 | $id = $this->params->getRequired('id'); 24 | 25 | $query = Event::on(Database::get()) 26 | ->with(['object', 'object.source', 'incident', 'incident.object', 'incident.object.source']) 27 | ->withColumns(['object.id_tags', 'incident.object.id_tags']) 28 | ->filter(Filter::equal('event.id', $id)); 29 | 30 | // ipl-orm doesn't detect dependent joins yet 31 | $query->getWith()['event.incident.object']->setJoinType('LEFT'); 32 | 33 | $this->applyRestrictions($query); 34 | 35 | /** @var Event $event */ 36 | $event = $query->first(); 37 | if ($event === null) { 38 | $this->httpNotFound(t('Event not found')); 39 | } 40 | 41 | $this->addControl(new ObjectHeader($event)); 42 | 43 | $this->controls->addAttributes(['class' => 'event-detail']); 44 | 45 | $this->addContent(new EventDetail($event)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /library/Notifications/View/SourceRenderer.php: -------------------------------------------------------------------------------- 1 | */ 15 | class SourceRenderer implements ItemRenderer 16 | { 17 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 18 | { 19 | $attributes->get('class')->addValue('source'); 20 | } 21 | 22 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 23 | { 24 | $visual->addHtml($item->getIcon()); 25 | } 26 | 27 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 28 | { 29 | $title->addHtml(new Link( 30 | $item->name, 31 | Url::fromPath('notifications/source', ['id' => $item->id]), 32 | ['class' => 'subject'] 33 | )); 34 | } 35 | 36 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 37 | { 38 | } 39 | 40 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 41 | { 42 | } 43 | 44 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 45 | { 46 | } 47 | 48 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 49 | { 50 | return false; // no custom sections 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /library/Notifications/View/ScheduleRenderer.php: -------------------------------------------------------------------------------- 1 | */ 15 | class ScheduleRenderer implements ItemRenderer 16 | { 17 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 18 | { 19 | $attributes->get('class')->addValue('schedule'); 20 | } 21 | 22 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 23 | { 24 | } 25 | 26 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 27 | { 28 | $title->addHtml( 29 | new Link( 30 | $item->name, 31 | Links::schedule($item->id), 32 | ['class' => 'subject'] 33 | ) 34 | ); 35 | } 36 | 37 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 38 | { 39 | } 40 | 41 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 42 | { 43 | } 44 | 45 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 46 | { 47 | } 48 | 49 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 50 | { 51 | return false; // no custom sections 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /library/Notifications/Model/Timeperiod.php: -------------------------------------------------------------------------------- 1 | add(new MillisecondTimestamp(['changed_at'])); 50 | $behaviors->add(new BoolCast(['deleted'])); 51 | } 52 | 53 | public function createRelations(Relations $relations): void 54 | { 55 | $relations->belongsTo('rotation', Rotation::class) 56 | ->setCandidateKey('owned_by_rotation_id') 57 | ->setJoinType('LEFT'); 58 | $relations->hasMany('timeperiod_entry', TimeperiodEntry::class) 59 | ->setJoinType('LEFT'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /application/controllers/ChannelController.php: -------------------------------------------------------------------------------- 1 | assertPermission('config/modules'); 17 | } 18 | 19 | public function indexAction(): void 20 | { 21 | $channelId = $this->params->getRequired('id'); 22 | $form = (new ChannelForm(Database::get())) 23 | ->loadChannel($channelId) 24 | ->on(ChannelForm::ON_SUCCESS, function (ChannelForm $form) { 25 | if ($form->getPressedSubmitElement()->getName() === 'delete') { 26 | $form->removeChannel(); 27 | Notification::success(sprintf( 28 | t('Deleted channel "%s" successfully'), 29 | $form->getValue('name') 30 | )); 31 | } else { 32 | $form->editChannel(); 33 | Notification::success(sprintf( 34 | t('Channel "%s" has successfully been saved'), 35 | $form->getValue('name') 36 | )); 37 | } 38 | 39 | $this->redirectNow('__CLOSE__'); 40 | })->handleRequest($this->getServerRequest()); 41 | 42 | $this->addTitleTab(sprintf(t('Channel: %s'), $form->getChannelName())); 43 | 44 | $this->addContent($form); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/Response/SuccessResponse.php: -------------------------------------------------------------------------------- 1 | 'OK', 26 | 201 => 'Created', 27 | 204 => 'No Content', 28 | ]; 29 | 30 | public function __construct( 31 | int|string|null $response = null, 32 | ?string $description = null, 33 | ?array $examples = null, 34 | ?array $headers = null, 35 | ?array $links = null, 36 | ) { 37 | if (! isset(self::SUCCESS_RESPONSES[$response])) { 38 | throw new \InvalidArgumentException('Unexpected response type'); 39 | } 40 | 41 | $content = $response !== 204 42 | ? new OA\JsonContent( 43 | examples: $examples, 44 | ref: '#/components/schemas/SuccessResponse', 45 | ) 46 | : null; 47 | 48 | parent::__construct( 49 | response: $response, 50 | description: $description, 51 | headers: $headers, 52 | content: $content, 53 | links: $links 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /application/controllers/ConfigController.php: -------------------------------------------------------------------------------- 1 | assertPermission('config/modules'); 19 | 20 | parent::init(); 21 | } 22 | 23 | public function databaseAction() 24 | { 25 | $moduleConfig = Config::module('notifications'); 26 | $form = (new DatabaseConfigForm()) 27 | ->populate($moduleConfig->getSection('database')) 28 | ->on(DatabaseConfigForm::ON_SUCCESS, function ($form) use ($moduleConfig) { 29 | $moduleConfig->setSection('database', $form->getValues()); 30 | $moduleConfig->saveIni(); 31 | 32 | Notification::success(t('New configuration has successfully been stored')); 33 | })->handleRequest($this->getServerRequest()); 34 | 35 | $this->mergeTabs($this->Module()->getConfigTabs()->activate('database')); 36 | 37 | $this->addContent($form); 38 | } 39 | 40 | /** 41 | * Merge tabs with other tabs contained in this tab panel 42 | * 43 | * @param Tabs $tabs 44 | * 45 | * @return void 46 | */ 47 | protected function mergeTabs(Tabs $tabs): void 48 | { 49 | /** @var Tab $tab */ 50 | foreach ($tabs->getTabs() as $tab) { 51 | $this->tabs->add($tab->getName(), $tab); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Icinga Notifications Web Changelog 2 | 3 | Please make sure to always read our [Upgrading](https://icinga.com/docs/icinga-notifications-web/latest/doc/05-Upgrading/) 4 | documentation before switching to a new version. 5 | 6 | ## 0.2.0 (2025-11-19) 7 | 8 | With Icinga Notifications 0.2.0 we changed how event rule filters are configured and processed. 9 | Sources are now responsible for deciding which event rules apply to a given event. In the case 10 | of Icinga 2, this allows associating event rules with custom variables, for example. Please 11 | make sure to upgrade Icinga DB to 1.5.0 and Icinga DB Web to 1.3.0 to facilitate this change. 12 | 13 | ## Additional Features 14 | 15 | Some of you asked for this in the past, and now Icinga Notifications Web provides a way to configure 16 | contacts and contact groups using a REST API! But that's not all, the API is also thoroughly documented 17 | using the OpenAPI standard to make it easy to integrate with other tools. Make sure to check it out: 18 | https://icinga.com/docs/icinga-notifications-web/latest/doc/20-REST-API/ 19 | 20 | The schedule configuration got also a highly expected enhancement: Timezone support! 21 | You can now configure a schedule to use a specific timezone other than the local timezone. 22 | When viewing a schedule, you can temporarily switch to any other timezone, allowing you to 23 | easily verify availability even if abroad. 24 | 25 | ### Fixes and Enhancements 26 | 27 | A few months ago, we performed several user testing sessions, and this release includes the changes 28 | suggested during those sessions. Expect to see various small improvements and better user experience. 29 | The schedule configuration in particular has been improved in many ways. 30 | 31 | ## 0.1.0 (2024-07-24) 32 | 33 | Initial release 34 | -------------------------------------------------------------------------------- /library/Notifications/Model/BrowserSession.php: -------------------------------------------------------------------------------- 1 | t('PHP\'s Session Identifier'), 48 | 'username' => t('Username'), 49 | 'user_agent' => t('User-Agent'), 50 | 'authenticated_at' => t('Authenticated At') 51 | ]; 52 | } 53 | 54 | public function getSearchColumns(): array 55 | { 56 | return [ 57 | 'php_session_id', 58 | 'username', 59 | 'user_agent' 60 | ]; 61 | } 62 | 63 | public function createBehaviors(Behaviors $behaviors): void 64 | { 65 | $behaviors->add(new MillisecondTimestamp(['authenticated_at'])); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /library/Notifications/View/ContactRenderer.php: -------------------------------------------------------------------------------- 1 | */ 17 | class ContactRenderer implements ItemRenderer 18 | { 19 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 20 | { 21 | $attributes->get('class')->addValue('contact'); 22 | } 23 | 24 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 25 | { 26 | $visual->addHtml(new HtmlElement( 27 | 'div', 28 | Attributes::create(['class' => 'contact-ball']), 29 | Text::create(grapheme_substr($item->full_name, 0, 1)) 30 | )); 31 | } 32 | 33 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 34 | { 35 | $title->addHtml(new Link($item->full_name, Links::contact($item->id), ['class' => 'subject'])); 36 | } 37 | 38 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 39 | { 40 | } 41 | 42 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 43 | { 44 | } 45 | 46 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 47 | { 48 | } 49 | 50 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 51 | { 52 | return false; // no custom sections 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /library/Notifications/Web/FilterRenderer.php: -------------------------------------------------------------------------------- 1 | getValue(); 15 | if (is_bool($value) && ! $value) { 16 | $this->string .= '!'; 17 | } 18 | 19 | $this->string .= $condition->getColumn(); 20 | 21 | if (is_bool($value)) { 22 | return; 23 | } 24 | 25 | switch (true) { 26 | case $condition instanceof Filter\Unequal: 27 | case $condition instanceof Filter\Unlike: 28 | $this->string .= '!='; 29 | break; 30 | case $condition instanceof Filter\Equal: 31 | case $condition instanceof Filter\Like: 32 | $this->string .= '='; 33 | break; 34 | case $condition instanceof Filter\GreaterThan: 35 | $this->string .= '>'; 36 | break; 37 | case $condition instanceof Filter\LessThan: 38 | $this->string .= '<'; 39 | break; 40 | case $condition instanceof Filter\GreaterThanOrEqual: 41 | $this->string .= '>='; 42 | break; 43 | case $condition instanceof Filter\LessThanOrEqual: 44 | $this->string .= '<='; 45 | break; 46 | } 47 | 48 | if (is_array($value)) { 49 | $this->string .= '(' . join('|', $value) . ')'; 50 | } elseif ($value !== null) { 51 | $this->string .= $value; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/css/common.less: -------------------------------------------------------------------------------- 1 | .controls { 2 | &.contactgroup-detail, 3 | &.event-detail, 4 | &.incident-detail { 5 | .box-shadow(0, 0, 0, 1px, @gray-lighter); 6 | flex-shrink: 0; 7 | z-index: 1; // The content may clip, this ensures the separator is always visible 8 | height: 8em; //TODO(sd): add dynamic fix 9 | } 10 | 11 | &.event-rule-detail { 12 | display: block; 13 | .box-shadow(0, 0, 0, 1px, @gray-lighter); 14 | } 15 | } 16 | 17 | .source-icon { 18 | .ball-solid(@gray-light); 19 | 20 | color: @text-color; 21 | margin-right: 0.2em; 22 | 23 | i.icon { 24 | vertical-align: text-top; 25 | } 26 | 27 | i.icon:before { 28 | margin: unset; 29 | } 30 | } 31 | 32 | .icon-ball { 33 | .ball(); 34 | .ball-size-l(); 35 | .ball-solid(@gray-light); 36 | 37 | color: @text-color; 38 | padding: 0; 39 | display: inline-flex; 40 | align-items: center; 41 | justify-content: center; 42 | 43 | i.icon::before { 44 | font-size: 0.6em; 45 | } 46 | 47 | i.icon.fa-paper-plane::before { 48 | margin-right: .2em; 49 | } 50 | } 51 | 52 | .contact-ball { 53 | .ball(); 54 | .ball-size-l(); 55 | .ball-solid(@gray-semilight); 56 | font-weight: bold; 57 | line-height: 1.2; 58 | text-transform: uppercase; 59 | } 60 | 61 | .severity-crit { 62 | color: @state-critical; 63 | } 64 | 65 | .severity-ok { 66 | color: @state-ok; 67 | } 68 | 69 | .severity-err { 70 | color: @state-unknown; 71 | } 72 | 73 | .severity-warning { 74 | color: @state-warning; 75 | } 76 | 77 | .object-tags-table { 78 | color: @text-color-light; 79 | } 80 | 81 | .add-new-component { 82 | margin: 0 0 1em 1em; 83 | } 84 | 85 | .item-layout.rule footer { 86 | justify-content: end; 87 | align-items: baseline; 88 | 89 | > :not(:last-child) { 90 | margin-right: 0.5em; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /public/js/doc/NOTIFICATIONS.md: -------------------------------------------------------------------------------- 1 | # Notifications Notifications (JS module, service worker) 2 | 3 | ## Architecture 4 | 5 | The desktop notification feature interacts with a service worker. The following illustration shows the architectural 6 | structure of the feature: 7 | 8 | architecture 9 | 10 | The individual browser tabs essentially send their requests through the service worker, which then decides whether to 11 | block the request or if it should forward it to the daemon. 12 | In case the request gets forwarded to the daemon, the service worker injects itself between the daemon and the 13 | browser tab by piping the readable stream. It can thus react to stream abortions from both sides. 14 | 15 | ## Why the stream injection? 16 | 17 | The service worker needs to be able to decide on whether to open up new event-streams or not. If Icinga 2 would only 18 | target desktop devices, it could just use JavaScript's `beforeunload/unload` 19 | events ([check this](https://www.igvita.com/2015/11/20/dont-lose-user-and-app-state-use-page-visibility/)). 20 | 21 | Mobile devices unfortunately behave a little different, and they might not trigger those events while putting the tab in 22 | the background or while freezing it (battery saving features; happens after a while when the phone gets locked). 23 | 24 | The `visibilitychange` event on the other hand, works as intended - even on mobile devices. But it's pretty much 25 | impossible for JavaScript to differentiate between a browser hiding a tab, a tab freeze (as the browser gets put into 26 | the background) or a tab kill. 27 | 28 | As the browser should ideally be constantly connected to the daemon through two event-streams, the service worker 29 | has to know when an event-stream closes down. 30 | -------------------------------------------------------------------------------- /doc/05-Upgrading.md: -------------------------------------------------------------------------------- 1 | # Upgrading Icinga Notifications Web 2 | 3 | Specific version upgrades are described below. Please note that version upgrades are incremental. 4 | If you are upgrading across multiple versions, make sure to follow the steps for each of them. 5 | 6 | ## Upgrading to Icinga Notifications Web 0.2.0 7 | 8 | With Icinga Notifications 0.2.0 we changed how event rule filters are configured and processed. 9 | Previously, event rules could be associated with specific objects by referencing their tags. 10 | This is no longer the case, and how event rules are associated with objects is now fully controlled 11 | by the source bound to the event rule. This means each event rule must now be bound to a specific 12 | source, which is responsible for evaluating which objects the event rule applies to. By following 13 | the upgrading steps of Icinga Notifications 0.2.0, all your event rules have automatically been 14 | migrated and are now bound to the Icinga 2 source that is already configured. If you have filters 15 | configured for event rules, they need to be migrated manually though. You need to access the database 16 | directly for this and update it accordingly. 17 | 18 | Review the currently configured event rules and their filters: 19 | 20 | ```sql 21 | SELECT name, object_filter FROM rule; 22 | ``` 23 | 24 | Take note of the `object_filter` values for each event rule. Store them somewhere. If you want to 25 | migrate them, you can do this only in the UI. In the case of Icinga 2, the supported filter syntax is 26 | the same as in Icinga DB Web and supports the same columns as restrictions do there. `hostgroup/…` 27 | becomes `hostgroup.name=…` and `servicegroup/…` becomes `servicegroup.name=…`. 28 | 29 | To migrate the filters in the UI, you need to update the table first: 30 | 31 | ```sql 32 | UPDATE rule SET object_filter = NULL; 33 | ``` 34 | 35 | Now, you can re-apply the filters in the UI. 36 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/Response/ErrorResponse.php: -------------------------------------------------------------------------------- 1 | 'Bad Request', 27 | 401 => 'Unauthorized', 28 | 403 => 'Forbidden', 29 | 404 => 'Not Found', 30 | 405 => 'Method Not Allowed', 31 | 409 => 'Conflict', 32 | 415 => 'Unsupported Media Type', 33 | 422 => 'Unprocessable Entity', 34 | ]; 35 | 36 | public function __construct( 37 | object|string|null $ref = null, 38 | int $response = 400, 39 | ?array $examples = null, 40 | ?array $headers = null, 41 | ?array $links = null, 42 | ) { 43 | if (isset(self::ERROR_RESPONSES[$response])) { 44 | $description = self::ERROR_RESPONSES[$response]; 45 | } else { 46 | throw new \InvalidArgumentException('Unexpected response type'); 47 | } 48 | 49 | parent::__construct( 50 | ref: $ref, 51 | response: $response, 52 | description: $description, 53 | headers: $headers, 54 | content: new OA\JsonContent( 55 | examples: $examples, 56 | ref: '#/components/schemas/ErrorResponse', 57 | ), 58 | links: $links, 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/Response/SuccessDataResponse.php: -------------------------------------------------------------------------------- 1 | 'rule-escalation-recipient-badge']; 24 | 25 | /** 26 | * Create the rule escalation recipient badge with icon 27 | * 28 | * @param RuleEscalationRecipient $recipient 29 | * @param ?int $moreCount The more count to show 30 | */ 31 | public function __construct(RuleEscalationRecipient $recipient, ?int $moreCount = null) 32 | { 33 | $this->recipient = $recipient; 34 | $this->moreCount = $moreCount; 35 | } 36 | 37 | public function createBadge() 38 | { 39 | $recipientModel = $this->recipient->getRecipient(); 40 | if ($recipientModel === null) { 41 | return; 42 | } 43 | 44 | $nameColumn = 'name'; 45 | $icon = 'users'; 46 | 47 | if ($recipientModel instanceof Contact) { 48 | $nameColumn = 'full_name'; 49 | $icon = 'user'; 50 | } 51 | 52 | return Html::tag('span', ['class' => 'badge'], [new Icon($icon), $recipientModel->$nameColumn]); 53 | } 54 | 55 | protected function assemble() 56 | { 57 | $this->add($this->createBadge()); 58 | 59 | if ($this->moreCount) { 60 | $this->add(Html::tag('span', sprintf(' + %d more', $this->moreCount))); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /library/Notifications/Widget/TimeGrid/ExtraEntryCount.php: -------------------------------------------------------------------------------- 1 | grid = $grid; 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * Set the grid step for which the extra count is being registered 35 | * 36 | * @param DateTime $gridStep 37 | * 38 | * @return $this 39 | */ 40 | public function setGridStep(DateTime $gridStep): self 41 | { 42 | $this->gridStep = clone $gridStep; 43 | 44 | return $this; 45 | } 46 | 47 | protected function assemble() 48 | { 49 | $count = $this->grid->getExtraEntryCount($this->gridStep); 50 | $this->addAttributes(['class' => 'extra-count']) 51 | ->setBaseTarget('_self') 52 | ->setContent( 53 | sprintf( 54 | $this->translatePlural( 55 | '+%d entry', 56 | '+%d entries', 57 | $count 58 | ), 59 | $count 60 | ) 61 | ); 62 | } 63 | 64 | public function renderUnwrapped() 65 | { 66 | if ($this->grid->getExtraEntryCount($this->gridStep) > 0) { 67 | return parent::renderUnwrapped(); 68 | } 69 | 70 | return ''; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/OadV1Get.php: -------------------------------------------------------------------------------- 1 | add(new MillisecondTimestamp(['changed_at'])); 58 | $behaviors->add(new BoolCast(['deleted'])); 59 | } 60 | 61 | public function createRelations(Relations $relations): void 62 | { 63 | $relations->belongsTo('rotation', Rotation::class); 64 | $relations->belongsTo('contact', Contact::class) 65 | ->setJoinType('LEFT'); 66 | $relations->belongsTo('contactgroup', Contactgroup::class) 67 | ->setJoinType('LEFT'); 68 | $relations->hasMany('shift', TimeperiodEntry::class); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /library/Notifications/View/ContactgroupRenderer.php: -------------------------------------------------------------------------------- 1 | */ 17 | class ContactgroupRenderer implements ItemRenderer 18 | { 19 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 20 | { 21 | $attributes->get('class')->addValue('contactgroup'); 22 | } 23 | 24 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 25 | { 26 | $visual->addHtml(new HtmlElement( 27 | 'div', 28 | Attributes::create(['class' => 'contact-ball']), 29 | Text::create(grapheme_substr($item->name, 0, 1)) 30 | )); 31 | } 32 | 33 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 34 | { 35 | if ($layout === 'header') { 36 | $title->addHtml(new HtmlElement('span', new Attributes(['class' => 'subject']), Text::create($item->name))); 37 | } else { 38 | $title->addHtml(new Link($item->name, Links::contactGroup($item->id), ['class' => 'subject'])); 39 | } 40 | } 41 | 42 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 43 | { 44 | } 45 | 46 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 47 | { 48 | } 49 | 50 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 51 | { 52 | } 53 | 54 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 55 | { 56 | return false; // no custom sections 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Detail/ObjectHeader.php: -------------------------------------------------------------------------------- 1 | object = $object; 40 | } 41 | 42 | /** 43 | * @throws NotImplementedError When the object type is not supported 44 | */ 45 | protected function assemble(): void 46 | { 47 | switch (true) { 48 | case $this->object instanceof Event: 49 | $renderer = new EventRenderer(); 50 | 51 | break; 52 | case $this->object instanceof Incident: 53 | $renderer = new IncidentRenderer(); 54 | 55 | break; 56 | case $this->object instanceof Contactgroup: 57 | $renderer = new ContactgroupRenderer(); 58 | 59 | break; 60 | default: 61 | throw new NotImplementedError('Not implemented'); 62 | } 63 | 64 | $layout = new HeaderItemLayout($this->object, $renderer); 65 | 66 | $this->addAttributes($layout->getAttributes()); 67 | $this->addHtml($layout); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /application/forms/EventRuleConfigElements/NotificationConfigProvider.php: -------------------------------------------------------------------------------- 1 | contacts === null) { 27 | $this->contacts = Contact::on(Database::get()) 28 | ->columns(['id', 'full_name']) 29 | ->execute(); 30 | } 31 | 32 | return $this->contacts; 33 | } 34 | 35 | public function fetchContactGroups(): iterable 36 | { 37 | if ($this->contactGroups === null) { 38 | $this->contactGroups = Contactgroup::on(Database::get()) 39 | ->columns(['id', 'name']) 40 | ->execute(); 41 | } 42 | 43 | return $this->contactGroups; 44 | } 45 | 46 | public function fetchSchedules(): iterable 47 | { 48 | if ($this->schedules === null) { 49 | $this->schedules = Schedule::on(Database::get()) 50 | ->columns(['id', 'name']) 51 | ->execute(); 52 | } 53 | 54 | return $this->schedules; 55 | } 56 | 57 | public function fetchChannels(): iterable 58 | { 59 | if ($this->channels === null) { 60 | $this->channels = Channel::on(Database::get()) 61 | ->columns(['id', 'name']) 62 | ->execute(); 63 | } 64 | 65 | return $this->channels; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /library/Notifications/Api/Middleware/ErrorHandlingMiddleware.php: -------------------------------------------------------------------------------- 1 | handle($request); 29 | } catch (HttpExceptionInterface $e) { 30 | return new Response( 31 | $e->getStatusCode(), 32 | array_merge($e->getHeaders(), ['Content-Type' => 'application/json']), 33 | Json::sanitize(['message' => $e->getMessage()]) 34 | ); 35 | } catch (InvalidFilterParameterException $e) { 36 | return new Response( 37 | 400, 38 | ['Content-Type' => 'application/json'], 39 | Json::sanitize([ 40 | 'message' => $e->getMessage() 41 | ]) 42 | ); 43 | } catch (Throwable $e) { 44 | Logger::error($e); 45 | Logger::debug(IcingaException::getConfidentialTraceAsString($e)); 46 | return new Response( 47 | 500, 48 | ['Content-Type' => 'application/json'], 49 | Json::sanitize(['message' => 'An error occurred, please check the log.']) 50 | ); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/php/library/Notifications/Widget/CalendarTest.php: -------------------------------------------------------------------------------- 1 | populate([ 27 | 'mode' => 'month', 28 | 'month' => '2023-02' 29 | ])->ensureAssembled(); 30 | 31 | $calendar = new Calendar(); 32 | $calendar->setControls($controls); 33 | 34 | $this->assertEquals( 35 | new \DateTime('2023-01-30T00:00:00+0100'), 36 | $calendar->getGrid()->getGridStart() 37 | ); 38 | } finally { 39 | date_default_timezone_set($oldTz); 40 | } 41 | } 42 | 43 | /** 44 | * @depends testMonthGridStartsAtTheFirstDayOfItsFirstDaysWeek 45 | */ 46 | public function testMonthGridVisualizesSixWeeks() 47 | { 48 | $oldTz = date_default_timezone_get(); 49 | 50 | try { 51 | date_default_timezone_set('Europe/Berlin'); 52 | 53 | $controls = (new Calendar\Controls())->populate([ 54 | 'mode' => 'month', 55 | 'month' => '2023-02' 56 | ])->ensureAssembled(); 57 | 58 | $calendar = new Calendar(); 59 | $calendar->setControls($controls); 60 | 61 | $this->assertEquals( 62 | new \DateTime('2023-03-13T00:00:00+0100'), 63 | $calendar->getGrid()->getGridEnd() 64 | ); 65 | } finally { 66 | date_default_timezone_set($oldTz); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /library/Notifications/Model/Schedule.php: -------------------------------------------------------------------------------- 1 | t('Name'), 52 | 'changed_at' => t('Changed At'), 53 | 'timezone' => t('Timezone') 54 | ]; 55 | } 56 | 57 | public function getSearchColumns(): array 58 | { 59 | return ['name']; 60 | } 61 | 62 | public function getDefaultSort(): string 63 | { 64 | return 'name'; 65 | } 66 | 67 | public function createBehaviors(Behaviors $behaviors): void 68 | { 69 | $behaviors->add(new MillisecondTimestamp(['changed_at'])); 70 | $behaviors->add(new BoolCast(['deleted'])); 71 | } 72 | 73 | public function createRelations(Relations $relations): void 74 | { 75 | $relations->hasMany('rotation', Rotation::class); 76 | $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class) 77 | ->setJoinType('LEFT'); 78 | $relations->hasMany('incident_history', IncidentHistory::class); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /library/Notifications/Common/PsrLogger.php: -------------------------------------------------------------------------------- 1 | ERROR 26 | * notice -> INFO 27 | */ 28 | private const MAP = [ 29 | LogLevel::EMERGENCY => 'error', 30 | LogLevel::ALERT => 'error', 31 | LogLevel::CRITICAL => 'error', 32 | LogLevel::ERROR => 'error', 33 | LogLevel::WARNING => 'warning', 34 | LogLevel::NOTICE => 'info', 35 | LogLevel::INFO => 'info', 36 | LogLevel::DEBUG => 'debug', 37 | ]; 38 | 39 | /** 40 | * Logs with an arbitrary level. 41 | * 42 | * @param string $level The log level 43 | * @param string|Stringable $message The log message 44 | * @param array $context Additional context variables to interpolate in the message 45 | */ 46 | public function log($level, string|\Stringable $message, array $context = []): void 47 | { 48 | $level = strtolower((string) $level); 49 | $icingaMethod = self::MAP[$level] ?? 'debug'; 50 | 51 | array_unshift($context, (string) $message); 52 | 53 | switch ($icingaMethod) { 54 | case 'error': 55 | IcingaLogger::error(...$context); 56 | break; 57 | case 'warning': 58 | IcingaLogger::warning(...$context); 59 | break; 60 | case 'info': 61 | IcingaLogger::info(...$context); 62 | break; 63 | default: 64 | IcingaLogger::debug(...$context); 65 | break; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Timeline/MinimalGrid.php: -------------------------------------------------------------------------------- 1 | format('H:i:s') !== '00:00:00') { 25 | throw new InvalidArgumentException('Start is not midnight'); 26 | } 27 | 28 | return parent::setGridStart($start); 29 | } 30 | 31 | protected function calculateGridEnd(): DateTime 32 | { 33 | return (clone $this->getGridStart())->add(new DateInterval(sprintf('P%dD', self::DAYS))); 34 | } 35 | 36 | protected function getNoOfVisuallyConnectedHours(): int 37 | { 38 | return self::DAYS * 24; 39 | } 40 | 41 | protected function getMaximumRowSpan(): int 42 | { 43 | return 1; 44 | } 45 | 46 | protected function createGridSteps(): Traversable 47 | { 48 | $interval = new DateInterval('P1D'); 49 | $dayStartsAt = clone $this->getGridStart(); 50 | 51 | for ($x = 0; $x < self::DAYS; $x++) { 52 | $nextDay = (clone $dayStartsAt)->add($interval); 53 | 54 | yield new GridStep($dayStartsAt, $nextDay, $x, 0); 55 | 56 | $dayStartsAt = $nextDay; 57 | } 58 | } 59 | 60 | protected function assemble(): void 61 | { 62 | $this->style->addFor($this, [ 63 | '--primaryRows' => 1, 64 | '--primaryColumns' => self::DAYS, 65 | '--columnsPerStep' => 48, 66 | '--rowsPerStep' => 1, 67 | '--stepRowHeight' => '1.5em' 68 | ]); 69 | 70 | $overlay = $this->createGridOverlay(); 71 | $this->addHtml( 72 | $this->createGrid(), 73 | $overlay 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /doc/01-About.md: -------------------------------------------------------------------------------- 1 | # Icinga Notifications Web 2 | 3 | Icinga Notifications is a set of components that processes received events from various sources, manages 4 | incidents and forwards notifications to predefined contacts. The components are: 5 | 6 | * [Icinga Notifications](https://github.com/Icinga/icinga-notifications), which receives events and sends notifications. 7 | * Icinga Notifications Web, which lets users configure Icinga Notifications and manage incidents. 8 | 9 | Icinga 2 itself and other sources propagate state updates and other events to [Icinga Notifications](https://github.com/Icinga/icinga-notifications). 10 | 11 | ## Big Picture 12 | 13 | ![Icinga Notifications Architecture](res/notifications-architecture.png) 14 | 15 | All configuration of Icinga Notifications is done via Icinga Notifications Web. This includes the setup of sources 16 | Icinga Notifications will receive events from. To set up a source, an accompanying integration in Icinga Web is 17 | required. At the moment, compatible integrations are available for: 18 | 19 | * Icinga 2, by using Icinga DB as backend 20 | * Icinga for Kubernetes 21 | 22 | Icinga Notifications receives events from the configured sources and decides whether to open an incident and when to 23 | forward them to which recipients. Icinga Notifications Web allows configuring these rules and managing incidents. To 24 | send notifications, Icinga Notifications is able to facilitate various types of channels, e.g., email, Rocket.Chat, 25 | webhook, etc. 26 | 27 | ## Available Channels 28 | 29 | The following channels are currently available out of the box: 30 | 31 | * _email_: Email submission via SMTP 32 | * _rocketchat_: Rocket.Chat 33 | * _webhook_: Configurable HTTP/HTTPS queries for third-party backends 34 | 35 | Additional custom channels can be developed independently of Icinga Notifications, 36 | following the [channel specification](https://icinga.com/docs/icinga-notifications/latest/doc/10-Channels). 37 | 38 | ## Installation 39 | 40 | To install Icinga Notifications Web, see [Installation](02-Installation.md). 41 | 42 | ## License 43 | 44 | Icinga Notifications Web and its documentation are licensed under the terms of the [GNU General Public License Version 2](https://github.com/Icinga/icinga-notifications-web?tab=GPL-2.0-1-ov-file#readme). 45 | -------------------------------------------------------------------------------- /application/controllers/IncidentController.php: -------------------------------------------------------------------------------- 1 | addTitleTab(t('Incident')); 25 | 26 | $id = $this->params->getRequired('id'); 27 | 28 | $query = Incident::on(Database::get()) 29 | ->with(['object', 'object.source']) 30 | ->withColumns('object.id_tags') 31 | ->filter(Filter::equal('incident.id', $id)); 32 | 33 | $this->applyRestrictions($query); 34 | 35 | /** @var Incident $incident */ 36 | $incident = $query->first(); 37 | if ($incident === null) { 38 | $this->httpNotFound(t('Incident not found')); 39 | } 40 | 41 | $this->addControl(new ObjectHeader($incident)); 42 | 43 | $this->controls->addAttributes(['class' => 'incident-detail']); 44 | 45 | $contact = Contact::on(Database::get()) 46 | ->columns('id') 47 | ->filter(Filter::equal('username', $this->Auth()->getUser()->getUsername())) 48 | ->first(); 49 | 50 | if ($contact !== null) { 51 | $this->addControl( 52 | (new IncidentQuickActions($incident, $contact->id)) 53 | ->on(IncidentQuickActions::ON_SUCCESS, function () use ($incident) { 54 | $this->redirectNow(Links::incident($incident->id)); 55 | }) 56 | ->handleRequest($this->getServerRequest()) 57 | ); 58 | } 59 | 60 | $this->addContent(new IncidentDetail($incident)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/OadV1Delete.php: -------------------------------------------------------------------------------- 1 | getName(), self::HOST_PREFIX)) { 37 | $varName = substr($def->getName(), strlen(self::HOST_PREFIX)); 38 | } elseif (str_starts_with($def->getName(), self::SERVICE_PREFIX)) { 39 | $varName = substr($def->getName(), strlen(self::SERVICE_PREFIX)); 40 | } else { 41 | return; 42 | } 43 | 44 | if (str_ends_with($varName, '[*]')) { 45 | $varName = substr($varName, 0, -3); 46 | } 47 | 48 | $def->setLabel(sprintf( 49 | $this->translate( 50 | ucfirst(substr($def->getName(), 0, strpos($def->getName(), '.'))) . ' %s', 51 | ), 52 | $varName 53 | )); 54 | } 55 | 56 | public function rewriteCondition(Filter\Condition $condition, $relation = null) 57 | { 58 | if (! $this->isSelectableColumn($condition->metaData()->get('columnName', ''))) { 59 | return null; 60 | } 61 | 62 | $class = get_class($condition); 63 | 64 | return new $class( 65 | $relation . 'object.extra_tag.' . substr($condition->getColumn(), strlen($relation)), 66 | $condition->getValue() 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /library/Notifications/Model/Contactgroup.php: -------------------------------------------------------------------------------- 1 | t('Name'), 54 | 'changed_at' => t('Changed At'), 55 | 'external_uuid' => t('UUID') 56 | ]; 57 | } 58 | 59 | public function getSearchColumns(): array 60 | { 61 | return ['name']; 62 | } 63 | 64 | public function createBehaviors(Behaviors $behaviors): void 65 | { 66 | $behaviors->add(new MillisecondTimestamp(['changed_at'])); 67 | $behaviors->add(new BoolCast(['deleted'])); 68 | } 69 | 70 | public function createRelations(Relations $relations): void 71 | { 72 | $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class) 73 | ->setJoinType('LEFT'); 74 | $relations->hasMany('incident_history', IncidentHistory::class); 75 | $relations->hasMany('contactgroup_member', ContactgroupMember::class); 76 | $relations 77 | ->belongsToMany('contact', Contact::class) 78 | ->through('contactgroup_member') 79 | ->setJoinType('LEFT'); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /library/Notifications/View/EventRuleRenderer.php: -------------------------------------------------------------------------------- 1 | */ 18 | class EventRuleRenderer implements ItemRenderer 19 | { 20 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 21 | { 22 | $attributes->get('class')->addValue('rule'); 23 | } 24 | 25 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 26 | { 27 | } 28 | 29 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 30 | { 31 | $title->addHtml(new Link($item->name, Links::eventRule($item->id), ['class' => 'subject'])); 32 | } 33 | 34 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 35 | { 36 | } 37 | 38 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 39 | { 40 | $rs = $item->rule_escalation->first(); 41 | if ($rs) { 42 | $recipientCount = $rs->rule_escalation_recipient->count(); 43 | if ($recipientCount) { 44 | $info->addHtml(new RuleEscalationRecipientBadge( 45 | $rs->rule_escalation_recipient->first(), 46 | $recipientCount - 1 47 | )); 48 | } 49 | } 50 | } 51 | 52 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 53 | { 54 | if ($item->object_filter) { 55 | $footer->addHtml(new Icon('filter')); 56 | } 57 | 58 | $escalationCount = $item->rule_escalation->count(); 59 | if ($escalationCount > 1) { 60 | $footer->addHtml(new Icon('code-branch'), new Text($escalationCount)); 61 | } 62 | } 63 | 64 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 65 | { 66 | return false; // no custom sections 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /library/Notifications/Hook/V1/SourceHook.php: -------------------------------------------------------------------------------- 1 | > target => label | optgroup => (target => label) 43 | */ 44 | public function getRuleFilterTargets(int $sourceId): array; 45 | 46 | /** 47 | * Get an editor for the given filter 48 | * 49 | * The returned form MUST NOT have a request associated with it and MUST NOT navigate away upon submission. 50 | * The action of the form is overridden in any case. 51 | * 52 | * @param string $filter A filter template or a filter previously serialized by {@see serializeRuleFilter()} 53 | * 54 | * @return Form 55 | */ 56 | public function getRuleFilterEditor(string $filter): Form; 57 | 58 | /** 59 | * Serialize the filter of the given editor 60 | * 61 | * The returned string is stored as-is in the database. The source MUST be able to deserialize it. 62 | * Upon editing by a user, {@see getRuleFilterEditor()} will be called with the serialized filter. 63 | * 64 | * @param Form $editor 65 | * 66 | * @return string 67 | */ 68 | public function serializeRuleFilter(Form $editor): string; 69 | } 70 | -------------------------------------------------------------------------------- /application/forms/EventRuleForm.php: -------------------------------------------------------------------------------- 1 | */ 18 | protected array $sources = []; 19 | 20 | /** @var bool Whether this form is for a new rule */ 21 | protected bool $isNew = false; 22 | 23 | /** 24 | * Set the sources to choose from 25 | * 26 | * @param array $sources 27 | * 28 | * @return $this 29 | */ 30 | public function setAvailableSources(array $sources): self 31 | { 32 | $this->sources = $sources; 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * Set whether this form is for a new rule 39 | * 40 | * @return $this 41 | */ 42 | public function setIsNew(): static 43 | { 44 | $this->isNew = true; 45 | 46 | return $this; 47 | } 48 | 49 | protected function assemble(): void 50 | { 51 | $this->applyDefaultElementDecorators(); 52 | $this->addCsrfCounterMeasure(); 53 | 54 | $this->addElement( 55 | 'text', 56 | 'name', 57 | [ 58 | 'label' => $this->translate('Title'), 59 | 'required' => true 60 | ] 61 | ); 62 | 63 | $this->addElement('select', 'source', [ 64 | 'label' => $this->translate('Source'), 65 | 'required' => true, 66 | 'options' => ['' => ' - ' . $this->translate('Please choose') . ' - '] + $this->sources, 67 | 'disabledOptions' => [''], 68 | 'value' => '' 69 | ]); 70 | if (! $this->isNew) { 71 | $this->getElement('source') 72 | ->setDescription($this->translate( 73 | 'Choosing a different source will reset all filters of the rule' 74 | )) 75 | ->getDecorators() 76 | ->replaceDecorator('Description', DescriptionDecorator::class, ['class' => 'description']); 77 | } 78 | 79 | $this->addElement('submit', 'btn_submit', [ 80 | 'label' => $this->translate('Save') 81 | ]); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /application/controllers/SourceController.php: -------------------------------------------------------------------------------- 1 | assertPermission('config/modules'); 22 | } 23 | 24 | public function indexAction(): void 25 | { 26 | $sourceId = (int) $this->params->getRequired('id'); 27 | 28 | $form = (new SourceForm(Database::get())) 29 | ->setCsrfCounterMeasureId(Session::getSession()->getId()) 30 | ->loadSource($sourceId) 31 | ->on(Form::ON_SUBMIT, function (SourceForm $form): never { 32 | Database::get()->transaction(fn () => $form->editSource()); 33 | Notification::success(sprintf( 34 | $this->translate('Updated source "%s" successfully'), 35 | $form->getSourceName() 36 | )); 37 | 38 | $this->switchToSingleColumnLayout(); 39 | })->handleRequest($this->getServerRequest()); 40 | 41 | $this->addTitleTab(sprintf($this->translate('Source: %s'), $form->getSourceName())); 42 | $this->addContent($form); 43 | } 44 | 45 | public function deleteAction(): void 46 | { 47 | $sourceId = (int) $this->params->getRequired('id'); 48 | 49 | $form = (new DeleteSourceForm()) 50 | ->setCsrfCounterMeasureId(Session::getSession()->getId()) 51 | ->loadSource($sourceId) 52 | ->setAction(Url::fromRequest()->getAbsoluteUrl()) 53 | ->on(Form::ON_SUBMIT, function (DeleteSourceForm $form): never { 54 | Database::get()->transaction(fn (Connection $db) => $form->removeSource($db)); 55 | Notification::success($this->translate('Deleted source successfully')); 56 | $this->switchToSingleColumnLayout(); 57 | }) 58 | ->handleRequest($this->getServerRequest()); 59 | 60 | $this->setTitle(sprintf($this->translate('Delete Source: %s'), $form->getSourceName())); 61 | $this->addContent($form); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /application/forms/EventRuleConfigElements/Escalations.php: -------------------------------------------------------------------------------- 1 | 'escalations']; 24 | 25 | protected function createAddButton(): SubmitButtonElement 26 | { 27 | /** @var SubmitButtonElement $button */ 28 | $button = $this->createElement('submitButton', 'add-button', [ 29 | 'title' => $this->translate('Add Escalation'), 30 | 'label' => new Icon('plus'), 31 | 'class' => ['add-button', 'animated'] 32 | ]); 33 | 34 | $button->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'add-button-wrapper']))); 35 | 36 | return $button; 37 | } 38 | 39 | protected function createDynamicElement(int $no, ?SubmitButtonElement $removeButton): FormElement 40 | { 41 | $escalation = new Escalation($no, ['provider' => $this->provider, 'immediate' => $no === 0]); 42 | if ($removeButton !== null) { 43 | $escalation->setRemoveButton($removeButton); 44 | } 45 | 46 | return $escalation; 47 | } 48 | 49 | /** 50 | * Prepare the escalations for display 51 | * 52 | * @param iterable $escalations 53 | * 54 | * @return array 55 | */ 56 | public static function prepare(iterable $escalations): array 57 | { 58 | $values = []; 59 | foreach ($escalations as $escalation) { 60 | $values[] = Escalation::prepare($escalation); 61 | } 62 | 63 | return $values; 64 | } 65 | 66 | /** 67 | * Get the escalations to store 68 | * 69 | * @return array 70 | */ 71 | public function getEscalations(): array 72 | { 73 | $escalations = []; 74 | foreach ($this->ensureAssembled()->getElements() as $element) { 75 | if ($element instanceof Escalation) { 76 | $escalations[] = $element; 77 | } 78 | } 79 | 80 | return $escalations; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /library/Notifications/Widget/ItemList/ObjectList.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | class ObjectList extends ItemList 33 | { 34 | use DetailActions; 35 | 36 | protected function init(): void 37 | { 38 | $this->initializeDetailActions(); 39 | } 40 | 41 | protected function createListItem(object $data): ListItem 42 | { 43 | $item = parent::createListItem($data); 44 | 45 | if (! $this->getDetailActionsDisabled()) { 46 | $link = match (true) { 47 | $data instanceof Event => Url::fromPath('notifications/event'), 48 | $data instanceof Incident => Url::fromPath('notifications/incident'), 49 | $data instanceof Schedule => Url::fromPath('notifications/schedule'), 50 | $data instanceof Rule => Url::fromPath('notifications/event-rule'), 51 | $data instanceof Contact => Url::fromPath('notifications/contact'), 52 | $data instanceof Contactgroup => Url::fromPath('notifications/contact-group'), 53 | $data instanceof Channel => Url::fromPath('notifications/channel'), 54 | $data instanceof Source => Url::fromPath('notifications/source'), 55 | default => null 56 | }; 57 | 58 | if ($link !== null) { 59 | $this->setDetailUrl($link); 60 | $this->addDetailFilterAttribute($item, Filter::equal('id', $data->id)); 61 | } 62 | } 63 | 64 | return $item; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /library/Notifications/Model/Rule.php: -------------------------------------------------------------------------------- 1 | t('Name'), 57 | 'source_id' => t('Source ID'), 58 | 'timeperiod_id' => t('Timeperiod ID'), 59 | 'object_filter' => t('Object Filter'), 60 | 'changed_at' => t('Changed At') 61 | ]; 62 | } 63 | 64 | public function getSearchColumns(): array 65 | { 66 | return ['name']; 67 | } 68 | 69 | public function getDefaultSort(): array 70 | { 71 | return ['name']; 72 | } 73 | 74 | public function createBehaviors(Behaviors $behaviors): void 75 | { 76 | $behaviors->add(new MillisecondTimestamp(['changed_at'])); 77 | $behaviors->add(new BoolCast(['deleted'])); 78 | } 79 | 80 | public function createRelations(Relations $relations): void 81 | { 82 | $relations->belongsTo('source', Source::class); 83 | $relations->hasMany('rule_escalation', RuleEscalation::class); 84 | 85 | $relations 86 | ->belongsToMany('incident', Incident::class) 87 | ->through('incident_rule') 88 | ->setJoinType('LEFT'); 89 | 90 | $relations->hasMany('incident_history', IncidentHistory::class)->setJoinType('LEFT'); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /library/Notifications/Model/TimeperiodEntry.php: -------------------------------------------------------------------------------- 1 | add(new MillisecondTimestamp([ 67 | 'start_time', 68 | 'end_time', 69 | 'until_time', 70 | 'changed_at' 71 | ])); 72 | $behaviors->add(new BoolCast(['deleted'])); 73 | } 74 | 75 | public function createRelations(Relations $relations): void 76 | { 77 | $relations->belongsTo('timeperiod', Timeperiod::class); 78 | $relations->belongsTo('member', RotationMember::class); 79 | } 80 | 81 | /** 82 | * Convert the entry to a RecurrenceRule 83 | * 84 | * @return Rule 85 | */ 86 | public function toRecurrenceRule(): Rule 87 | { 88 | $rrule = new Rule($this->rrule, $this->start_time, null, $this->timezone); 89 | 90 | if ($this->rrule === null) { 91 | $rrule->setFreq(Frequency::YEARLY); 92 | $rrule->setUntil($this->start_time); 93 | } 94 | 95 | return $rrule; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /library/Notifications/Widget/TimeGrid/GridStep.php: -------------------------------------------------------------------------------- 1 | 'step']; 29 | 30 | /** 31 | * Create a new grid step 32 | * 33 | * @param DateTime $start The start time of the grid step 34 | * @param DateTime $end The end time of the grid step 35 | * @param int $x The x position of the step on the grid 36 | * @param int $y The y position of the step on the grid 37 | */ 38 | public function __construct(DateTime $start, DateTime $end, int $x, int $y) 39 | { 40 | $this->start = $start; 41 | $this->end = $end; 42 | $this->coordinates = [$x, $y]; 43 | } 44 | 45 | /** 46 | * Get the start time of the grid step 47 | * 48 | * @return DateTime 49 | */ 50 | public function getStart(): DateTime 51 | { 52 | return $this->start; 53 | } 54 | 55 | /** 56 | * Get the end time of the grid step 57 | * 58 | * @return DateTime 59 | */ 60 | public function getEnd(): DateTime 61 | { 62 | return $this->end; 63 | } 64 | 65 | /** 66 | * Get the coordinates of the grid step 67 | * 68 | * @return array{int, int} The x and y position of the step on the grid 69 | */ 70 | public function getCoordinates(): array 71 | { 72 | return $this->coordinates; 73 | } 74 | 75 | protected function registerAttributeCallbacks(Attributes $attributes) 76 | { 77 | $this->getAttributes() 78 | ->registerAttributeCallback('data-start', function () { 79 | return $this->getStart()->format(DateTimeInterface::ATOM); 80 | }) 81 | ->registerAttributeCallback('data-x-position', function () { 82 | return $this->coordinates[0]; 83 | }) 84 | ->registerAttributeCallback('data-y-position', function () { 85 | return $this->coordinates[1]; 86 | }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /application/forms/EventRuleConfigElements/EscalationRecipients.php: -------------------------------------------------------------------------------- 1 | 'escalation-recipients']; 24 | 25 | protected function createAddButton(): SubmitButtonElement 26 | { 27 | /** @var SubmitButtonElement $button */ 28 | $button = $this->createElement('submitButton', 'add-button', [ 29 | 'title' => $this->translate('Add Recipient'), 30 | 'label' => new Icon('plus'), 31 | 'class' => ['add-button', 'animated'] 32 | ]); 33 | 34 | $button->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'add-button-wrapper']))); 35 | 36 | return $button; 37 | } 38 | 39 | protected function createDynamicElement(int $no, ?SubmitButtonElement $removeButton): FormElement 40 | { 41 | $recipient = new EscalationRecipient($no, ['provider' => $this->provider]); 42 | if ($removeButton !== null) { 43 | $recipient->setRemoveButton($removeButton); 44 | } 45 | 46 | return $recipient; 47 | } 48 | 49 | /** 50 | * Prepare the recipients for display 51 | * 52 | * @param iterable $recipients 53 | * 54 | * @return array 55 | */ 56 | public static function prepare(iterable $recipients): array 57 | { 58 | $values = []; 59 | foreach ($recipients as $recipient) { 60 | $values[] = EscalationRecipient::prepare($recipient); 61 | } 62 | 63 | return $values; 64 | } 65 | 66 | /** 67 | * Get the recipients to store 68 | * 69 | * @return array 70 | */ 71 | public function getRecipients(): array 72 | { 73 | $recipients = []; 74 | foreach ($this->ensureAssembled()->getElements() as $element) { 75 | if ($element instanceof EscalationRecipient) { 76 | $recipients[] = $element; 77 | } 78 | } 79 | 80 | return $recipients; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /library/Notifications/Model/Objects.php: -------------------------------------------------------------------------------- 1 | $id_tags 32 | */ 33 | class Objects extends Model 34 | { 35 | public function getTableName(): string 36 | { 37 | return 'object'; 38 | } 39 | 40 | public function getKeyName(): string 41 | { 42 | return 'id'; 43 | } 44 | 45 | public function getColumns(): array 46 | { 47 | return [ 48 | 'source_id', 49 | 'name', 50 | 'url', 51 | 'mute_reason' 52 | ]; 53 | } 54 | 55 | /** 56 | * @return string[] 57 | */ 58 | public function getSearchColumns(): array 59 | { 60 | return ['object_id_tag.tag', 'object_id_tag.value']; 61 | } 62 | 63 | /** 64 | * @return string 65 | */ 66 | public function getDefaultSort(): string 67 | { 68 | return 'object.name'; 69 | } 70 | 71 | public function createBehaviors(Behaviors $behaviors): void 72 | { 73 | $behaviors->add(new Binary(['id'])); 74 | $behaviors->add(new IdTagAggregator()); 75 | } 76 | 77 | public function createRelations(Relations $relations): void 78 | { 79 | $relations->hasMany('event', Event::class); 80 | $relations->hasMany('incident', Incident::class); 81 | 82 | $relations->hasMany('object_id_tag', ObjectIdTag::class); 83 | $relations->hasMany('tag', Tag::class); 84 | $relations->hasMany('object_extra_tag', ObjectExtraTag::class) 85 | ->setJoinType('LEFT'); 86 | $relations->hasMany('extra_tag', ExtraTag::class) 87 | ->setJoinType('LEFT'); 88 | 89 | $relations->belongsTo('source', Source::class)->setJoinType('LEFT'); 90 | } 91 | 92 | public function getName(): ValidHtml 93 | { 94 | return ObjectsRendererHook::getObjectName($this); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /application/controllers/ApiController.php: -------------------------------------------------------------------------------- 1 | assertPermission('notifications/api'); 34 | 35 | $pipeline = new MiddlewarePipeline([ 36 | new ErrorHandlingMiddleware(), 37 | new LegacyRequestConversionMiddleware($this->getRequest()), 38 | new RoutingMiddleware(), 39 | new DispatchMiddleware(), 40 | new ValidationMiddleware(), 41 | new EndpointExecutionMiddleware(), 42 | ]); 43 | 44 | $this->emitResponse($pipeline->execute()); 45 | 46 | exit; 47 | } 48 | 49 | /** 50 | * Emit the HTTP response to the client. 51 | * 52 | * @param ResponseInterface $response The response object to emit. 53 | * 54 | * @return void 55 | */ 56 | protected function emitResponse(ResponseInterface $response): void 57 | { 58 | do { 59 | ob_end_clean(); 60 | } while (ob_get_level() > 0); 61 | 62 | http_response_code($response->getStatusCode()); 63 | 64 | foreach ($response->getHeaders() as $name => $values) { 65 | foreach ($values as $value) { 66 | header(sprintf('%s: %s', $name, $value), false); 67 | } 68 | } 69 | header('Content-Type: application/json'); 70 | 71 | $body = $response->getBody(); 72 | while (! $body->eof()) { 73 | echo $body->read(8192); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /library/Notifications/Common/DetailActions.php: -------------------------------------------------------------------------------- 1 | detailActionsDisabled = $state; 26 | 27 | return $this; 28 | } 29 | 30 | /** 31 | * Get whether this list should be an action-list 32 | * 33 | * @return bool 34 | */ 35 | public function getDetailActionsDisabled(): bool 36 | { 37 | return $this->detailActionsDisabled; 38 | } 39 | 40 | /** 41 | * Prepare this list as action-list 42 | * 43 | * @return $this 44 | */ 45 | public function initializeDetailActions(): static 46 | { 47 | $this->getAttributes() 48 | ->registerAttributeCallback( 49 | 'class', 50 | fn () => $this->getDetailActionsDisabled() ? null : 'action-list' 51 | ); 52 | 53 | return $this; 54 | } 55 | 56 | /** 57 | * Set the url to use for a single selected list item 58 | * 59 | * @param Url $url 60 | * 61 | * @return $this 62 | */ 63 | protected function setDetailUrl(Url $url): static 64 | { 65 | $this->getAttributes() 66 | ->registerAttributeCallback( 67 | 'data-icinga-detail-url', 68 | fn() => $this->getDetailActionsDisabled() ? null : (string) $url 69 | ); 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Associate the given element with the given single-selection filter 76 | * 77 | * @param BaseHtmlElement $element 78 | * @param Filter\Rule $filter 79 | * 80 | * @return $this 81 | */ 82 | public function addDetailFilterAttribute(BaseHtmlElement $element, Filter\Rule $filter): static 83 | { 84 | $element->getAttributes() 85 | ->registerAttributeCallback( 86 | 'data-action-item', 87 | fn() => ! $this->getDetailActionsDisabled() 88 | ) 89 | ->registerAttributeCallback( 90 | 'data-icinga-detail-filter', 91 | fn() => $this->getDetailActionsDisabled() ? null : QueryString::render($filter) 92 | ); 93 | 94 | return $this; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /library/Notifications/Model/Behavior/ObjectTags.php: -------------------------------------------------------------------------------- 1 | query = $query; 24 | 25 | return $this; 26 | } 27 | 28 | public function rewriteCondition(Filter\Condition $condition, $relation = null): ?Rule 29 | { 30 | $filterAll = null; 31 | /** @var string $relation */ 32 | /** @var ?string $column */ 33 | $column = $condition->metaData()->get('columnName'); 34 | if ($column !== null) { 35 | if (substr($relation, -10) === 'extra_tag.') { 36 | $relation = substr($relation, 0, -10) . 'object_extra_tag.'; 37 | } else { // tag. 38 | $relation = substr($relation, 0, -4) . 'object_id_tag.'; 39 | } 40 | 41 | $nameFilter = Filter::like($relation . 'tag', $column); 42 | $class = get_class($condition); 43 | $valueFilter = new $class($relation . 'value', $condition->getValue()); 44 | 45 | $filterAll = Filter::all($nameFilter, $valueFilter); 46 | } 47 | 48 | return $filterAll; 49 | } 50 | 51 | public function rewriteColumn($column, $relation = null): AliasedExpression 52 | { 53 | /** @var string $relation */ 54 | /** @var string $column */ 55 | $model = $this->query->getModel(); 56 | $subQuery = $this->query->createSubQuery(new $model(), $relation) 57 | ->limit(1) 58 | ->columns('value') 59 | ->filter(Filter::equal('tag', $column)); 60 | 61 | $this->applyRestrictions($subQuery); 62 | 63 | $alias = $this->query->getDb()->quoteIdentifier([str_replace('.', '_', $relation) . "_$column"]); 64 | 65 | [$select, $values] = $this->query->getDb()->getQueryBuilder()->assembleSelect($subQuery->assembleSelect()); 66 | return new AliasedExpression($alias, "($select)", null, ...$values); 67 | } 68 | 69 | public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void 70 | { 71 | $def->setLabel(ucfirst($def->getName())); 72 | } 73 | 74 | public function isSelectableColumn(string $name): bool 75 | { 76 | return true; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /library/Notifications/Common/Auth.php: -------------------------------------------------------------------------------- 1 | getAuth()->getUser(); 35 | if ($user->isUnrestricted()) { 36 | return; 37 | } 38 | 39 | $queryFilter = Filter::any(); 40 | foreach ($user->getRoles() as $role) { 41 | $roleFilter = Filter::all(); 42 | /** @var string $restriction */ 43 | $restriction = $role->getRestrictions('notifications/filter/objects'); 44 | if ($restriction) { 45 | $roleFilter->add($this->parseRestriction($restriction, 'notifications/filter/objects')); 46 | } 47 | 48 | if (! $roleFilter->isEmpty()) { 49 | $queryFilter->add($roleFilter); 50 | } 51 | } 52 | 53 | $query->filter($queryFilter); 54 | } 55 | 56 | /** 57 | * Parse the given restriction 58 | * 59 | * @param string $queryString 60 | * @param string $restriction The name of the restriction 61 | * 62 | * @return Filter\Rule 63 | */ 64 | protected function parseRestriction(string $queryString, string $restriction): Filter\Rule 65 | { 66 | // 'notifications/filter/objects' restriction 67 | return QueryString::fromString($queryString) 68 | ->on( 69 | QueryString::ON_CONDITION, 70 | function (Filter\Condition $condition) { 71 | //The condition column is actually the tag (eg): tag = hostgroup/linux, value = null 72 | if ($condition->getValue() === true) { 73 | $column = 'object.object_extra_tag.tag'; 74 | 75 | $condition->setValue($condition->getColumn()); 76 | $condition->setColumn($column); 77 | } 78 | //TODO: add support for foo=bar (tag=value) 79 | } 80 | )->parse(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /library/Notifications/Common/ConfigurationTabs.php: -------------------------------------------------------------------------------- 1 | getRequest()->getActionName() === 'index') { 24 | if ($this->Auth()->hasPermission('notifications/config/schedules')) { 25 | $tabs->add('schedules', [ 26 | 'label' => $this->translate('Schedules'), 27 | 'url' => match ($this->getRequest()->getControllerName()) { 28 | 'schedules' => $this->getRequest()->getUrl(), 29 | default => Links::schedules() 30 | }, 31 | 'baseTarget' => '_main' 32 | ]); 33 | } 34 | 35 | if ($this->Auth()->hasPermission('notifications/config/event-rules')) { 36 | $tabs->add('event-rules', [ 37 | 'label' => $this->translate('Event Rules'), 38 | 'url' => match ($this->getRequest()->getControllerName()) { 39 | 'event-rules' => $this->getRequest()->getUrl(), 40 | default => Links::eventRules() 41 | }, 42 | 'baseTarget' => '_main' 43 | ]); 44 | } 45 | 46 | if ($this->Auth()->hasPermission('notifications/config/contacts')) { 47 | $tabs->add('contacts', [ 48 | 'label' => $this->translate('Contacts'), 49 | 'url' => match ($this->getRequest()->getControllerName()) { 50 | 'contacts' => $this->getRequest()->getUrl(), 51 | default => Links::contacts() 52 | }, 53 | 'baseTarget' => '_main' 54 | ])->add('contact-groups', [ 55 | 'label' => $this->translate('Contact Groups'), 56 | 'url' => match ($this->getRequest()->getControllerName()) { 57 | 'contact-groups' => $this->getRequest()->getUrl(), 58 | default => Links::contactGroups() 59 | }, 60 | 'baseTarget' => '_main' 61 | ]); 62 | } 63 | } 64 | 65 | return $tabs; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /library/Notifications/Widget/TimeGrid/Timescale.php: -------------------------------------------------------------------------------- 1 | 'timescale']; 27 | 28 | /** @var int The number of days shown */ 29 | protected $days; 30 | 31 | /** @var Style */ 32 | protected $style; 33 | 34 | /** 35 | * Create a new Timescale 36 | * 37 | * @param int $days 38 | * @param Style $style 39 | */ 40 | public function __construct(int $days, Style $style) 41 | { 42 | $this->days = $days; 43 | $this->style = $style; 44 | } 45 | 46 | public function assemble(): void 47 | { 48 | if ($this->days === 1) { 49 | $timestampPerDay = 12; 50 | } elseif ($this->days <= 7) { 51 | $timestampPerDay = 2; 52 | } else { 53 | $timestampPerDay = 1; 54 | } 55 | 56 | $this->style->addFor($this, ['--timestampsPerDay' => $timestampPerDay * 2]); // *2 for .ticks 57 | 58 | $dateFormatter = new IntlDateFormatter( 59 | Locale::getDefault(), 60 | IntlDateFormatter::NONE, 61 | IntlDateFormatter::SHORT 62 | ); 63 | 64 | $timeIntervals = 24 / $timestampPerDay; 65 | 66 | $time = new DateTime(); 67 | $dayTimestamps = []; 68 | for ($i = 0; $i < $timestampPerDay; $i++) { 69 | $stamp = array_map( 70 | function ($part) { 71 | return new HtmlElement('span', null, new Text($part)); 72 | }, 73 | // am-pm is separated by non-breaking whitespace 74 | preg_split('/\s/u', $dateFormatter->format($time->setTime($i * $timeIntervals, 0))) 75 | ); 76 | 77 | $dayTimestamps[] = new HtmlElement('span', new Attributes(['class' => 'timestamp']), ...$stamp); 78 | $dayTimestamps[] = new HtmlElement('span', new Attributes(['class' => 'ticks'])); 79 | } 80 | 81 | $allTimestamps = array_merge(...array_fill(0, $this->days, $dayTimestamps)); 82 | // clone is required because $allTimestamps contains references of same object 83 | $allTimestamps[] = (clone $allTimestamps[0])->addAttributes(['class' => 'midnight']); // extra stamp of 12AM 84 | 85 | $this->addHtml(...$allTimestamps); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /library/Notifications/View/IncidentContactRenderer.php: -------------------------------------------------------------------------------- 1 | */ 21 | class IncidentContactRenderer implements ItemRenderer 22 | { 23 | use Translation; 24 | 25 | /** @var bool Whether the rendered item should not include a link to the contact */ 26 | private bool $disableContactLink = false; 27 | 28 | /** 29 | * Set whether the rendered item should not include a link to the contact 30 | * 31 | * @param bool $disableLink 32 | * 33 | * @return $this 34 | */ 35 | public function disableContactLink(bool $disableLink): static 36 | { 37 | $this->disableContactLink = $disableLink; 38 | 39 | return $this; 40 | } 41 | 42 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 43 | { 44 | $attributes->get('class')->addValue('incident-contact'); 45 | } 46 | 47 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 48 | { 49 | $visual->addHtml(new Icon($item->role === 'manager' ? Icons::USER_MANAGER : Icons::USER)); 50 | } 51 | 52 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 53 | { 54 | if (! $this->disableContactLink) { 55 | $title->addHtml(new Link($item->full_name, Links::contact($item->id), ['class' => 'subject'])); 56 | } else { 57 | $title->addHtml(new HtmlElement( 58 | 'span', 59 | Attributes::create(['class' => 'subject']), 60 | Text::create($item->full_name) 61 | )); 62 | } 63 | 64 | if ($item->role === 'manager') { 65 | $title->addHtml(new Text($this->translate('manages this incident'))); 66 | } 67 | } 68 | 69 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 70 | { 71 | } 72 | 73 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 74 | { 75 | } 76 | 77 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 78 | { 79 | } 80 | 81 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 82 | { 83 | return false; // no custom sections 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /application/forms/EventRuleConfigElements/EscalationConditions.php: -------------------------------------------------------------------------------- 1 | 'escalation-conditions']; 26 | 27 | protected function createAddButton(): SubmitButtonElement 28 | { 29 | /** @var SubmitButtonElement $button */ 30 | $button = $this->createElement('submitButton', 'add-button', [ 31 | 'title' => $this->translate('Add Condition'), 32 | 'label' => new Icon('plus'), 33 | 'class' => ['add-button', 'animated'] 34 | ]); 35 | 36 | $button->addWrapper(new HtmlElement('div', Attributes::create(['class' => 'add-button-wrapper']))); 37 | 38 | return $button; 39 | } 40 | 41 | protected function createDynamicElement(int $no, ?SubmitButtonElement $removeButton): FormElement 42 | { 43 | $condition = new EscalationCondition($no); 44 | if ($removeButton !== null) { 45 | $condition->setRemoveButton($removeButton); 46 | } 47 | 48 | return $condition; 49 | } 50 | 51 | /** 52 | * Prepare the conditions for display 53 | * 54 | * @param string $query The query string 55 | * 56 | * @return array 57 | */ 58 | public static function prepare(string $query): array 59 | { 60 | $filters = QueryString::parse($query); 61 | if ($filters instanceof Condition) { 62 | $filters = [$filters]; 63 | } 64 | 65 | $conditions = []; 66 | foreach ($filters as $condition) { 67 | $conditions[] = EscalationCondition::prepare($condition); 68 | } 69 | 70 | return $conditions; 71 | } 72 | 73 | /** 74 | * Get the conditions to store 75 | * 76 | * @return ?string 77 | */ 78 | public function getConditions(): ?string 79 | { 80 | $filters = Filter::all(); 81 | foreach ($this->ensureAssembled()->getElements() as $element) { 82 | if ($element instanceof EscalationCondition) { 83 | $filters->add($element->getCondition()); 84 | } 85 | } 86 | 87 | if ($filters->isEmpty()) { 88 | return null; 89 | } 90 | 91 | return (new FilterRenderer($filters))->render(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /library/Notifications/Common/LoadMore.php: -------------------------------------------------------------------------------- 1 | pageSize = $size; 34 | 35 | return $this; 36 | } 37 | 38 | /** 39 | * Set the page number 40 | * 41 | * @param int $number 42 | * 43 | * @return $this 44 | */ 45 | public function setPageNumber(int $number): self 46 | { 47 | $this->pageNumber = $number; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Set the url to fetch more items 54 | * 55 | * @param Url $url 56 | * 57 | * @return $this 58 | */ 59 | public function setLoadMoreUrl(Url $url): self 60 | { 61 | $this->loadMoreUrl = $url; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * Iterate over the given data 68 | * 69 | * Add the page separator and the "LoadMore" button at the desired position 70 | * 71 | * @param ResultSet $result 72 | * 73 | * @return Generator 74 | */ 75 | protected function getIterator(ResultSet $result): Generator 76 | { 77 | $count = 0; 78 | $pageNumber = $this->pageNumber ?: 1; 79 | 80 | if ($pageNumber > 1) { 81 | $this->add(new PageSeparatorItem($pageNumber)); 82 | } 83 | 84 | foreach ($result as $data) { 85 | $count++; 86 | if ($count % $this->pageSize === 0) { 87 | $pageNumber++; 88 | } elseif ($count > $this->pageSize && $count % $this->pageSize === 1) { 89 | $this->add(new PageSeparatorItem($pageNumber)); 90 | } 91 | 92 | yield $data; 93 | } 94 | 95 | if ($count > 0 && $this->loadMoreUrl !== null) { 96 | $showMore = (new ShowMore( 97 | $result, 98 | $this->loadMoreUrl->setParam('page', $pageNumber) 99 | ->setAnchor('page-' . ($pageNumber)) 100 | )) 101 | ->setLabel(t('Load More')) 102 | ->setAttribute('data-no-icinga-ajax', true); 103 | 104 | $this->add($showMore->setTag('li')->addAttributes(['class' => 'list-item'])); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /library/Notifications/Api/Middleware/LegacyRequestConversionMiddleware.php: -------------------------------------------------------------------------------- 1 | legacyRequest = $legacyRequest; 28 | } 29 | 30 | /** 31 | * @throws HttpBadRequestException 32 | */ 33 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 34 | { 35 | if ( 36 | ! $this->legacyRequest->isApiRequest() 37 | && strtolower($this->legacyRequest->getParam('endpoint')) !== (new OpenApi())->getEndpoint() 38 | ) { 39 | throw new HttpBadRequestException('No API request'); 40 | } 41 | 42 | $httpMethod = $this->legacyRequest->getMethod(); 43 | $serverRequest = (new ServerRequest( 44 | $httpMethod, 45 | $this->legacyRequest->getRequestUri(), 46 | serverParams: $this->legacyRequest->getServer() 47 | )) 48 | ->withAttribute('route_params', $this->legacyRequest->getParams()); 49 | 50 | try { 51 | if ($contentType = $this->legacyRequest->getHeader('Content-Type')) { 52 | $serverRequest = $serverRequest->withHeader('Content-Type', $contentType); 53 | } 54 | 55 | $requestBody = $this->legacyRequest->getPost(); 56 | } catch (JsonDecodeException) { 57 | throw new HttpBadRequestException('Invalid request body: given content is not a valid JSON'); 58 | } catch (\Zend_Controller_Request_Exception) { 59 | throw new HttpBadRequestException('Invalid request header: Content-Type must be application/json'); 60 | } 61 | 62 | if ($httpMethod === 'POST' || $httpMethod === 'PUT') { 63 | $serverRequest = $serverRequest->withParsedBody($requestBody); 64 | } else { 65 | if (! empty($requestBody)) { 66 | throw new HttpBadRequestException( 67 | 'Invalid request body: body is only allowed for POST and PUT requests' 68 | ); 69 | } 70 | } 71 | 72 | return $handler->handle($serverRequest); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /library/Notifications/Api/OpenApiDescriptionElement/Response/Example/ResponseExample.php: -------------------------------------------------------------------------------- 1 | 'Identifier mismatch'], 14 | )] 15 | #[OA\Examples( 16 | example: 'IdentifierNotFound', 17 | summary: 'Identifier not found', 18 | value: ['message' => 'Identifier not found'] 19 | )] 20 | #[OA\Examples( 21 | example: 'IdentifierPayloadIdMissmatch', 22 | summary: 'Identifier and payload Id missmatch', 23 | value: ['message' => 'Identifier mismatch: the Payload id must be different from the URL identifier'], 24 | )] 25 | #[OA\Examples( 26 | example: 'InvalidContentType', 27 | summary: 'Invalid content type', 28 | value: ['message' => 'Invalid request header: Content-Type must be application/json'], 29 | )] 30 | #[OA\Examples( 31 | example: 'InvalidFilterParameter', 32 | summary: 'Invalid filter parameter', 33 | value: ['message' => 'Invalid request parameter: Filter column x is not allowed'] 34 | )] 35 | #[OA\Examples( 36 | example: 'InvalidIdentifier', 37 | summary: 'Identifier is not valid', 38 | value: ['message' => 'The given identifier is not a valid UUID'] 39 | )] 40 | #[OA\Examples( 41 | example: 'InvalidRequestBodyFieldFormat', 42 | summary: 'Invalid request body field format', 43 | value: ['message' => 'Invalid request body: expects x to be of type y'], 44 | )] 45 | #[OA\Examples( 46 | example: 'InvalidRequestBodyFormat', 47 | summary: 'Invalid request body format', 48 | value: ['message' => 'Invalid request body: given content is not a valid JSON'], 49 | )] 50 | #[OA\Examples( 51 | example: 'InvalidRequestBodyId', 52 | summary: 'Invalid request body id', 53 | value: ['message' => 'Invalid request body: given id is not a valid UUID'], 54 | )] 55 | #[OA\Examples( 56 | example: 'MissingRequiredRequestBodyField', 57 | summary: 'Missing required request body field', 58 | value: ['message' => 'Invalid request body: the field x must be present'], 59 | )] 60 | #[OA\Examples( 61 | example: 'NoIdentifierWithFilter', 62 | summary: 'No identifier with filter', 63 | value: [ 64 | 'message' => 65 | "Invalid request: GET with identifier and query parameters, it's not allowed to use both together.", 66 | ], 67 | )] 68 | #[OA\Examples( 69 | example: 'UnexpectedQueryParameter', 70 | summary: 'Unexpected query parameter', 71 | value: ['message' => 'Unexpected query parameter: Filter is only allowed for GET requests'] 72 | )] 73 | class ResponseExample extends Examples 74 | { 75 | public function __construct(string $name) 76 | { 77 | parent::__construct(example: $name, ref: '#/components/examples/' . $name); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Icinga Notifications Web 2 | 3 | [![PHP Support](https://img.shields.io/badge/php-%3E%3D%208.2-777BB4?logo=PHP)](https://php.net/) 4 | ![Build Status](https://github.com/Icinga/icinga-notifications-web/actions/workflows/php.yml/badge.svg?branch=main) 5 | [![Github Tag](https://img.shields.io/github/tag/Icinga/icinga-notifications-web.svg)](https://github.com/Icinga/icinga-notifications-web/releases/latest) 6 | 7 | Icinga Notifications is a set of components that processes received events from various sources, manages incidents and 8 | forwards notifications to predefined contacts, consisting of: 9 | 10 | * [Icinga Notifications](https://github.com/Icinga/icinga-notifications), which receives events and sends notifications. 11 | * Icinga Notifications Web, which provides graphical configuration. 12 | 13 | Icinga 2 itself and miscellaneous other sources propagate state updates and other events to [Icinga Notifications](https://github.com/Icinga/icinga-notifications). 14 | 15 | ## Big Picture 16 | 17 | ![Icinga Notifications Architecture](doc/res/notifications-architecture.png) 18 | 19 | Because Icinga Notifications consists of several components, 20 | this section tries to help understand how these components relate. 21 | 22 | First, the Icinga Notifications configuration resides in a SQL database. 23 | It can be conveniently tweaked via Icinga Notifications Web directly from a web browser. 24 | The Icinga Notifications daemon uses this database to read the current configuration. 25 | 26 | As in any Icinga setup, all host and service checks are defined in Icinga 2. 27 | By querying the Icinga 2 API, the Icinga Notifications daemon retrieves state changes, acknowledgements and other events. 28 | These events are stored in the database and are available for further inspection in Icinga Notifications Web. 29 | Next to Icinga 2, other notification sources can be configured. 30 | 31 | Depending on its configuration, the daemon will take action on these events. 32 | This optionally includes escalations that are sent through a channel plugin. 33 | Each of those channel plugins implements a domain-specific transport, e.g., the `email` channel sends emails via SMTP. 34 | When configured, Icinga Notifications will use channel plugins to notify end users or talk to other APIs. 35 | 36 | ## Available Channels 37 | 38 | Icinga Notifications comes with multiple channels out of the box: 39 | 40 | * _email_: Email submission via SMTP 41 | * _rocketchat_: Rocket.Chat 42 | * _webhook_: Configurable HTTP/HTTPS queries for third-party backends 43 | 44 | Additional custom channels can be developed independently of Icinga Notifications, 45 | following the [channel specification](https://icinga.com/docs/icinga-notifications/latest/doc/10-Channels). 46 | 47 | ## Documentation 48 | 49 | Icinga Notifications Web documentation is available at [icinga.com/docs](https://icinga.com/docs/icinga-notifications-web/latest). 50 | 51 | ## License 52 | 53 | Icinga Notifications Web and its documentation are licensed under the terms of the [GNU General Public License Version 2](LICENSE). 54 | -------------------------------------------------------------------------------- /public/js/module.js: -------------------------------------------------------------------------------- 1 | /* Icinga Notifications Web | (c) 2024 Icinga GmbH | GPLv2 */ 2 | 3 | (function (Icinga) { 4 | 5 | "use strict"; 6 | 7 | class Notifications { 8 | /** 9 | * Constructor 10 | * 11 | * @param {Icinga.Module} module 12 | */ 13 | constructor(module) { 14 | this.icinga = module.icinga; 15 | module.on('click', '.show-more[data-no-icinga-ajax] a', this.onLoadMoreClick); 16 | } 17 | 18 | /** 19 | * Load more results 20 | * 21 | * @param {PointerEvent} event 22 | * 23 | * @returns {boolean} 24 | * @todo This is partly copied from Icinga DB Web. The full implementation, 25 | * once moved to ipl-web, should be used instead. 26 | */ 27 | onLoadMoreClick(event) { 28 | event.stopPropagation(); 29 | event.preventDefault(); 30 | 31 | this.loadMore(event.target); 32 | 33 | return false; 34 | } 35 | 36 | /** 37 | * Load additional results and append them to the current list 38 | * 39 | * @param {HTMLAnchorElement} anchor 40 | * 41 | * @returns {void} 42 | */ 43 | loadMore(anchor) { 44 | let showMore = anchor.parentElement; 45 | let progressTimer = this.icinga.timer.register(() => { 46 | let label = anchor.innerText; 47 | 48 | let dots = label.substring(label.length - 3); 49 | if (dots.slice(0, 1) !== '.') { 50 | dots = '. '; 51 | } else { 52 | label = label.slice(0, -3); 53 | if (dots === '...') { 54 | dots = '. '; 55 | } else if (dots === '.. ') { 56 | dots = '...'; 57 | } else if (dots === '. ') { 58 | dots = '.. '; 59 | } 60 | } 61 | 62 | anchor.innerText = label + dots; 63 | }, null, 250); 64 | 65 | let url = anchor.getAttribute('href'); 66 | let req = this.icinga.loader.loadUrl( 67 | // Add showCompact, we don't want controls in paged results 68 | this.icinga.utils.addUrlFlag(url, 'showCompact'), 69 | $(showMore.parentElement), 70 | undefined, 71 | undefined, 72 | 'append', 73 | false, 74 | progressTimer 75 | ); 76 | req.addToHistory = false; 77 | req.done(() => { 78 | showMore.remove(); 79 | 80 | // Set data-icinga-url to make it available for Icinga.History.getCurrentState() 81 | req.$target.closest('.container').data('icingaUrl', url); 82 | 83 | this.icinga.history.replaceCurrentState(); 84 | }); 85 | } 86 | } 87 | 88 | Icinga.availableModules.notifications = Notifications; 89 | 90 | })(Icinga); 91 | -------------------------------------------------------------------------------- /library/Notifications/Model/RuleEscalation.php: -------------------------------------------------------------------------------- 1 | t('Rule ID'), 60 | 'position' => t('Position'), 61 | 'condition' => t('Condition'), 62 | 'name' => t('Name'), 63 | 'fallback_for' => t('Fallback For'), 64 | 'changed_at' => t('Changed At') 65 | ]; 66 | } 67 | 68 | public function getSearchColumns(): array 69 | { 70 | return ['name']; 71 | } 72 | 73 | public function getDefaultSort(): array 74 | { 75 | return ['position']; 76 | } 77 | 78 | 79 | public function createBehaviors(Behaviors $behaviors): void 80 | { 81 | $behaviors->add(new MillisecondTimestamp(['changed_at'])); 82 | $behaviors->add(new BoolCast(['deleted'])); 83 | } 84 | 85 | public function createRelations(Relations $relations): void 86 | { 87 | $relations->belongsTo('rule', Rule::class); 88 | 89 | $relations 90 | ->belongsToMany('incident', Incident::class) 91 | ->through('incident_rule_escalation_state'); 92 | 93 | $relations 94 | ->belongsToMany('contact', Contact::class) 95 | ->through('rule_escalation_recipient') 96 | ->setJoinType('LEFT'); 97 | 98 | $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class) 99 | ->setJoinType('LEFT'); 100 | $relations->hasMany('incident_history', IncidentHistory::class) 101 | ->setJoinType('LEFT'); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /library/Notifications/Api/Middleware/MiddlewarePipeline.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private SplQueue $pipeline; 28 | 29 | /** 30 | * @param MiddlewareInterface[] $middlewares 31 | */ 32 | public function __construct( 33 | array $middlewares, 34 | ) { 35 | $this->pipeline = new SplQueue(); 36 | foreach ($middlewares as $middleware) { 37 | $this->pipe($middleware); 38 | } 39 | } 40 | 41 | /** 42 | * Add middleware to the pipeline. 43 | * 44 | * @param MiddlewareInterface $middleware 45 | * 46 | * @return $this 47 | */ 48 | public function pipe(MiddlewareInterface $middleware): self 49 | { 50 | $this->pipeline->enqueue($middleware); 51 | 52 | return $this; 53 | } 54 | 55 | /** 56 | * Handle the request and process the middleware pipeline. 57 | * This method is used to process the entire pipeline with a real request. 58 | * The request is passed to the first middleware in the pipeline. 59 | * The response is returned from the last middleware in the pipeline. 60 | * If no middleware is left in the pipeline, a 404 Not Found response is returned. 61 | * 62 | * @param ServerRequestInterface $request 63 | * 64 | * @return ResponseInterface 65 | */ 66 | public function handle(ServerRequestInterface $request): ResponseInterface 67 | { 68 | $middleware = $this->pipeline->dequeue(); 69 | 70 | if ($middleware === null) { 71 | return new Response(404, ['Content-Type' => 'application/json'], 'Not Found'); 72 | } 73 | 74 | return $middleware->process($request, $this); 75 | } 76 | 77 | /** 78 | * Execute the middleware pipeline. 79 | * This method is used to process the entire pipeline with a fake request. 80 | * 81 | * @param ServerRequestInterface|null $request 82 | * 83 | * @return ResponseInterface 84 | */ 85 | public function execute(ServerRequestInterface $request = null): ResponseInterface 86 | { 87 | if ($request === null) { 88 | $request = new ServerRequest('GET', '/'); // initial dummy request 89 | } 90 | 91 | return $this->handle($request); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /public/css/schedule.less: -------------------------------------------------------------------------------- 1 | /* Layout */ 2 | 3 | .schedule-detail-controls { 4 | .box-shadow(0, 0, 0, 1px, @gray-lighter); 5 | z-index: 4; // The content may clip, this ensures the separator and dropdown is always visible 6 | 7 | > * { 8 | margin-bottom: .5em; 9 | } 10 | 11 | strong { 12 | font-size: 1.333em; 13 | } 14 | 15 | .button-link i.icon { 16 | display: inline; 17 | 18 | &::before { 19 | display: inline; 20 | margin-right: 0; 21 | } 22 | } 23 | 24 | .schedule-controls { 25 | float: right; 26 | min-height: 2em; 27 | 28 | display: inline-flex; 29 | align-items: baseline; 30 | flex-wrap: wrap; 31 | gap: .5em; 32 | 33 | .suggestion-element-group .suggestion-element-icon, 34 | .suggestion-element-group .suggestion-element { 35 | height: 2em; 36 | line-height: 1; 37 | } 38 | 39 | .view-mode-switcher { 40 | margin-bottom: 0; 41 | 42 | label { 43 | min-width: 6em; 44 | text-align: center; 45 | } 46 | } 47 | } 48 | } 49 | 50 | .schedule-detail { 51 | display: flex; 52 | flex-direction: column; 53 | height: 100%; 54 | 55 | .from-scratch-hint { 56 | display: flex; 57 | align-items: center; 58 | font-size: 14/12em; 59 | 60 | i.icon { 61 | float: left; 62 | font-size: 1.5em; 63 | } 64 | 65 | div { 66 | margin: 0 auto; 67 | } 68 | } 69 | } 70 | 71 | .schedule-container { 72 | flex: 1 1 auto; 73 | display: flex; 74 | overflow: auto; 75 | 76 | .calendar { 77 | flex: 1 1 auto; 78 | display: flex; 79 | flex-direction: column; 80 | 81 | .time-grid { 82 | flex: 1 1 auto; 83 | height: 0; 84 | } 85 | } 86 | 87 | .timeline { 88 | flex: 1 1 auto; 89 | } 90 | } 91 | 92 | .timezone-warning { 93 | display: flex; 94 | align-items: center; 95 | justify-content: center; 96 | column-gap: 1em; 97 | 98 | width: fit-content; 99 | margin: 0 auto 1em auto; 100 | 101 | i.icon::before { 102 | margin-right: 0; 103 | } 104 | 105 | p { 106 | margin: 0; 107 | } 108 | } 109 | 110 | /* Design */ 111 | 112 | .schedule-detail { 113 | .entry.highlighted { 114 | outline: 2px solid var(--entry-border-color); 115 | outline-offset: 1px; 116 | } 117 | 118 | .sidebar .row-title.highlighted, 119 | .step.highlighted { 120 | background-color: @gray-lighter; 121 | border-color: @gray-light; 122 | } 123 | 124 | .sidebar .row-title.highlighted { 125 | margin-top: -1px; // cover the border-top area 126 | padding-top: 1px; 127 | } 128 | } 129 | 130 | .schedule-detail .from-scratch-hint { 131 | .rounded-corners(); 132 | border: 1px solid @gray-light; 133 | padding: .5em; 134 | color: @text-color-light; 135 | } 136 | 137 | .timezone-warning { 138 | padding: .5em 1em; 139 | border: 1px solid @state-warning; 140 | border-radius: .25em; 141 | 142 | i.icon { 143 | color: @state-warning; 144 | font-size: 1.5em; 145 | } 146 | } 147 | 148 | .schedule-controls:has(.suggestion-element:invalid) { 149 | .suggestion-element-icon { 150 | color: @state-critical; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /library/Notifications/Model/Daemon/Event.php: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 36 | $this->contact = $contact; 37 | $this->data = $data; 38 | $this->reconnectInterval = 3000; 39 | $this->lastEventId = $lastEventId; 40 | 41 | $this->createdAt = new DateTime(); 42 | } 43 | 44 | public function getIdentifier(): string 45 | { 46 | return $this->identifier; 47 | } 48 | 49 | public function getContact(): int 50 | { 51 | return $this->contact; 52 | } 53 | 54 | public function getData(): stdClass 55 | { 56 | return $this->data; 57 | } 58 | 59 | public function getCreatedAt(): string 60 | { 61 | return $this->createdAt->format(DateTimeInterface::RFC3339_EXTENDED); 62 | } 63 | 64 | public function getReconnectInterval(): int 65 | { 66 | return $this->reconnectInterval; 67 | } 68 | 69 | public function getLastEventId(): int 70 | { 71 | return $this->lastEventId; 72 | } 73 | 74 | public function setReconnectInterval(int $reconnectInterval): void 75 | { 76 | $this->reconnectInterval = $reconnectInterval; 77 | } 78 | 79 | /** 80 | * Compile event message according to 81 | * {@link https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream SSE Spec} 82 | * 83 | * @return string 84 | * @throws JsonEncodeException 85 | */ 86 | protected function compileMessage(): string 87 | { 88 | $payload = (object) [ 89 | 'time' => $this->getCreatedAt(), 90 | 'payload' => $this->getData() 91 | ]; 92 | 93 | $message = 'event: ' . $this->identifier . PHP_EOL; 94 | $message .= 'data: ' . Json::encode($payload) . PHP_EOL; 95 | //$message .= 'id: ' . ($this->getLastEventId() + 1) . PHP_EOL; 96 | $message .= 'retry: ' . $this->reconnectInterval . PHP_EOL; 97 | 98 | // ending newline 99 | $message .= PHP_EOL; 100 | 101 | return $message; 102 | } 103 | 104 | public function __toString(): string 105 | { 106 | // compile event to the appropriate representation for event streams 107 | return $this->compileMessage(); 108 | } 109 | } 110 | --------------------------------------------------------------------------------