├── AUTHORS ├── LICENSE ├── README.md ├── application ├── clicommands │ └── DaemonCommand.php ├── controllers │ ├── ChannelController.php │ ├── ChannelsController.php │ ├── ConfigController.php │ ├── ContactController.php │ ├── ContactGroupController.php │ ├── ContactGroupsController.php │ ├── ContactsController.php │ ├── DaemonController.php │ ├── EventController.php │ ├── EventRuleController.php │ ├── EventRulesController.php │ ├── EventsController.php │ ├── IncidentController.php │ ├── IncidentsController.php │ ├── ScheduleController.php │ ├── SchedulesController.php │ ├── SourceController.php │ └── SourcesController.php └── forms │ ├── AddEscalationForm.php │ ├── AddFilterForm.php │ ├── BaseEscalationForm.php │ ├── ChannelForm.php │ ├── ContactGroupForm.php │ ├── DatabaseConfigForm.php │ ├── EscalationConditionForm.php │ ├── EscalationRecipientForm.php │ ├── EventRuleForm.php │ ├── MoveRotationForm.php │ ├── RemoveEscalationForm.php │ ├── RotationConfigForm.php │ ├── SaveEventRuleForm.php │ ├── ScheduleForm.php │ └── SourceForm.php ├── config └── systemd │ └── icinga-desktop-notifications.service ├── configuration.php ├── doc ├── 01-About.md ├── 02-Installation.md ├── 02-Installation.md.d │ └── From-Source.md ├── 03-Configuration.md ├── 06-Desktop-Notifications.md └── res │ ├── notifications-architecture.png │ └── notifications-preview.png ├── library └── Notifications │ ├── Common │ ├── Auth.php │ ├── Database.php │ ├── Icons.php │ ├── Links.php │ ├── LoadMore.php │ └── NoSubjectLink.php │ ├── Daemon │ ├── Daemon.php │ ├── Sender.php │ └── Server.php │ ├── Hook │ └── ObjectsRendererHook.php │ ├── Model │ ├── AvailableChannelType.php │ ├── Behavior │ │ ├── IdTagAggregator.php │ │ └── ObjectTags.php │ ├── BrowserSession.php │ ├── Channel.php │ ├── Contact.php │ ├── ContactAddress.php │ ├── Contactgroup.php │ ├── ContactgroupMember.php │ ├── Daemon │ │ ├── Connection.php │ │ ├── Event.php │ │ ├── EventIdentifier.php │ │ └── User.php │ ├── Event.php │ ├── ExtraTag.php │ ├── Incident.php │ ├── IncidentContact.php │ ├── IncidentHistory.php │ ├── ObjectExtraTag.php │ ├── ObjectIdTag.php │ ├── Objects.php │ ├── Rotation.php │ ├── RotationMember.php │ ├── Rule.php │ ├── RuleEscalation.php │ ├── RuleEscalationRecipient.php │ ├── Schedule.php │ ├── Source.php │ ├── Tag.php │ ├── Timeperiod.php │ └── TimeperiodEntry.php │ ├── ProvidedHook │ ├── Notifications │ │ └── ObjectsRenderer.php │ └── SessionStorage.php │ ├── Util │ └── ObjectSuggestionsCursor.php │ ├── View │ ├── ChannelRenderer.php │ ├── ContactRenderer.php │ ├── ContactgroupRenderer.php │ ├── EventRenderer.php │ ├── EventRuleRenderer.php │ ├── IncidentContactRenderer.php │ ├── IncidentHistoryRenderer.php │ ├── IncidentRenderer.php │ ├── ScheduleRenderer.php │ └── SourceRenderer.php │ ├── Web │ ├── Control │ │ └── SearchBar │ │ │ ├── ExtraTagSuggestions.php │ │ │ └── ObjectSuggestions.php │ ├── FilterRenderer.php │ └── Form │ │ ├── ContactForm.php │ │ └── EventRuleDecorator.php │ └── Widget │ ├── Calendar.php │ ├── Calendar │ ├── Attendee.php │ ├── Controls.php │ ├── DayGrid.php │ ├── Entry.php │ ├── MonthGrid.php │ └── WeekGrid.php │ ├── Detail │ ├── EventDetail.php │ ├── IncidentDetail.php │ ├── IncidentQuickActions.php │ ├── ObjectHeader.php │ ├── ScheduleDetail.php │ └── ScheduleDetail │ │ └── Controls.php │ ├── Escalations.php │ ├── EventRuleConfig.php │ ├── EventSourceBadge.php │ ├── FlowLine.php │ ├── IconBall.php │ ├── ItemList │ ├── ContactGroupListItem.php │ ├── ContactListItem.php │ ├── LoadMoreObjectList.php │ ├── ObjectList.php │ └── PageSeparatorItem.php │ ├── MemberSuggestions.php │ ├── RecipientSuggestions.php │ ├── RightArrow.php │ ├── RuleEscalationRecipientBadge.php │ ├── ShowMore.php │ ├── TimeGrid │ ├── BaseGrid.php │ ├── DaysHeader.php │ ├── DynamicGrid.php │ ├── Entry.php │ ├── EntryProvider.php │ ├── ExtraEntryCount.php │ ├── GridStep.php │ ├── Timescale.php │ └── Util.php │ ├── Timeline.php │ └── Timeline │ ├── Entry.php │ ├── Member.php │ ├── MinimalGrid.php │ └── Rotation.php ├── module.info ├── phpstan-baseline-7x.neon ├── phpstan-baseline-8x.neon ├── phpstan-baseline-by-php-version.php ├── phpstan-baseline-standard.neon ├── phpstan.neon ├── public ├── css │ ├── calendar.less │ ├── checkbox-icon.less │ ├── common.less │ ├── detail │ │ ├── event-rule-detail.less │ │ └── incident-detail.less │ ├── event-source-badge.less │ ├── form.less │ ├── list │ │ ├── action-list.less │ │ └── schedule-list.less │ ├── load-more.less │ ├── mixins.less │ ├── quick-actions.less │ ├── schedule.less │ ├── timeline.less │ └── view-mode-switcher.less ├── img │ ├── icinga-notifications-critical.webp │ ├── icinga-notifications-ok.webp │ ├── icinga-notifications-unknown.webp │ ├── icinga-notifications-warning.webp │ └── pictogram │ │ ├── 24-7-dark.jpg │ │ ├── 24-7-light.jpg │ │ ├── multi-dark.jpg │ │ ├── multi-light.jpg │ │ ├── partial-dark.jpg │ │ └── partial-light.jpg └── js │ ├── doc │ ├── NOTIFICATIONS.md │ ├── notifications-arch.puml │ └── notifications-arch.svg │ ├── module.js │ ├── notifications-worker.js │ ├── notifications.js │ └── schedule.js ├── run.php └── test └── php ├── application └── forms │ └── SourceFormTest.php └── library └── Notifications └── Widget └── CalendarTest.php /AUTHORS: -------------------------------------------------------------------------------- 1 | Alexander A. Klimov 2 | Alvar Penning 3 | Florian Strohmaier 4 | Johannes Meyer 5 | Noé Costa 6 | Ravi Kumar Kempapura Srinivasa 7 | Sukhwinder Dhillon 8 | Yonas Habteab 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Icinga Notifications Web 2 | 3 | [![PHP Support](https://img.shields.io/badge/php-%3E%3D%207.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 | > [!WARNING] 8 | > This is an early beta version for you to try, but do not use this in production. There may still be severe bugs. 9 | > 10 | > At the moment, we don't provide any support for this module. 11 | 12 | Icinga Notifications is a set of components that processes received events from various sources, manages incidents and 13 | forwards notifications to predefined contacts, consisting of: 14 | 15 | * [Icinga Notifications](https://github.com/Icinga/icinga-notifications), which receives events and sends notifications. 16 | * Icinga Notifications Web, which provides graphical configuration. 17 | 18 | Icinga 2 itself and miscellaneous other sources propagate state updates and other events to [Icinga Notifications](https://github.com/Icinga/icinga-notifications). 19 | 20 | ## Big Picture 21 | 22 | ![Icinga Notifications Architecture](doc/res/notifications-architecture.png) 23 | 24 | Because Icinga Notifications consists of several components, 25 | this section tries to help understand how these components relate. 26 | 27 | First, the Icinga Notifications configuration resides in a SQL database. 28 | It can be conveniently tweaked via Icinga Notifications Web directly from a web browser. 29 | The Icinga Notifications daemon uses this database to read the current configuration. 30 | 31 | As in any Icinga setup, all host and service checks are defined in Icinga 2. 32 | By querying the Icinga 2 API, the Icinga Notifications daemon retrieves state changes, acknowledgements and other events. 33 | These events are stored in the database and are available for further inspection in Icinga Notifications Web. 34 | Next to Icinga 2, other notification sources can be configured. 35 | 36 | Depending on its configuration, the daemon will take action on these events. 37 | This optionally includes escalations that are sent through a channel plugin. 38 | Each of those channel plugins implements a domain-specific transport, e.g., the `email` channel sends emails via SMTP. 39 | When configured, Icinga Notifications will use channel plugins to notify end users or talk to other APIs. 40 | 41 | ## Available Channels 42 | 43 | Icinga Notifications comes with multiple channels out of the box: 44 | 45 | * _email_: Email submission via SMTP 46 | * _rocketchat_: Rocket.Chat 47 | * _webhook_: Configurable HTTP/HTTPS queries for third-party backends 48 | 49 | Additional custom channels can be developed independently of Icinga Notifications, 50 | following the [channel specification](https://icinga.com/docs/icinga-notifications/latest/doc/10-Channels). 51 | 52 | ## Documentation 53 | 54 | Icinga Notifications Web documentation is available at [icinga.com/docs](https://icinga.com/docs/icinga-notifications-web/latest). 55 | 56 | ## License 57 | 58 | Icinga Notifications Web and its documentation are licensed under the terms of the [GNU General Public License Version 2](LICENSE). 59 | -------------------------------------------------------------------------------- /application/clicommands/DaemonCommand.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /application/controllers/ContactController.php: -------------------------------------------------------------------------------- 1 | assertPermission('notifications/config/contacts'); 17 | } 18 | 19 | public function indexAction(): void 20 | { 21 | $contactId = $this->params->getRequired('id'); 22 | 23 | $form = (new ContactForm(Database::get())) 24 | ->loadContact($contactId) 25 | ->on(ContactForm::ON_SUCCESS, function (ContactForm $form) { 26 | $form->editContact(); 27 | Notification::success(sprintf( 28 | t('Contact "%s" has successfully been saved'), 29 | $form->getContactName() 30 | )); 31 | 32 | $this->redirectNow('__CLOSE__'); 33 | })->on(ContactForm::ON_REMOVE, function (ContactForm $form) { 34 | $form->removeContact(); 35 | Notification::success(sprintf( 36 | t('Deleted contact "%s" successfully'), 37 | $form->getContactName() 38 | )); 39 | 40 | $this->redirectNow('__CLOSE__'); 41 | })->handleRequest($this->getServerRequest()); 42 | 43 | $this->addTitleTab(sprintf(t('Contact: %s'), $form->getContactName())); 44 | 45 | $this->addContent($form); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /application/controllers/DaemonController.php: -------------------------------------------------------------------------------- 1 | getHelper('viewRenderer'); 25 | $viewRenderer->setNoRender(); 26 | 27 | /** @var Zend_Layout $layout */ 28 | $layout = $this->getHelper('layout'); 29 | $layout->disableLayout(); 30 | } 31 | 32 | public function scriptAction(): void 33 | { 34 | /** 35 | * we have to use `getRequest()->getParam` here instead of the usual `$this->param` as the required parameters 36 | * are not submitted by an HTTP request but injected manually {@see icinga-notifications-web/run.php} 37 | */ 38 | $fileName = $this->getRequest()->getParam('file', 'undefined'); 39 | $extension = $this->getRequest()->getParam('extension', 'undefined'); 40 | $mime = ''; 41 | 42 | switch ($extension) { 43 | case 'undefined': 44 | $this->httpNotFound(t("File extension is missing.")); 45 | 46 | // no return 47 | case '.js': 48 | $mime = 'application/javascript'; 49 | 50 | break; 51 | case '.js.map': 52 | $mime = 'application/json'; 53 | 54 | break; 55 | } 56 | 57 | $root = Icinga::app() 58 | ->getModuleManager() 59 | ->getModule('notifications') 60 | ->getBaseDir() . '/public/js'; 61 | 62 | $filePath = realpath($root . DIRECTORY_SEPARATOR . 'notifications-' . $fileName . $extension); 63 | if ($filePath === false || substr($filePath, 0, strlen($root)) !== $root) { 64 | if ($fileName === 'undefined') { 65 | $this->httpNotFound(t("No file name submitted")); 66 | } 67 | 68 | $this->httpNotFound(sprintf(t("notifications-%s%s does not exist"), $fileName, $extension)); 69 | } else { 70 | $fileStat = stat($filePath); 71 | 72 | if ($fileStat) { 73 | $eTag = sprintf( 74 | '%x-%x-%x', 75 | $fileStat['ino'], 76 | $fileStat['size'], 77 | (float) str_pad((string) ($fileStat['mtime']), 16, '0') 78 | ); 79 | 80 | $this->getResponse()->setHeader( 81 | 'Cache-Control', 82 | 'public, max-age=1814400, stale-while-revalidate=604800', 83 | true 84 | ); 85 | 86 | if ($this->getRequest()->getServer('HTTP_IF_NONE_MATCH') === $eTag) { 87 | $this->getResponse()->setHttpResponseCode(304); 88 | } else { 89 | $this->getResponse() 90 | ->setHeader('ETag', $eTag) 91 | ->setHeader('Content-Type', $mime, true) 92 | ->setHeader( 93 | 'Last-Modified', 94 | gmdate('D, d M Y H:i:s', $fileStat['mtime']) . ' GMT' 95 | ); 96 | $file = file_get_contents($filePath); 97 | if ($file) { 98 | $this->getResponse()->setBody($file); 99 | } 100 | } 101 | } else { 102 | $this->httpNotFound(sprintf(t("notifications-%s%s could not be read"), $fileName, $extension)); 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /application/controllers/SourceController.php: -------------------------------------------------------------------------------- 1 | assertPermission('config/modules'); 18 | } 19 | 20 | public function indexAction(): void 21 | { 22 | $sourceId = (int) $this->params->getRequired('id'); 23 | 24 | $form = (new SourceForm(Database::get())) 25 | ->loadSource($sourceId) 26 | ->on(SourceForm::ON_SUCCESS, function (SourceForm $form) { 27 | /** @var FormSubmitElement $pressedButton */ 28 | $pressedButton = $form->getPressedSubmitElement(); 29 | if ($pressedButton->getName() === 'delete') { 30 | $form->removeSource(); 31 | Notification::success(sprintf( 32 | $this->translate('Deleted source "%s" successfully'), 33 | $form->getSourceName() 34 | )); 35 | } else { 36 | $form->editSource(); 37 | Notification::success(sprintf( 38 | $this->translate('Updated source "%s" successfully'), 39 | $form->getSourceName() 40 | )); 41 | } 42 | 43 | $this->switchToSingleColumnLayout(); 44 | })->handleRequest($this->getServerRequest()); 45 | 46 | $this->addTitleTab(sprintf($this->translate('Source: %s'), $form->getSourceName())); 47 | $this->addContent($form); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /application/forms/AddEscalationForm.php: -------------------------------------------------------------------------------- 1 | ['add-escalation-form', 'icinga-form', 'icinga-controls'], 22 | 'name' => 'add-escalation-form' 23 | ]; 24 | 25 | 26 | protected function assemble() 27 | { 28 | $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); 29 | $this->addElement($this->createUidElement()); 30 | 31 | 32 | $this->addElement( 33 | 'submitButton', 34 | 'add', 35 | [ 36 | 'class' => ['add-button', 'control-button', 'spinner'], 37 | 'label' => new Icon('plus'), 38 | 'title' => $this->translate('Add a new escalation') 39 | ] 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /application/forms/AddFilterForm.php: -------------------------------------------------------------------------------- 1 | ['add-filter-form', 'icinga-form', 'icinga-controls'], 22 | 'name' => 'add-filter-form' 23 | ]; 24 | 25 | 26 | protected function assemble() 27 | { 28 | $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); 29 | $this->addElement($this->createUidElement()); 30 | 31 | 32 | $this->addElement( 33 | 'submitButton', 34 | 'add', 35 | [ 36 | 'class' => ['add-button', 'control-button', 'spinner'], 37 | 'label' => new Icon('plus'), 38 | 'title' => $this->translate('Add filter') 39 | ] 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /application/forms/BaseEscalationForm.php: -------------------------------------------------------------------------------- 1 | ['escalation-form', 'icinga-form', 'icinga-controls']]; 23 | 24 | /** @var int The count of existing conditions/recipients */ 25 | protected $count; 26 | 27 | /** @var bool Whether the `add` button is pressed */ 28 | protected $isAddPressed; 29 | 30 | /** @var ValidHtml[] */ 31 | protected $options; 32 | 33 | /** @var ?int The counter of removed option */ 34 | protected $removedOptionNumber; 35 | 36 | public function __construct(int $count) 37 | { 38 | $this->count = $count; 39 | } 40 | 41 | public function hasBeenSubmitted() 42 | { 43 | return false; 44 | } 45 | 46 | abstract protected function assembleElements(): void; 47 | 48 | protected function createAddButton(): FormElement 49 | { 50 | $addButton = $this->createElement( 51 | 'submitButton', 52 | 'add', 53 | [ 54 | 'class' => ['add-button', 'control-button', 'spinner'], 55 | 'label' => new Icon('plus'), 56 | 'title' => $this->translate('Add more'), 57 | 'formnovalidate' => true 58 | ] 59 | ); 60 | 61 | $this->registerElement($addButton); 62 | 63 | return $addButton; 64 | } 65 | 66 | protected function assemble() 67 | { 68 | $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); 69 | $this->addElement($this->createUidElement()); 70 | 71 | $addButton = $this->createAddButton(); 72 | 73 | $button = $this->getPressedSubmitElement(); 74 | if ($button && $button->getName() === 'add') { 75 | $this->isAddPressed = true; 76 | } 77 | 78 | if ($this->count || $this->isAddPressed) { 79 | $this->assembleElements(); 80 | } 81 | 82 | $this->add($addButton); 83 | } 84 | 85 | public function isAddButtonPressed(): ?bool 86 | { 87 | return $this->isAddPressed; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /application/forms/EventRuleForm.php: -------------------------------------------------------------------------------- 1 | addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); 20 | 21 | $this->addElement( 22 | 'text', 23 | 'name', 24 | [ 25 | 'label' => $this->translate('Title'), 26 | 'required' => true 27 | ] 28 | ); 29 | 30 | $this->addElement('submit', 'btn_submit', [ 31 | 'label' => $this->translate('Save') 32 | ]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /application/forms/RemoveEscalationForm.php: -------------------------------------------------------------------------------- 1 | ['remove-escalation-form', 'icinga-form', 'icinga-controls'], 22 | ]; 23 | 24 | /** @var bool */ 25 | private $disableRemoveButtton; 26 | 27 | protected function assemble() 28 | { 29 | $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); 30 | $this->addElement($this->createUidElement()); 31 | 32 | $this->addElement( 33 | 'submitButton', 34 | 'remove', 35 | [ 36 | 'class' => ['remove-button', 'control-button', 'spinner'], 37 | 'label' => new Icon('minus') 38 | ] 39 | ); 40 | 41 | $this->getElement('remove') 42 | ->getAttributes() 43 | ->registerAttributeCallback('disabled', function () { 44 | return $this->disableRemoveButtton; 45 | }) 46 | ->registerAttributeCallback('title', function () { 47 | if ($this->disableRemoveButtton) { 48 | return $this->translate( 49 | 'There exist active incidents for this escalation and hence cannot be removed' 50 | ); 51 | } 52 | 53 | return $this->translate('Remove escalation'); 54 | }); 55 | } 56 | 57 | /** 58 | * Method to set disabled state of remove button 59 | * 60 | * @param bool $state 61 | * 62 | * @return $this 63 | */ 64 | public function setRemoveButtonDisabled(bool $state = false) 65 | { 66 | $this->disableRemoveButtton = $state; 67 | 68 | return $this; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /configuration.php: -------------------------------------------------------------------------------- 1 | menuSection( 10 | N_('Notifications'), 11 | [ 12 | 'icon' => 'bell-alt', 13 | 'priority' => 52 14 | ] 15 | ); 16 | 17 | $section->add( 18 | N_('Configuration'), 19 | [ 20 | 'icon' => 'wrench', 21 | 'description' => $this->translate('Configuration'), 22 | 'url' => 'notifications/schedules' 23 | ] 24 | ); 25 | 26 | $section->add( 27 | N_('Events'), 28 | [ 29 | 'icon' => 'history', 30 | 'description' => $this->translate('Events'), 31 | 'url' => 'notifications/events' 32 | ] 33 | ); 34 | 35 | $this->providePermission( 36 | 'notifications/config/event-rules', 37 | $this->translate('Allow to configure event rules') 38 | ); 39 | 40 | $this->providePermission( 41 | 'notifications/config/contact-groups', 42 | $this->translate('Allow to configure contact groups') 43 | ); 44 | 45 | $this->provideRestriction( 46 | 'notifications/filter/objects', 47 | $this->translate('Restrict access to the objects that match the filter') 48 | ); 49 | 50 | $this->provideConfigTab( 51 | 'database', 52 | [ 53 | 'title' => $this->translate('Database'), 54 | 'label' => $this->translate('Database'), 55 | 'url' => 'config/database' 56 | ] 57 | ); 58 | 59 | $this->provideConfigTab( 60 | 'channels', 61 | [ 62 | 'title' => $this->translate('Channels'), 63 | 'label' => $this->translate('Channels'), 64 | 'url' => 'channels' 65 | ] 66 | ); 67 | 68 | $this->provideConfigTab( 69 | 'sources', 70 | [ 71 | 'title' => $this->translate('Sources'), 72 | 'label' => $this->translate('Sources'), 73 | 'url' => 'sources' 74 | ] 75 | ); 76 | 77 | $section->add( 78 | N_('Incidents'), 79 | [ 80 | 'icon' => 'th-list', 81 | 'description' => $this->translate('Incidents'), 82 | 'url' => 'notifications/incidents' 83 | ] 84 | ); 85 | 86 | $this->provideJsFile('schedule.js'); 87 | 88 | $cssDirectory = $this->getCssDir(); 89 | $cssFiles = new RecursiveIteratorIterator(new RecursiveDirectoryIterator( 90 | $cssDirectory, 91 | RecursiveDirectoryIterator::CURRENT_AS_PATHNAME | RecursiveDirectoryIterator::SKIP_DOTS 92 | )); 93 | 94 | foreach ($cssFiles as $path) { 95 | $this->provideCssFile(ltrim(substr($path, strlen($cssDirectory)), DIRECTORY_SEPARATOR)); 96 | } 97 | 98 | $this->provideJsFile('notifications.js'); 99 | -------------------------------------------------------------------------------- /doc/01-About.md: -------------------------------------------------------------------------------- 1 | # Icinga Notifications Web 2 | 3 | !!! warning 4 | 5 | This is an early beta version for you to try, but do not use this in production. There may still be severe bugs. 6 | 7 | At the moment, we don't provide any support for this module. 8 | 9 | Icinga Notifications is a set of components that processes received events from miscellaneous sources, manages 10 | incidents and forwards notifications to predefined contacts, consisting of: 11 | 12 | * [Icinga Notifications](https://github.com/Icinga/icinga-notifications), which receives events and sends notifications. 13 | * Icinga Notifications Web, which provides graphical configuration. 14 | 15 | Icinga 2 itself and other sources propagate state updates and other events to [Icinga Notifications](https://github.com/Icinga/icinga-notifications). 16 | 17 | ## Big Picture 18 | 19 | ![Icinga Notifications Architecture](res/notifications-architecture.png) 20 | 21 | Because Icinga Notifications consists of several components, 22 | this section tries to help understand how these components relate. 23 | 24 | First, the Icinga Notifications configuration resides in a SQL database. 25 | It can be conveniently tweaked via Icinga Notifications Web directly from a web browser. 26 | The Icinga Notifications daemon uses this database to read the current configuration. 27 | 28 | As in any Icinga setup, all host and service checks are defined in Icinga 2. 29 | By querying the Icinga 2 API, the Icinga Notifications daemon retrieves state changes, acknowledgements and other events. 30 | These events are stored in the database and are available for further inspection in Icinga Notifications Web. 31 | Next to Icinga 2, other notification sources can be configured. 32 | 33 | Depending on its configuration, the daemon will take action on these events. 34 | This optionally includes escalations that are sent through a channel plugin. 35 | Each of those channel plugins implements a domain-specific transport, e.g., the `email` channel sends emails via SMTP. 36 | When configured, Icinga Notifications will use channel plugins to notify end users or talk to other APIs. 37 | 38 | ## Available Channels 39 | 40 | Icinga Notifications comes with multiple channels out of the box: 41 | 42 | * _email_: Email submission via SMTP 43 | * _rocketchat_: Rocket.Chat 44 | * _webhook_: Configurable HTTP/HTTPS queries for third-party backends 45 | 46 | Additional custom channels can be developed independently of Icinga Notifications, 47 | following the [channel specification](https://icinga.com/docs/icinga-notifications/latest/doc/10-Channels). 48 | 49 | ## Installation 50 | 51 | To install Icinga Notifications Web see [Installation](02-Installation.md). 52 | 53 | ## License 54 | 55 | 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). 56 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 (≥7.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.9) 16 | - [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) (≥0.15.0) 17 | - [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (≥0.12.0) 18 | 19 | 20 | -------------------------------------------------------------------------------- /doc/03-Configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ![Icinga Notifications Web Preview](res/notifications-preview.png) 4 | 5 | If Icinga Web has been installed but not yet set up, please visit Icinga Web and follow the web-based setup wizard. 6 | For Icinga Web setups already running, log in to Icinga Web with a privileged user and follow the steps below to 7 | configure Icinga Notifications Web: 8 | 9 | 10 | 11 | ## Module Activation 12 | 13 | If you just installed the module, do not forget to activate it on your Icinga Web instance(s) by using your 14 | preferred way: 15 | 16 | - Use Icinga Web's command-line interface on the webserver(s) and execute `icingacli module enable notifications`. 17 | - Visit Icinga Web, log in as a privileged user and activate the module under `Configuration → 18 | Modules → notifications` by switching the state from `disabled` to `enabled`. 19 | 20 | 21 | 22 | ## Database Configuration 23 | 24 | Connection configuration for the database, which both, 25 | [Icinga Notifications](https://github.com/Icinga/icinga-notifications) and [Icinga Notifications Web](https://github.com/Icinga/icinga-notifications-web), use. 26 | 27 | !!! tip 28 | 29 | If not already done, initialize your database by following the [instructions](https://icinga.com/docs/icinga-notifications/latest/doc/02-Installation#setting-up-the-database). 30 | 31 | 1. Create a new resource for the Icinga Notifications database via the `Configuration → Application → Resources` menu. 32 | 2. Configure the resource you just created as the database connection for the Icinga Notifications Web module using the 33 | `Configuration → Modules → notifications → Database` menu. 34 | 35 | ## Channels Configuration 36 | 37 | As the Icinga Notifications daemon notifies contacts in case of events and incidents, you need to configure appropriate 38 | communication channels. 39 | 40 | The currently supported channels can be found [here](01-About.md#available-channels). 41 | 42 | They can be configured through `Configuration → Modules → notifications → Channels` and the credentials to be supplied 43 | might differ depending on the channel type. 44 | 45 | You need to configure at least one valid communication channel to be able to supply your contacts with notifications. 46 | 47 | ## Sources Configuration 48 | 49 | The notifications module operates on data fed by miscellaneous sources and is therefore not restricted to Icinga 2 only. 50 | Consult the source specific documentation on how to integrate such. 51 | 52 | You need to provide at least one valid source for this module to function properly. 53 | 54 | ### Adding an Icinga 2 source 55 | 56 | !!! tip 57 | 58 | If there is no API user with the required permissions yet, read through [Icinga's API documentation](https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#authentication). 59 | 60 | The API user needs the following [permissions](https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#overview): 61 | 62 | - `events/*` 63 | - `objects/query/*` 64 | - `status/query` 65 | 66 | If you want the notifications module to process Icinga 2 events, you will need to add it as a source: 67 | 68 | 1. Navigate to `Configuration → Module → notifications → Sources` and add a new source. 69 | 2. Choose type `Icinga` and provide the Icinga 2 API credentials. 70 | 3. (Optional) Disable `Verify API Certificate` if you want 71 | [Icinga Notifications](https://icinga.com/docs/icinga-notifications/latest) to skip its check for the certificate 72 | validity of the given REST API endpoint. 73 | -------------------------------------------------------------------------------- /doc/res/notifications-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/doc/res/notifications-architecture.png -------------------------------------------------------------------------------- /doc/res/notifications-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/doc/res/notifications-preview.png -------------------------------------------------------------------------------- /library/Notifications/Common/Auth.php: -------------------------------------------------------------------------------- 1 | getAuth()->getUser(); 31 | if ($user->isUnrestricted()) { 32 | return; 33 | } 34 | 35 | $queryFilter = Filter::any(); 36 | foreach ($user->getRoles() as $role) { 37 | $roleFilter = Filter::all(); 38 | /** @var string $restriction */ 39 | $restriction = $role->getRestrictions('notifications/filter/objects'); 40 | if ($restriction) { 41 | $roleFilter->add($this->parseRestriction($restriction, 'notifications/filter/objects')); 42 | } 43 | 44 | if (! $roleFilter->isEmpty()) { 45 | $queryFilter->add($roleFilter); 46 | } 47 | } 48 | 49 | $query->filter($queryFilter); 50 | } 51 | 52 | /** 53 | * Parse the given restriction 54 | * 55 | * @param string $queryString 56 | * @param string $restriction The name of the restriction 57 | * 58 | * @return Filter\Rule 59 | */ 60 | protected function parseRestriction(string $queryString, string $restriction): Filter\Rule 61 | { 62 | // 'notifications/filter/objects' restriction 63 | return QueryString::fromString($queryString) 64 | ->on( 65 | QueryString::ON_CONDITION, 66 | function (Filter\Condition $condition) { 67 | //The condition column is actually the tag (eg): tag = hostgroup/linux, value = null 68 | if ($condition->getValue() === true) { 69 | $column = 'object.object_extra_tag.tag'; 70 | 71 | $condition->setValue($condition->getColumn()); 72 | $condition->setColumn($column); 73 | } 74 | //TODO: add support for foo=bar (tag=value) 75 | } 76 | )->parse(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /library/Notifications/Common/Icons.php: -------------------------------------------------------------------------------- 1 | $id]); 17 | } 18 | 19 | public static function events(): Url 20 | { 21 | return Url::fromPath('notifications/events'); 22 | } 23 | 24 | public static function incidents(): Url 25 | { 26 | return Url::fromPath('notifications/incidents'); 27 | } 28 | 29 | public static function incident(int $id): Url 30 | { 31 | return Url::fromPath('notifications/incident', ['id' => $id]); 32 | } 33 | 34 | public static function contacts(): Url 35 | { 36 | return Url::fromPath('notifications/contacts'); 37 | } 38 | 39 | public static function contact(int $id): Url 40 | { 41 | return Url::fromPath('notifications/contact', ['id' => $id]); 42 | } 43 | 44 | public static function contactAdd(): Url 45 | { 46 | return Url::fromPath('notifications/contacts/add'); 47 | } 48 | 49 | public static function channels(): Url 50 | { 51 | return Url::fromPath('notifications/channels'); 52 | } 53 | 54 | public static function channel(int $id): Url 55 | { 56 | return Url::fromPath('notifications/channel', ['id' => $id]); 57 | } 58 | 59 | public static function channelAdd(): Url 60 | { 61 | return Url::fromPath('notifications/channels/add'); 62 | } 63 | 64 | public static function eventRules(): Url 65 | { 66 | return Url::fromPath('notifications/event-rules'); 67 | } 68 | 69 | public static function eventRule(int $id): Url 70 | { 71 | return Url::fromPath('notifications/event-rule', ['id' => $id]); 72 | } 73 | 74 | public static function schedules(): Url 75 | { 76 | return Url::fromPath('notifications/schedules'); 77 | } 78 | 79 | public static function schedule(int $id): Url 80 | { 81 | return Url::fromPath('notifications/schedule', ['id' => $id]); 82 | } 83 | 84 | public static function scheduleAdd(): Url 85 | { 86 | return Url::fromPath('notifications/schedule/add'); 87 | } 88 | 89 | public static function scheduleSettings(int $id): Url 90 | { 91 | return Url::fromPath('notifications/schedule/settings', ['id' => $id]); 92 | } 93 | 94 | public static function contactGroups(): Url 95 | { 96 | return Url::fromPath('notifications/contact-groups'); 97 | } 98 | 99 | public static function contactGroupsAdd(): Url 100 | { 101 | return Url::fromPath('notifications/contact-groups/add'); 102 | } 103 | 104 | public static function contactGroupsSuggestMember(): Url 105 | { 106 | return Url::fromPath('notifications/contact-groups/suggest-member'); 107 | } 108 | 109 | public static function contactGroup(int $id): Url 110 | { 111 | return Url::fromPath('notifications/contact-group', ['id' => $id]); 112 | } 113 | 114 | public static function contactGroupEdit(int $id): Url 115 | { 116 | return Url::fromPath('notifications/contact-group/edit', ['id' => $id]); 117 | } 118 | 119 | public static function rotationAdd(int $scheduleId): Url 120 | { 121 | return Url::fromPath('notifications/schedule/add-rotation', ['schedule' => $scheduleId]); 122 | } 123 | 124 | public static function rotationSettings(int $id, int $scheduleId): Url 125 | { 126 | return Url::fromPath('notifications/schedule/edit-rotation', ['id' => $id, 'schedule' => $scheduleId]); 127 | } 128 | 129 | public static function moveRotation(): Url 130 | { 131 | return Url::fromPath('notifications/schedule/move-rotation'); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /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/Common/NoSubjectLink.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/AvailableChannelType.php: -------------------------------------------------------------------------------- 1 | hasMany('channel', Channel::class) 45 | ->setForeignKey('type'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /library/Notifications/Model/Behavior/IdTagAggregator.php: -------------------------------------------------------------------------------- 1 | query = $query; 34 | } 35 | 36 | public function rewriteColumn($column, ?string $relation = null) 37 | { 38 | if ($column === 'id_tags') { 39 | $path = ($relation ?? $this->query->getModel()->getTableAlias()) . '.object_id_tag'; 40 | 41 | $this->query->utilize($path); 42 | 43 | $pathRelation = $this->query->getResolver()->resolveRelation($path); 44 | if ($relation !== null) { 45 | // TODO: This is really another case where ipl-orm could automatically adjust the join type... 46 | $pathRelation->setJoinType($this->query->getResolver()->resolveRelation($relation)->getJoinType()); 47 | } 48 | 49 | $pathAlias = $this->query->getResolver()->getAlias($pathRelation->getTarget()); 50 | $myAlias = $this->query->getResolver()->getAlias( 51 | $relation 52 | ? $this->query->getResolver()->resolveRelation($relation)->getTarget() 53 | : $this->query->getModel() 54 | ); 55 | 56 | return new AliasedExpression("{$myAlias}_id_tags", sprintf( 57 | $this->query->getDb()->getAdapter() instanceof Pgsql 58 | ? 'json_object_agg(COALESCE(%s, \'\'), %s)' 59 | : 'json_objectagg(COALESCE(%s, \'\'), %s)', 60 | $this->query->getResolver()->qualifyColumn('tag', $pathAlias), 61 | $this->query->getResolver()->qualifyColumn('value', $pathAlias) 62 | )); 63 | } 64 | } 65 | 66 | public function isSelectableColumn(string $name): bool 67 | { 68 | return $name === 'id_tags'; 69 | } 70 | 71 | public function fromDb($value, $key, $context) 72 | { 73 | if (! is_string($value)) { 74 | return []; 75 | } 76 | 77 | $tags = json_decode($value, true) ?? []; 78 | if (iterable_key_first($tags) === '') { 79 | return []; 80 | } 81 | 82 | return $tags; 83 | } 84 | 85 | public function toDb($value, $key, $context) 86 | { 87 | throw new InvalidColumnException($key, new Objects()); 88 | } 89 | 90 | public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void 91 | { 92 | } 93 | 94 | public function rewriteCondition(Filter\Condition $condition, $relation = null) 95 | { 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /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/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/Model/Channel.php: -------------------------------------------------------------------------------- 1 | t('Name'), 57 | 'type' => t('Type'), 58 | 'changed_at' => t('Changed At') 59 | ]; 60 | } 61 | 62 | public function getSearchColumns(): array 63 | { 64 | return ['name']; 65 | } 66 | 67 | 68 | public function getDefaultSort(): array 69 | { 70 | return ['name']; 71 | } 72 | 73 | public function createBehaviors(Behaviors $behaviors): void 74 | { 75 | $behaviors->add(new MillisecondTimestamp(['changed_at'])); 76 | $behaviors->add(new BoolCast(['deleted'])); 77 | } 78 | 79 | public function createRelations(Relations $relations): void 80 | { 81 | $relations->hasMany('incident_history', IncidentHistory::class)->setJoinType('LEFT'); 82 | $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class)->setJoinType('LEFT'); 83 | $relations->hasMany('contact', Contact::class) 84 | ->setJoinType('LEFT') 85 | ->setForeignKey('default_channel_id'); 86 | $relations->belongsTo('available_channel_type', AvailableChannelType::class) 87 | ->setCandidateKey('type'); 88 | } 89 | 90 | /** 91 | * Get the channel icon 92 | * 93 | * @return Icon 94 | */ 95 | public function getIcon(): Icon 96 | { 97 | switch ($this->type) { 98 | case 'rocketchat': 99 | $icon = new Icon('comment-dots'); 100 | break; 101 | case 'email': 102 | $icon = new Icon('at'); 103 | break; 104 | default: 105 | $icon = new Icon('envelope'); 106 | } 107 | 108 | return $icon; 109 | } 110 | 111 | /** 112 | * Fetch and map all the configured channel names to a key => value array 113 | * 114 | * @param Connection $conn 115 | * 116 | * @return string[] All the channel names mapped as id => name 117 | */ 118 | public static function fetchChannelNames(Connection $conn): array 119 | { 120 | $channels = []; 121 | $query = Channel::on($conn); 122 | /** @var Channel $channel */ 123 | foreach ($query as $channel) { 124 | $name = $channel->name; 125 | $channels[$channel->id] = $name; 126 | } 127 | 128 | return $channels; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /library/Notifications/Model/Contact.php: -------------------------------------------------------------------------------- 1 | t('Full Name'), 60 | 'username' => t('Username'), 61 | 'changed_at' => t('Changed At') 62 | ]; 63 | } 64 | 65 | public function getSearchColumns(): array 66 | { 67 | return ['full_name']; 68 | } 69 | 70 | public function createBehaviors(Behaviors $behaviors): void 71 | { 72 | $behaviors->add(new MillisecondTimestamp(['changed_at'])); 73 | $behaviors->add(new BoolCast(['deleted'])); 74 | } 75 | 76 | public function getDefaultSort(): array 77 | { 78 | return ['full_name']; 79 | } 80 | 81 | public function createRelations(Relations $relations): void 82 | { 83 | $relations->belongsTo('channel', Channel::class) 84 | ->setCandidateKey('default_channel_id'); 85 | 86 | $relations->belongsToMany('incident', Incident::class) 87 | ->through('incident_contact') 88 | ->setJoinType('LEFT'); 89 | 90 | $relations->hasMany('incident_contact', IncidentContact::class); 91 | $relations->hasMany('incident_history', IncidentHistory::class); 92 | $relations->hasMany('rotation_member', RotationMember::class) 93 | ->setJoinType('LEFT'); 94 | $relations->hasMany('contact_address', ContactAddress::class); 95 | $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class) 96 | ->setJoinType('LEFT'); 97 | 98 | $relations->hasMany('contactgroup_member', ContactgroupMember::class); 99 | 100 | $relations->belongsToMany('contactgroup', Contactgroup::class) 101 | ->through('contactgroup_member') 102 | ->setJoinType('LEFT'); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /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/Model/Contactgroup.php: -------------------------------------------------------------------------------- 1 | t('Name')]; 52 | } 53 | 54 | public function getSearchColumns(): array 55 | { 56 | return ['name']; 57 | } 58 | 59 | public function createBehaviors(Behaviors $behaviors): void 60 | { 61 | $behaviors->add(new MillisecondTimestamp(['changed_at'])); 62 | $behaviors->add(new BoolCast(['deleted'])); 63 | } 64 | 65 | public function createRelations(Relations $relations): void 66 | { 67 | $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class) 68 | ->setJoinType('LEFT'); 69 | $relations->hasMany('incident_history', IncidentHistory::class); 70 | $relations->hasMany('contactgroup_member', ContactgroupMember::class); 71 | $relations 72 | ->belongsToMany('contact', Contact::class) 73 | ->through('contactgroup_member') 74 | ->setJoinType('LEFT'); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /library/Notifications/Model/Daemon/EventIdentifier.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/Model/ExtraTag.php: -------------------------------------------------------------------------------- 1 | add(new ObjectTags()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /library/Notifications/Model/Incident.php: -------------------------------------------------------------------------------- 1 | t('Object Id'), 61 | 'started_at' => t('Started At'), 62 | 'recovered_at' => t('Recovered At'), 63 | 'severity' => t('Severity') 64 | ]; 65 | } 66 | 67 | public function getSearchColumns(): array 68 | { 69 | return ['object.name']; 70 | } 71 | 72 | public function getDefaultSort(): array 73 | { 74 | return ['incident.severity desc, incident.started_at']; 75 | } 76 | 77 | public static function on(Connection $db): Query 78 | { 79 | $query = parent::on($db); 80 | 81 | $query->on(Query::ON_SELECT_ASSEMBLED, function (Select $select) use ($query) { 82 | if (isset($query->getUtilize()['incident.object.object_id_tag'])) { 83 | Database::registerGroupBy($query, $select); 84 | } 85 | }); 86 | 87 | return $query; 88 | } 89 | 90 | public function createBehaviors(Behaviors $behaviors): void 91 | { 92 | $behaviors->add(new Binary(['object_id'])); 93 | $behaviors->add(new MillisecondTimestamp([ 94 | 'started_at', 95 | 'recovered_at' 96 | ])); 97 | } 98 | 99 | public function createRelations(Relations $relations): void 100 | { 101 | $relations->belongsTo('object', Objects::class); 102 | 103 | $relations 104 | ->belongsToMany('event', Event::class) 105 | ->through('incident_event'); 106 | 107 | $relations->belongsToMany('contact', Contact::class) 108 | ->through('incident_contact'); 109 | 110 | $relations->hasMany('incident_contact', IncidentContact::class); 111 | $relations->hasMany('incident_history', IncidentHistory::class); 112 | 113 | $relations 114 | ->belongsToMany('rule', Rule::class) 115 | ->through('incident_rule'); 116 | 117 | $relations 118 | ->belongsToMany('rule_escalation', RuleEscalation::class) 119 | ->through('incident_rule_escalation_state') 120 | ->setJoinType('LEFT'); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /library/Notifications/Model/RotationMember.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/Model/Rule.php: -------------------------------------------------------------------------------- 1 | t('Name'), 54 | 'timeperiod_id' => t('Timeperiod ID'), 55 | 'object_filter' => t('Object Filter'), 56 | 'changed_at' => t('Changed At') 57 | ]; 58 | } 59 | 60 | public function getSearchColumns(): array 61 | { 62 | return ['name']; 63 | } 64 | 65 | public function getDefaultSort(): array 66 | { 67 | return ['name']; 68 | } 69 | 70 | public function createBehaviors(Behaviors $behaviors): void 71 | { 72 | $behaviors->add(new MillisecondTimestamp(['changed_at'])); 73 | $behaviors->add(new BoolCast(['deleted'])); 74 | } 75 | 76 | public function createRelations(Relations $relations): void 77 | { 78 | $relations->hasMany('rule_escalation', RuleEscalation::class); 79 | 80 | $relations 81 | ->belongsToMany('incident', Incident::class) 82 | ->through('incident_rule') 83 | ->setJoinType('LEFT'); 84 | 85 | $relations->hasMany('incident_history', IncidentHistory::class)->setJoinType('LEFT'); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /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/Model/RuleEscalationRecipient.php: -------------------------------------------------------------------------------- 1 | t('Rule Escalation ID'), 61 | 'contact_id' => t('Contact ID'), 62 | 'contactgroup_id' => t('Contactgroup ID'), 63 | 'schedule_id' => t('Schedule ID'), 64 | 'channel_id' => t('Channel ID'), 65 | 'changed_at' => t('Changed At') 66 | ]; 67 | } 68 | 69 | public function getDefaultSort(): array 70 | { 71 | return ['rule_escalation_id']; 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('rule_escalation', RuleEscalation::class); 83 | $relations->belongsTo('contact', Contact::class); 84 | $relations->belongsTo('schedule', Schedule::class); 85 | $relations->belongsTo('contactgroup', Contactgroup::class); 86 | $relations->belongsTo('channel', Channel::class); 87 | } 88 | 89 | /** 90 | * Get the recipient model 91 | * 92 | * @return Contact|Contactgroup|Schedule|null 93 | */ 94 | public function getRecipient(): ?Model 95 | { 96 | $recipientModel = null; 97 | if ($this->contact_id) { 98 | $recipientModel = $this->contact->first(); 99 | } 100 | 101 | if ($this->contactgroup_id) { 102 | $recipientModel = $this->contactgroup->first(); 103 | } 104 | 105 | if ($this->schedule_id) { 106 | $recipientModel = $this->schedule->first(); 107 | } 108 | 109 | return $recipientModel; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /library/Notifications/Model/Schedule.php: -------------------------------------------------------------------------------- 1 | t('Name'), 50 | 'changed_at' => t('Changed At') 51 | ]; 52 | } 53 | 54 | public function getSearchColumns(): array 55 | { 56 | return ['name']; 57 | } 58 | 59 | public function getDefaultSort(): string 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('rotation', Rotation::class); 73 | $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class) 74 | ->setJoinType('LEFT'); 75 | $relations->hasMany('incident_history', IncidentHistory::class); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /library/Notifications/Model/Source.php: -------------------------------------------------------------------------------- 1 | t('Type'), 69 | 'name' => t('Name'), 70 | 'changed_at' => t('Changed At') 71 | ]; 72 | } 73 | 74 | public function getSearchColumns(): array 75 | { 76 | return ['type']; 77 | } 78 | 79 | public function getDefaultSort(): string 80 | { 81 | return 'source.name'; 82 | } 83 | 84 | public function createBehaviors(Behaviors $behaviors): void 85 | { 86 | $behaviors->add(new MillisecondTimestamp(['changed_at'])); 87 | $behaviors->add(new BoolCast(['deleted'])); 88 | } 89 | 90 | public function createRelations(Relations $relations): void 91 | { 92 | $relations->hasMany('object', Objects::class); 93 | } 94 | 95 | /** 96 | * Get the source icon 97 | * 98 | * @return Icon 99 | */ 100 | public function getIcon(): Icon 101 | { 102 | switch ($this->type) { 103 | //TODO(sd): Add icons for other known sources 104 | case self::ICINGA_TYPE_NAME: 105 | $icon = new IcingaIcon('icinga'); 106 | break; 107 | default: 108 | $icon = new Icon('share-nodes'); 109 | } 110 | 111 | return $icon; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /library/Notifications/Model/Tag.php: -------------------------------------------------------------------------------- 1 | add(new ObjectTags()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | $title->addHtml(new Link($item->name, Links::contactGroup($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/View/EventRenderer.php: -------------------------------------------------------------------------------- 1 | */ 23 | class EventRenderer implements ItemRenderer 24 | { 25 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 26 | { 27 | $attributes->get('class')->addValue('event'); 28 | } 29 | 30 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 31 | { 32 | $icon = $item->getIcon(); 33 | if ($icon) { 34 | $visual->addHtml($icon); 35 | } 36 | } 37 | 38 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 39 | { 40 | if (! $item->incident instanceof Incident) { 41 | throw new InvalidArgumentException('Incidents must be loaded with the event'); 42 | } 43 | 44 | if ($item->incident->id !== null) { 45 | $title->addHtml(Html::tag('span', [], sprintf('#%d:', $item->incident->id))); 46 | } 47 | 48 | if ($layout === 'header') { 49 | $content = new HtmlElement('span'); 50 | } else { 51 | $content = new Link(null, Links::event($item->id)); 52 | } 53 | 54 | /** @var Objects $obj */ 55 | $obj = $item->object; 56 | $name = $obj->getName(); 57 | 58 | $content->addAttributes($name->getAttributes()); 59 | $content->addFrom($name); 60 | 61 | $title->addHtml($content); 62 | $title->addHtml(HtmlElement::create('span', ['class' => 'state'], $item->getTypeText())); 63 | } 64 | 65 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 66 | { 67 | switch ($item->type) { 68 | case 'mute': 69 | case 'unmute': 70 | case 'flapping-start': 71 | case 'flapping-end': 72 | case 'downtime-start': 73 | case 'downtime-end': 74 | case 'downtime-removed': 75 | case 'acknowledgement-set': 76 | case 'acknowledgement-cleared': 77 | if ($item->mute_reason !== null) { 78 | $caption->add($item->mute_reason); 79 | break; 80 | } 81 | 82 | // Sometimes these events have no mute reason, but a message 83 | default: 84 | $caption->add($item->message); 85 | } 86 | } 87 | 88 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 89 | { 90 | if ($item->type === 'state') { 91 | /** @var Objects $object */ 92 | $object = $item->object; 93 | /** @var Source $source */ 94 | $source = $object->source; 95 | $info->addHtml( 96 | (new Ball(Ball::SIZE_BIG)) 97 | ->addAttributes(['class' => 'source-icon']) 98 | ->addHtml($source->getIcon()) 99 | ); 100 | } 101 | 102 | $info->addHtml(new TimeAgo($item->time->getTimestamp())); 103 | } 104 | 105 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 106 | { 107 | } 108 | 109 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 110 | { 111 | return false; // no custom sections 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /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/View/IncidentContactRenderer.php: -------------------------------------------------------------------------------- 1 | */ 19 | class IncidentContactRenderer implements ItemRenderer 20 | { 21 | use Translation; 22 | 23 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 24 | { 25 | $attributes->get('class')->addValue('incident-contact'); 26 | } 27 | 28 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 29 | { 30 | $visual->addHtml(new Icon($item->role === 'manager' ? Icons::USER_MANAGER : Icons::USER)); 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 | if ($item->role === 'manager') { 38 | $title->addHtml(new Text($this->translate('manages this incident'))); 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/View/IncidentRenderer.php: -------------------------------------------------------------------------------- 1 | */ 26 | class IncidentRenderer implements ItemRenderer 27 | { 28 | use Translation; 29 | 30 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 31 | { 32 | $attributes->get('class')->addValue('incident'); 33 | } 34 | 35 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 36 | { 37 | switch ($item->severity) { 38 | case 'ok': 39 | $icon = Icons::OK; 40 | break; 41 | case 'err': 42 | $icon = Icons::ERROR; 43 | break; 44 | case 'crit': 45 | $icon = Icons::CRITICAL; 46 | break; 47 | default: 48 | $icon = Icons::WARNING; 49 | } 50 | 51 | $content = new Icon($icon, ['class' => ['severity-' . $item->severity]]); 52 | 53 | if ($item->severity === 'ok' || $item->severity === 'err') { 54 | $content->setStyle('fa-regular'); 55 | } 56 | 57 | $visual->addHtml($content); 58 | } 59 | 60 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 61 | { 62 | $title->addHtml(Html::tag('span', [], sprintf('#%d:', $item->id))); 63 | 64 | if ($layout === 'header') { 65 | $content = new HtmlElement('span'); 66 | } else { 67 | $content = new Link(null, Links::incident($item->id)); 68 | } 69 | 70 | /** @var Objects $obj */ 71 | $obj = $item->object; 72 | $name = $obj->getName(); 73 | 74 | $content->addAttributes($name->getAttributes()); 75 | $content->addFrom($name); 76 | 77 | $title->addHtml($content); 78 | } 79 | 80 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 81 | { 82 | } 83 | 84 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 85 | { 86 | if ($item->severity !== 'ok' && $item->object->mute_reason !== null) { 87 | $info->addHtml(new Icon(Icons::MUTE, ['title' => $item->object->mute_reason])); 88 | } 89 | 90 | /** @var Source $source */ 91 | $source = $item->object->source; 92 | $info->addHtml( 93 | (new Ball(Ball::SIZE_BIG)) 94 | ->addAttributes(['class' => 'source-icon']) 95 | ->addHtml($source->getIcon()) 96 | ); 97 | 98 | if ($item->recovered_at !== null) { 99 | $info->addHtml(FormattedString::create( 100 | $this->translate('closed %s', '(incident) ... '), 101 | new TimeAgo($item->recovered_at->getTimestamp()) 102 | )); 103 | } else { 104 | $info->addHtml(new TimeSince($item->started_at->getTimestamp())); 105 | } 106 | } 107 | 108 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 109 | { 110 | } 111 | 112 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 113 | { 114 | return false; // no custom sections 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /library/Notifications/View/ScheduleRenderer.php: -------------------------------------------------------------------------------- 1 | */ 20 | class ScheduleRenderer implements ItemRenderer 21 | { 22 | public function assembleAttributes($item, Attributes $attributes, string $layout): void 23 | { 24 | $attributes->get('class')->addValue('schedule'); 25 | } 26 | 27 | public function assembleVisual($item, HtmlDocument $visual, string $layout): void 28 | { 29 | } 30 | 31 | public function assembleTitle($item, HtmlDocument $title, string $layout): void 32 | { 33 | $title->addHtml( 34 | new Link( 35 | $item->name, 36 | Links::schedule($item->id), 37 | ['class' => 'subject'] 38 | ) 39 | ); 40 | } 41 | 42 | public function assembleCaption($item, HtmlDocument $caption, string $layout): void 43 | { 44 | // Number of days is set to 7, since default mode for schedule is week 45 | // and the start day should be the current day 46 | $timeline = (new Timeline((new DateTime())->setTime(0, 0), 7)) 47 | ->minimalLayout() 48 | ->setStyle( 49 | (new Style()) 50 | ->setNonce(Csp::getStyleNonce()) 51 | ->setModule('notifications') 52 | ); 53 | 54 | $rotations = $item->rotation->with('timeperiod')->orderBy('first_handoff', SORT_DESC); 55 | 56 | foreach ($rotations as $rotation) { 57 | $timeline->addRotation(new Rotation($rotation)); 58 | } 59 | 60 | $caption->addHtml($timeline); 61 | } 62 | 63 | public function assembleExtendedInfo($item, HtmlDocument $info, string $layout): void 64 | { 65 | } 66 | 67 | public function assembleFooter($item, HtmlDocument $footer, string $layout): void 68 | { 69 | } 70 | 71 | public function assemble($item, string $name, HtmlDocument $element, string $layout): bool 72 | { 73 | return false; // no custom sections 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Detail/ObjectHeader.php: -------------------------------------------------------------------------------- 1 | object = $object; 38 | } 39 | 40 | /** 41 | * @throws NotImplementedError When the object type is not supported 42 | */ 43 | protected function assemble(): void 44 | { 45 | switch (true) { 46 | case $this->object instanceof Event: 47 | $renderer = new EventRenderer(); 48 | 49 | break; 50 | case $this->object instanceof Incident: 51 | $renderer = new IncidentRenderer(); 52 | 53 | break; 54 | default: 55 | throw new NotImplementedError('Not implemented'); 56 | } 57 | 58 | $layout = new HeaderItemLayout($this->object, $renderer); 59 | 60 | $this->addAttributes($layout->getAttributes()); 61 | $this->addHtml($layout); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Detail/ScheduleDetail.php: -------------------------------------------------------------------------------- 1 | 'notifications-schedule', 'class' => 'schedule-detail']; 25 | 26 | /** @var Schedule */ 27 | protected $schedule; 28 | 29 | /** @var Controls */ 30 | protected $controls; 31 | 32 | /** 33 | * Create a new Schedule 34 | * 35 | * @param Schedule $schedule 36 | * @param Controls $controls 37 | */ 38 | public function __construct(Schedule $schedule, Controls $controls) 39 | { 40 | $this->schedule = $schedule; 41 | $this->controls = $controls; 42 | } 43 | 44 | /** 45 | * Assemble the timeline 46 | * 47 | * @param Timeline $timeline 48 | */ 49 | protected function assembleTimeline(Timeline $timeline): void 50 | { 51 | foreach ($this->schedule->rotation->with('timeperiod')->orderBy('first_handoff', SORT_DESC) as $rotation) { 52 | $timeline->addRotation(new Rotation($rotation)); 53 | } 54 | } 55 | 56 | /** 57 | * Create the timeline 58 | * 59 | * @return Timeline 60 | */ 61 | protected function createTimeline(): Timeline 62 | { 63 | $timeline = new Timeline($this->controls->getStartDate(), $this->controls->getNumberOfDays()); 64 | $timeline->setStyle( 65 | (new Style()) 66 | ->setNonce(Csp::getStyleNonce()) 67 | ->setModule('notifications') 68 | ); 69 | 70 | $this->assembleTimeline($timeline); 71 | 72 | return $timeline; 73 | } 74 | 75 | protected function assemble() 76 | { 77 | $this->addHtml( 78 | new HtmlElement('div', Attributes::create(['class' => 'schedule-header']), $this->controls), 79 | new HtmlElement('div', Attributes::create(['class' => 'schedule-container']), $this->createTimeline()) 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Detail/ScheduleDetail/Controls.php: -------------------------------------------------------------------------------- 1 | 'schedule-controls']; 25 | 26 | /** 27 | * Get the chosen mode 28 | * 29 | * @return string 30 | */ 31 | public function getMode(): string 32 | { 33 | return $this->getPopulatedValue('mode') 34 | ?? Session::getSession()->getNamespace('notifications') 35 | ->get('schedule.timeline.mode', self::DEFAULT_MODE); 36 | } 37 | 38 | /** 39 | * Get the number of days the user wants to see 40 | * 41 | * @return int 42 | */ 43 | public function getNumberOfDays(): int 44 | { 45 | switch ($this->getMode()) { 46 | case 'day': 47 | return 1; 48 | case 'weeks': 49 | return 14; 50 | case 'month': 51 | return 31; 52 | case 'week': 53 | default: 54 | return 7; 55 | } 56 | } 57 | 58 | /** 59 | * Get the start date where the user wants the schedule to begin 60 | * 61 | * @return DateTime 62 | */ 63 | public function getStartDate(): DateTime 64 | { 65 | return (new DateTime())->setTime(0, 0); 66 | } 67 | 68 | protected function onSuccess() 69 | { 70 | Session::getSession()->getNamespace('notifications') 71 | ->set('schedule.timeline.mode', $this->getValue('mode')); 72 | } 73 | 74 | protected function assemble() 75 | { 76 | $param = 'mode'; 77 | $options = [ 78 | 'day' => $this->translate('Day'), 79 | 'week' => $this->translate('Week'), 80 | 'weeks' => $this->translate('2 Weeks'), 81 | 'month' => $this->translate('Month') 82 | ]; 83 | 84 | $this->addElement('hidden', $param, ['required' => true]); 85 | 86 | $chosenMode = $this->getMode(); 87 | $viewModeSwitcher = HtmlElement::create('fieldset', ['class' => 'view-mode-switcher']); 88 | foreach ($options as $value => $label) { 89 | $input = $this->createElement('input', $param, [ 90 | 'class' => 'autosubmit', 91 | 'type' => 'radio', 92 | 'id' => $param . '-' . $value, 93 | 'value' => $value, 94 | 'checked' => $value === $chosenMode 95 | ]); 96 | 97 | $viewModeSwitcher->addHtml( 98 | $input, 99 | new HtmlElement('label', Attributes::create(['for' => $param . '-' . $value]), Text::create($label)) 100 | ); 101 | } 102 | 103 | $this->addHtml($viewModeSwitcher); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Escalations.php: -------------------------------------------------------------------------------- 1 | 'escalations']; 14 | 15 | protected $tag = 'div'; 16 | 17 | protected $config; 18 | 19 | private $escalations = []; 20 | 21 | protected function assemble() 22 | { 23 | $this->add($this->escalations); 24 | } 25 | 26 | public function addEscalation(int $position, array $escalation, ?RemoveEscalationForm $removeEscalationForm = null) 27 | { 28 | $flowLine = (new FlowLine())->getRightArrow(); 29 | 30 | if ( 31 | in_array( 32 | 'count-zero-escalation-condition-form', 33 | $escalation[0]->getAttributes()->get('class')->getValue() 34 | ) 35 | ) { 36 | $flowLine->addAttributes(['class' => 'right-arrow-long']); 37 | } 38 | 39 | if ($removeEscalationForm) { 40 | $this->escalations[$position] = Html::tag( 41 | 'div', 42 | ['class' => 'escalation'], 43 | [ 44 | $removeEscalationForm, 45 | $flowLine, 46 | $escalation[0], 47 | $flowLine, 48 | $escalation[1], 49 | ] 50 | ); 51 | } else { 52 | $this->escalations[$position] = Html::tag( 53 | 'div', 54 | ['class' => 'escalation'], 55 | [ 56 | $flowLine->addAttributes(['class' => 'right-arrow-one-escalation']), 57 | $escalation[0], 58 | $flowLine, 59 | $escalation[1] 60 | ] 61 | ); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /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/Widget/FlowLine.php: -------------------------------------------------------------------------------- 1 | setAttributes(['class' => 'right-arrow']); 17 | 18 | return $this; 19 | } 20 | 21 | public function getHorizontalLine() 22 | { 23 | $this->setAttributes(['class' => 'horizontal-line']); 24 | 25 | return $this; 26 | } 27 | 28 | public function getVerticalLine() 29 | { 30 | $this->setAttributes(['class' => 'vertical-line']); 31 | 32 | return $this; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /library/Notifications/Widget/ItemList/ContactGroupListItem.php: -------------------------------------------------------------------------------- 1 | getAttributes()->set('data-action-item', true); 27 | } 28 | 29 | protected function assembleVisual(BaseHtmlElement $visual): void 30 | { 31 | $visual->addHtml(new HtmlElement( 32 | 'div', 33 | Attributes::create(['class' => 'contact-ball']), 34 | Text::create(grapheme_substr($this->item->name, 0, 1)) 35 | )); 36 | } 37 | 38 | protected function assembleMain(BaseHtmlElement $main): void 39 | { 40 | $main->addHtml($this->createHeader()); 41 | } 42 | 43 | protected function assembleHeader(BaseHtmlElement $header): void 44 | { 45 | $header->addHtml($this->createTitle()); 46 | } 47 | 48 | protected function assembleTitle(BaseHtmlElement $title): void 49 | { 50 | $title->addHtml(new Link($this->item->name, Links::contactGroup($this->item->id), ['class' => 'subject'])); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /library/Notifications/Widget/ItemList/ContactListItem.php: -------------------------------------------------------------------------------- 1 | getAttributes() 30 | ->set('data-action-item', true); 31 | } 32 | 33 | protected function assembleVisual(BaseHtmlElement $visual): void 34 | { 35 | $visual->addHtml(new HtmlElement( 36 | 'div', 37 | Attributes::create(['class' => 'contact-ball']), 38 | Text::create(grapheme_substr($this->item->full_name, 0, 1)) 39 | )); 40 | } 41 | 42 | protected function assembleTitle(BaseHtmlElement $title): void 43 | { 44 | $title->addHtml(new Link( 45 | $this->item->full_name, 46 | Url::fromPath('notifications/contact', ['id' => $this->item->id]), 47 | ['class' => 'subject'] 48 | )); 49 | } 50 | 51 | protected function assembleHeader(BaseHtmlElement $header): void 52 | { 53 | $header->add($this->createTitle()); 54 | } 55 | 56 | protected function assembleMain(BaseHtmlElement $main): void 57 | { 58 | $main->add($this->createHeader()); 59 | 60 | $main->add($this->createFooter()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /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/Widget/ItemList/ObjectList.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | class ObjectList extends ItemList 30 | { 31 | /** @var bool Whether the action-list functionality should be disabled */ 32 | protected $disableActionList = false; 33 | 34 | public function __construct($data, $itemRenderer) 35 | { 36 | parent::__construct($data, $itemRenderer); 37 | 38 | $this->getAttributes() // TODO(sd): only required for IncidentHistory, find a better solution 39 | ->registerAttributeCallback('class', function () { 40 | return $this->disableActionList ? null : 'action-list'; 41 | }); 42 | } 43 | 44 | /** 45 | * Set whether the action-list functionality should be disabled 46 | * 47 | * @param bool $state 48 | * 49 | * @return $this 50 | */ 51 | public function disableActionList(bool $state = true): self 52 | { 53 | $this->disableActionList = $state; 54 | 55 | return $this; 56 | } 57 | 58 | protected function createListItem(object $data): ListItem 59 | { 60 | $item = parent::createListItem($data); 61 | 62 | if (! $this->disableActionList) { 63 | $item->addAttributes(['data-action-item' => true]); 64 | } 65 | 66 | return $item; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /library/Notifications/Widget/MemberSuggestions.php: -------------------------------------------------------------------------------- 1 | searchTerm = $term; 32 | 33 | return $this; 34 | } 35 | 36 | public function setOriginalValue(string $term): self 37 | { 38 | $this->originalValue = $term; 39 | 40 | return $this; 41 | } 42 | 43 | public function excludeTerms(array $terms): self 44 | { 45 | $this->excludeTerms = $terms; 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * Load suggestions as requested by the client 52 | * 53 | * @param ServerRequestInterface $request 54 | * 55 | * @return $this 56 | */ 57 | public function forRequest(ServerRequestInterface $request): self 58 | { 59 | if ($request->getMethod() !== 'POST') { 60 | return $this; 61 | } 62 | 63 | $requestData = json_decode($request->getBody()->read(8192), true); 64 | if (empty($requestData)) { 65 | return $this; 66 | } 67 | 68 | $this->setSearchTerm($requestData['term']['label']); 69 | $this->setOriginalValue($requestData['term']['search']); 70 | 71 | $toExclude = array_filter($requestData['exclude'] ?? [], function ($term) { 72 | return is_numeric($term); 73 | }); 74 | 75 | $this->excludeTerms($toExclude); 76 | 77 | return $this; 78 | } 79 | 80 | protected function assemble(): void 81 | { 82 | $contactFilter = Filter::like('full_name', $this->searchTerm); 83 | 84 | if (! empty($this->excludeTerms)) { 85 | $contactFilter = Filter::all( 86 | $contactFilter, 87 | Filter::any( 88 | Filter::equal('full_name', $this->originalValue), 89 | Filter::unequal('id', $this->excludeTerms) 90 | ) 91 | ); 92 | } 93 | 94 | foreach (Contact::on(Database::get())->filter($contactFilter) as $contact) { 95 | $this->addHtml( 96 | new HtmlElement( 97 | 'li', 98 | null, 99 | new HtmlElement( 100 | 'input', 101 | Attributes::create([ 102 | 'type' => 'button', 103 | 'value' => $contact->full_name, 104 | 'data-label' => $contact->full_name, 105 | 'data-search' => $contact->id, 106 | 'data-class' => 'contact' 107 | ]) 108 | ) 109 | ) 110 | ); 111 | } 112 | 113 | if ($this->isEmpty()) { 114 | $this->addHtml( 115 | new HtmlElement( 116 | 'li', 117 | Attributes::create(['class' => 'nothing-to-suggest']), 118 | new HtmlElement('em', null, Text::create(t('Nothing to suggest'))) 119 | ) 120 | ); 121 | } 122 | } 123 | 124 | public function renderUnwrapped(): string 125 | { 126 | $this->ensureAssembled(); 127 | 128 | if ($this->isEmpty()) { 129 | return ''; 130 | } 131 | 132 | return parent::renderUnwrapped(); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /library/Notifications/Widget/RightArrow.php: -------------------------------------------------------------------------------- 1 | 'right-arrow']; 15 | } 16 | -------------------------------------------------------------------------------- /library/Notifications/Widget/RuleEscalationRecipientBadge.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/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 | -------------------------------------------------------------------------------- /library/Notifications/Widget/TimeGrid/DaysHeader.php: -------------------------------------------------------------------------------- 1 | ['days-header', 'time-grid-header']]; 27 | 28 | /** @var DateTime Starting day */ 29 | protected $startDay; 30 | 31 | /** 32 | * Create a new DaysHeader 33 | * 34 | * @param DateTime $startDay 35 | * @param int $days 36 | */ 37 | public function __construct(DateTime $startDay, int $days) 38 | { 39 | $this->startDay = $startDay; 40 | $this->days = $days; 41 | } 42 | 43 | public function assemble(): void 44 | { 45 | $dayNames = [ 46 | $this->translate('Mon', 'monday'), 47 | $this->translate('Tue', 'tuesday'), 48 | $this->translate('Wed', 'wednesday'), 49 | $this->translate('Thu', 'thursday'), 50 | $this->translate('Fri', 'friday'), 51 | $this->translate('Sat', 'saturday'), 52 | $this->translate('Sun', 'sunday') 53 | ]; 54 | 55 | $interval = new DateInterval('P1D'); 56 | $today = (new DateTime())->setTime(0, 0); 57 | $time = clone $this->startDay; 58 | $dateFormatter = new IntlDateFormatter( 59 | Locale::getDefault(), 60 | IntlDateFormatter::MEDIUM, 61 | IntlDateFormatter::NONE 62 | ); 63 | 64 | for ($i = 0; $i < $this->days; $i++) { 65 | if ($time == $today) { 66 | $title = [new HtmlElement( 67 | 'span', 68 | Attributes::create(['class' => 'day-name']), 69 | Text::create($this->translate('Today')) 70 | )]; 71 | } else { 72 | $title = [ 73 | new HtmlElement( 74 | 'span', 75 | Attributes::create(['class' => 'date']), 76 | Text::create($time->format($this->translate('d/m', 'day-name, time'))) 77 | ), 78 | Text::create(' '), 79 | new HtmlElement( 80 | 'span', 81 | Attributes::create(['class' => 'day-name']), 82 | Text::create($dayNames[$time->format('N') - 1]) 83 | ) 84 | ]; 85 | } 86 | 87 | $this->addHtml(new HtmlElement( 88 | 'div', 89 | Attributes::create(['class' => 'column-title', 'title' => $dateFormatter->format($time)]), 90 | ...$title 91 | )); 92 | 93 | $time->add($interval); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/Widget/TimeGrid/Util.php: -------------------------------------------------------------------------------- 1 | */ 13 | private static $entryColors = []; 14 | 15 | public static function diffHours(DateTime $from, DateTime $to) 16 | { 17 | $diff = $from->diff($to); 18 | if ($diff->invert) { 19 | throw new InvalidArgumentException('The end date must be after the start date'); 20 | } 21 | 22 | $hours = 0; 23 | if ($diff->h > 0) { 24 | $hours += $diff->h; 25 | } 26 | 27 | if ($diff->i > 0) { 28 | $hours += $diff->i / 60; 29 | } 30 | 31 | if ($diff->days > 0) { 32 | $hours += $diff->days * 24; 33 | } 34 | 35 | return $hours; 36 | } 37 | 38 | public static function roundToNearestThirtyMinute(DateTime $time): DateTime 39 | { 40 | $hour = (int) $time->format('H'); 41 | $minute = (int) $time->format('i'); 42 | 43 | $time = clone $time; 44 | if ($minute < 15) { 45 | $time->setTime($hour, 0); 46 | } elseif ($minute >= 45) { 47 | $time->setTime($hour + 1, 0); 48 | } else { 49 | $time->setTime($hour, 30); 50 | } 51 | 52 | return $time; 53 | } 54 | 55 | /** 56 | * Calculate a color for an entry based on the given text 57 | * 58 | * @param string $text 59 | * @param int<0, 100> $transparency 60 | * 61 | * @return string A CSS color definition 62 | */ 63 | public static function calculateEntryColor(string $text, int $transparency): string 64 | { 65 | if (! isset(self::$entryColors[$text])) { 66 | // Get a representation of the attendee's name suitable for conversion to a decimal 67 | // TODO: There are how million colors in sRGB? Then why not use this as max value and ensure a good spread? 68 | // Hashes always have a high number, so the reason why we use the remainder of the modulo operation 69 | // below makes somehow sense, though it limits the variation to 360 colors which is not good enough. 70 | // The saturation makes it more diverse, but only by a factor of 3. So essentially there are 360 * 3 71 | // colors. By far lower than the 16.7 million colors in sRGB. But of course, we need distinct colors 72 | // so if 500 thousand colors of these 16.7 millions are so similar that we can't distinguish them, 73 | // there's no need for such a high variance. Hence we'd still need to partition the colors in a way 74 | // that they are distinct enough. 75 | $hash = hexdec(substr(hash('sha256', $text), 28, 8)); 76 | // Limit the hue to a maximum of 360 as it's HSL's maximum of 360 degrees 77 | $h = (int) fmod($hash, 359.0); // TODO: Check if 359 is really of advantage here, instead of 360 78 | // The hue is already at least 1 degree off to every other, using a limited set of saturation values 79 | // further ensures that colors are distinct enough even if similar 80 | $s = [35, 50, 65][$h % 3]; 81 | 82 | self::$entryColors[$text] = [$h, $s]; 83 | } else { 84 | [$h, $s] = self::$entryColors[$text]; 85 | } 86 | 87 | // We use a fixed luminosity to ensure good and equal contrast in both dark and light mode 88 | return sprintf('~"hsl(%d %d%% 50%% / %d%%)"', $h, $s, $transparency); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /library/Notifications/Widget/Timeline/Entry.php: -------------------------------------------------------------------------------- 1 | member = $member; 22 | 23 | return $this; 24 | } 25 | 26 | public function getMember(): ?Member 27 | { 28 | return $this->member; 29 | } 30 | 31 | public function getColor(int $transparency): string 32 | { 33 | return TimeGrid\Util::calculateEntryColor($this->getMember()->getName(), $transparency); 34 | } 35 | 36 | protected function assembleContainer(BaseHtmlElement $container): void 37 | { 38 | $container->addHtml( 39 | new HtmlElement( 40 | 'div', 41 | Attributes::create(['class' => 'title']), 42 | new Icon($this->getMember()->getIcon()), 43 | new HtmlElement( 44 | 'span', 45 | Attributes::create(['class' => 'name']), 46 | Text::create($this->getMember()->getName()) 47 | ) 48 | ) 49 | ); 50 | 51 | $dateType = \IntlDateFormatter::NONE; 52 | $timeType = \IntlDateFormatter::SHORT; 53 | if ( 54 | $this->getStart()->diff($this->getEnd())->days > 0 55 | || $this->getStart()->format('Y-m-d') !== $this->getEnd()->format('Y-m-d') 56 | ) { 57 | $dateType = \IntlDateFormatter::SHORT; 58 | } 59 | 60 | $formatter = new \IntlDateFormatter(\Locale::getDefault(), $dateType, $timeType); 61 | 62 | $container->addAttributes([ 63 | 'title' => sprintf( 64 | $this->translate('%s is available from %s to %s'), 65 | $this->getMember()->getName(), 66 | $formatter->format($this->getStart()), 67 | $formatter->format($this->getEnd()) 68 | ) 69 | ]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /module.info: -------------------------------------------------------------------------------- 1 | Module: notifications 2 | Version: 0.1.0 3 | Requires: 4 | Libraries: icinga-php-library (>=0.15.0), icinga-php-thirdparty (>=0.12.0) 5 | Description: Icinga Notifications Web 6 | Manage incidents and who gets notified about them how and when 7 | -------------------------------------------------------------------------------- /phpstan-baseline-7x.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Parameter \\#2 \\$str of function explode expects string, mixed given\\.$#" 5 | count: 2 6 | path: application/forms/EscalationRecipientForm.php 7 | 8 | - 9 | message: "#^Parameter \\#1 \\$time of function strtotime expects string, mixed given\\.$#" 10 | count: 1 11 | path: library/Notifications/Widget/Calendar.php 12 | -------------------------------------------------------------------------------- /phpstan-baseline-8x.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#" 5 | count: 2 6 | path: application/forms/EscalationRecipientForm.php 7 | 8 | - 9 | message: "#^Parameter \\#1 \\$datetime of function strtotime expects string, mixed given\\.$#" 10 | count: 1 11 | path: library/Notifications/Widget/Calendar.php 12 | -------------------------------------------------------------------------------- /phpstan-baseline-by-php-version.php: -------------------------------------------------------------------------------- 1 | = 80000) { 5 | $includes[] = __DIR__ . '/phpstan-baseline-8x.neon'; 6 | } else { 7 | $includes[] = __DIR__ . '/phpstan-baseline-7x.neon'; 8 | } 9 | 10 | return [ 11 | 'includes' => $includes 12 | ]; 13 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline-standard.neon 3 | - phpstan-baseline-by-php-version.php 4 | 5 | parameters: 6 | level: max 7 | 8 | checkFunctionNameCase: true 9 | checkInternalClassCaseSensitivity: true 10 | treatPhpDocTypesAsCertain: false 11 | 12 | paths: 13 | - application 14 | - library 15 | 16 | scanDirectories: 17 | - /icingaweb2 18 | - /usr/share/icinga-php 19 | - /usr/share/icingaweb2-modules 20 | 21 | ignoreErrors: 22 | - 23 | messages: 24 | - '#Unsafe usage of new static\(\)#' 25 | - '#. but return statement is missing#' 26 | reportUnmatched: false 27 | 28 | universalObjectCratesClasses: 29 | - ipl\Orm\Model 30 | - Icinga\Web\View 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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.notification-suppressed { 29 | opacity: .75; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/css/event-source-badge.less: -------------------------------------------------------------------------------- 1 | .event-source-badge { 2 | position: relative; 3 | 4 | .source-icon { 5 | position: absolute; 6 | } 7 | 8 | .name { 9 | position: relative; 10 | display: inline-block; 11 | height: 2em; 12 | border-bottom-right-radius: 1em; 13 | border-top-right-radius: 1em; 14 | z-index: -99; 15 | padding: @ball-pad .5em @ball-pad 1.5em; 16 | margin-left: 1em; 17 | 18 | background-color: @gray; 19 | color: @text-color-inverted; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/css/form.less: -------------------------------------------------------------------------------- 1 | /* Layout */ 2 | 3 | .icinga-form { 4 | input[type="color"] { 5 | padding: .25em; 6 | background: @low-sat-blue; 7 | } 8 | 9 | input[type="color"]::-webkit-color-swatch-wrapper { 10 | padding: 0; 11 | border-radius: 0; 12 | } 13 | 14 | input[type="color"]::-webkit-color-swatch { 15 | border-radius: 0; 16 | border: none; 17 | } 18 | 19 | input[type="date"] { 20 | width: auto; 21 | } 22 | } 23 | 24 | .rotation-config { 25 | .rotation-mode { 26 | width: 50em; 27 | padding: .5em 1em; 28 | margin: 0 auto; 29 | 30 | h2 { 31 | margin: 0; 32 | } 33 | 34 | ul { 35 | display: flex; 36 | justify-content: space-between; 37 | 38 | list-style: none; 39 | margin: 0; 40 | padding: 0; 41 | 42 | li { 43 | flex: 1 1 auto; 44 | width: 0; 45 | 46 | &:not(:last-child) { 47 | margin-right: 1em; 48 | } 49 | } 50 | 51 | label { 52 | display: flex; 53 | flex-direction: column; 54 | width: auto; 55 | 56 | input { 57 | display: none; 58 | } 59 | 60 | .mode-img { 61 | width: 8em; 62 | margin-bottom: .5em; 63 | outline: 3px solid @icinga-blue; 64 | } 65 | } 66 | } 67 | } 68 | 69 | .control-group { 70 | align-items: baseline; 71 | } 72 | 73 | #first-handoff-description { 74 | margin: 0 0 0 14em; 75 | } 76 | 77 | .term-input-area.read-only { 78 | label.contact, 79 | label.group { 80 | input + i { 81 | display: block; 82 | } 83 | } 84 | } 85 | } 86 | 87 | /* Style */ 88 | 89 | .rotation-mode { 90 | --img-24-7: url('../img/notifications/pictogram/24-7-dark.jpg'); 91 | --img-partial: url("../img/notifications/pictogram/partial-dark.jpg"); 92 | --img-multi: url("../img/notifications/pictogram/multi-dark.jpg"); 93 | } 94 | 95 | @light-mode: { 96 | .rotation-mode { 97 | --img-24-7: url("../img/notifications/pictogram/24-7-light.jpg"); 98 | --img-partial: url("../img/notifications/pictogram/partial-light.jpg"); 99 | --img-multi: url("../img/notifications/pictogram/multi-light.jpg"); 100 | } 101 | }; 102 | 103 | .rotation-config { 104 | label:not(:hover) { 105 | &.contact input:not(:focus) ~ .icon::before { 106 | content: "\f007"; 107 | } 108 | 109 | &.group input:not(:focus) ~ .icon::before { 110 | content: "\f0c0"; 111 | } 112 | } 113 | 114 | .rotation-mode { 115 | border: 1px solid @gray-light; 116 | .rounded-corners(); 117 | 118 | &.disabled { 119 | background-color: @gray-lighter; 120 | } 121 | 122 | label { 123 | font-weight: bold; 124 | 125 | * { 126 | font-weight: normal; 127 | } 128 | 129 | .example { 130 | font-style: italic; 131 | color: @text-color-light; 132 | } 133 | } 134 | 135 | .mode-img { 136 | background-position: center; 137 | background-repeat: no-repeat; 138 | background-size: contain; 139 | aspect-ratio: 2; 140 | 141 | .rounded-corners(); 142 | 143 | &.img-24-7 { 144 | background-image: var(--img-24-7); 145 | } 146 | 147 | &.img-partial { 148 | background-image: var(--img-partial); 149 | } 150 | 151 | &.img-multi { 152 | background-image: var(--img-multi); 153 | } 154 | } 155 | 156 | input:not(:checked) + .mode-img { 157 | filter: grayscale(100%); 158 | outline: 1px solid @gray-light; 159 | } 160 | } 161 | } 162 | 163 | .source-form textarea { 164 | .monospace-font(); 165 | } 166 | 167 | .channel-form { 168 | .btn-remove:disabled { 169 | background: @gray-light; 170 | color: @disabled-gray; 171 | border-color: transparent; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 { 39 | &.show-more a { 40 | flex: 1; 41 | margin: 1.5em 0; 42 | padding: .5em 0; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/css/mixins.less: -------------------------------------------------------------------------------- 1 | .event-rule-button() { 2 | color: @icinga-blue; 3 | background: @low-sat-blue; 4 | 5 | border: none; 6 | text-align: center; 7 | line-height: 1.5; 8 | display: block; 9 | 10 | &:hover, 11 | &:focus { 12 | background: @low-sat-blue-dark; 13 | color: @icinga-blue; 14 | } 15 | 16 | &:focus { 17 | outline: 3px solid fade(@icinga-blue, 50%); 18 | outline-offset: 1px; 19 | } 20 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/css/schedule.less: -------------------------------------------------------------------------------- 1 | /* Layout */ 2 | 3 | .schedule-detail-controls { 4 | .box-shadow(0, 0, 0, 1px, @gray-lighter); 5 | z-index: 2; // The content may clip, this ensures the separator and dropdown is always visible 6 | padding-bottom: .5em; 7 | 8 | h2 { 9 | display: inline; 10 | } 11 | 12 | > a:last-of-type { 13 | float: right; 14 | } 15 | } 16 | 17 | .schedule-detail { 18 | display: flex; 19 | flex-direction: column; 20 | height: 100%; 21 | 22 | .schedule-header { 23 | display: flex; 24 | margin-bottom: 1em; 25 | justify-content: flex-end; 26 | 27 | .view-mode-switcher { 28 | margin-bottom: 0; 29 | 30 | label { 31 | min-width: 6em; 32 | text-align: center; 33 | } 34 | } 35 | } 36 | } 37 | 38 | .schedule-container { 39 | flex: 1 1 auto; 40 | display: flex; 41 | overflow: auto; 42 | 43 | .calendar { 44 | flex: 1 1 auto; 45 | display: flex; 46 | flex-direction: column; 47 | 48 | .time-grid { 49 | flex: 1 1 auto; 50 | height: 0; 51 | } 52 | } 53 | 54 | .timeline { 55 | flex: 1 1 auto; 56 | } 57 | } 58 | 59 | /* Design */ 60 | 61 | .schedule-detail .entry.highlighted { 62 | outline: 2px solid var(--entry-border-color); 63 | outline-offset: 1px; 64 | } 65 | 66 | .schedule-detail .step.highlighted { 67 | background-color: @gray-lighter; 68 | border-color: @gray-light; 69 | } 70 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/img/icinga-notifications-critical.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/icinga-notifications-critical.webp -------------------------------------------------------------------------------- /public/img/icinga-notifications-ok.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/icinga-notifications-ok.webp -------------------------------------------------------------------------------- /public/img/icinga-notifications-unknown.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/icinga-notifications-unknown.webp -------------------------------------------------------------------------------- /public/img/icinga-notifications-warning.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/icinga-notifications-warning.webp -------------------------------------------------------------------------------- /public/img/pictogram/24-7-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/pictogram/24-7-dark.jpg -------------------------------------------------------------------------------- /public/img/pictogram/24-7-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/pictogram/24-7-light.jpg -------------------------------------------------------------------------------- /public/img/pictogram/multi-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/pictogram/multi-dark.jpg -------------------------------------------------------------------------------- /public/img/pictogram/multi-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/pictogram/multi-light.jpg -------------------------------------------------------------------------------- /public/img/pictogram/partial-dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/pictogram/partial-dark.jpg -------------------------------------------------------------------------------- /public/img/pictogram/partial-light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Icinga/icinga-notifications-web/4efa60e43c8e968e6de5fbe79d05d903d9738d71/public/img/pictogram/partial-light.jpg -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------