summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AUTHORS17
-rw-r--r--CHANGELOG.md42
-rw-r--r--CONTRIBUTING.md253
-rw-r--r--LICENSE339
-rw-r--r--README.md69
-rw-r--r--SECURITY.md9
-rw-r--r--application/clicommands/MigrateCommand.php835
-rw-r--r--application/controllers/CommandTransportController.php154
-rw-r--r--application/controllers/CommentController.php72
-rw-r--r--application/controllers/CommentsController.php197
-rw-r--r--application/controllers/ConfigController.php63
-rw-r--r--application/controllers/DowntimeController.php84
-rw-r--r--application/controllers/DowntimesController.php203
-rw-r--r--application/controllers/ErrorController.php97
-rw-r--r--application/controllers/EventController.php71
-rw-r--r--application/controllers/HealthController.php115
-rw-r--r--application/controllers/HistoryController.php139
-rw-r--r--application/controllers/HostController.php293
-rw-r--r--application/controllers/HostgroupController.php82
-rw-r--r--application/controllers/HostgroupsController.php145
-rw-r--r--application/controllers/HostsController.php242
-rw-r--r--application/controllers/MigrateController.php161
-rw-r--r--application/controllers/NotificationsController.php136
-rw-r--r--application/controllers/ServiceController.php245
-rw-r--r--application/controllers/ServicegroupController.php89
-rw-r--r--application/controllers/ServicegroupsController.php133
-rw-r--r--application/controllers/ServicesController.php436
-rw-r--r--application/controllers/TacticalController.php94
-rw-r--r--application/controllers/UserController.php48
-rw-r--r--application/controllers/UsergroupController.php48
-rw-r--r--application/controllers/UsergroupsController.php95
-rw-r--r--application/controllers/UsersController.php97
-rw-r--r--application/forms/ApiTransportForm.php102
-rw-r--r--application/forms/Command/CommandForm.php179
-rw-r--r--application/forms/Command/Instance/ToggleInstanceFeaturesForm.php154
-rw-r--r--application/forms/Command/Object/AcknowledgeProblemForm.php210
-rw-r--r--application/forms/Command/Object/AddCommentForm.php162
-rw-r--r--application/forms/Command/Object/CheckNowForm.php72
-rw-r--r--application/forms/Command/Object/DeleteCommentForm.php75
-rw-r--r--application/forms/Command/Object/DeleteDowntimeForm.php90
-rw-r--r--application/forms/Command/Object/ProcessCheckResultForm.php156
-rw-r--r--application/forms/Command/Object/RemoveAcknowledgementForm.php77
-rw-r--r--application/forms/Command/Object/ScheduleCheckForm.php137
-rw-r--r--application/forms/Command/Object/ScheduleHostDowntimeForm.php119
-rw-r--r--application/forms/Command/Object/ScheduleServiceDowntimeForm.php267
-rw-r--r--application/forms/Command/Object/SendCustomNotificationForm.php125
-rw-r--r--application/forms/Command/Object/ToggleObjectFeaturesForm.php186
-rw-r--r--application/forms/DatabaseConfigForm.php33
-rw-r--r--application/forms/Navigation/ActionForm.php58
-rw-r--r--application/forms/Navigation/IcingadbHostActionForm.php10
-rw-r--r--application/forms/Navigation/IcingadbServiceActionForm.php10
-rw-r--r--application/forms/RedisConfigForm.php606
-rw-r--r--application/forms/SetAsBackendForm.php34
-rw-r--r--application/views/scripts/joystickPagination-icingadb.phtml162
-rw-r--r--application/views/scripts/services/grid-flipped.phtml149
-rw-r--r--application/views/scripts/services/grid.phtml150
-rw-r--r--configuration.php570
-rw-r--r--doc/01-About.md64
-rw-r--r--doc/02-Installation.md28
-rw-r--r--doc/02-Installation.md.d/From-Source.md17
-rw-r--r--doc/03-Configuration.md67
-rw-r--r--doc/04-Security.md142
-rw-r--r--doc/05-Upgrading.md37
-rw-r--r--doc/09-Automation.md233
-rw-r--r--doc/10-Migration.md160
-rw-r--r--doc/11-Concepts.md78
-rw-r--r--doc/res/CheckStatisticsAnatomy.jpgbin0 -> 80613 bytes
-rw-r--r--doc/res/ListAnatomyOverdue.jpgbin0 -> 180572 bytes
-rw-r--r--doc/res/ListItemAnatomy.jpgbin0 -> 56773 bytes
-rw-r--r--doc/res/ListItemAnatomyCompact.jpgbin0 -> 44243 bytes
-rw-r--r--doc/res/ListItemAnatomyDetailed.jpgbin0 -> 65700 bytes
-rw-r--r--doc/res/ListItemDowntimeAnatomy.jpgbin0 -> 63975 bytes
-rw-r--r--doc/res/ModalAnatomy.jpgbin0 -> 100034 bytes
-rw-r--r--doc/res/continue-with-preview.pngbin0 -> 76099 bytes
-rw-r--r--doc/res/icingadb-architecture.pngbin0 -> 563761 bytes
-rw-r--r--doc/res/icingadb-dashboard.pngbin0 -> 246028 bytes
-rw-r--r--doc/res/icingadb-web.pngbin0 -> 532529 bytes
-rw-r--r--doc/res/modal-dialog-preview.pngbin0 -> 71148 bytes
-rw-r--r--doc/res/searchbar-completion-preview.pngbin0 -> 63731 bytes
-rw-r--r--doc/res/service-detail-preview.pngbin0 -> 154748 bytes
-rw-r--r--doc/res/view-switcher-preview.pngbin0 -> 103252 bytes
-rw-r--r--library/Icingadb/Authentication/ObjectAuthorization.php261
-rw-r--r--library/Icingadb/Command/IcingaApiCommand.php128
-rw-r--r--library/Icingadb/Command/IcingaCommand.php22
-rw-r--r--library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php109
-rw-r--r--library/Icingadb/Command/Object/AcknowledgeProblemCommand.php140
-rw-r--r--library/Icingadb/Command/Object/AddCommentCommand.php42
-rw-r--r--library/Icingadb/Command/Object/CommandAuthor.php45
-rw-r--r--library/Icingadb/Command/Object/DeleteCommentCommand.php13
-rw-r--r--library/Icingadb/Command/Object/DeleteDowntimeCommand.php13
-rw-r--r--library/Icingadb/Command/Object/GetObjectCommand.php35
-rw-r--r--library/Icingadb/Command/Object/ObjectsCommand.php67
-rw-r--r--library/Icingadb/Command/Object/ProcessCheckResultCommand.php140
-rw-r--r--library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php42
-rw-r--r--library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php13
-rw-r--r--library/Icingadb/Command/Object/ScheduleCheckCommand.php86
-rw-r--r--library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php42
-rw-r--r--library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php196
-rw-r--r--library/Icingadb/Command/Object/SendCustomNotificationCommand.php44
-rw-r--r--library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php108
-rw-r--r--library/Icingadb/Command/Object/WithCommentCommand.php50
-rw-r--r--library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php353
-rw-r--r--library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php12
-rw-r--r--library/Icingadb/Command/Transport/ApiCommandException.php14
-rw-r--r--library/Icingadb/Command/Transport/ApiCommandTransport.php353
-rw-r--r--library/Icingadb/Command/Transport/CommandTransport.php130
-rw-r--r--library/Icingadb/Command/Transport/CommandTransportConfig.php31
-rw-r--r--library/Icingadb/Command/Transport/CommandTransportException.php14
-rw-r--r--library/Icingadb/Command/Transport/CommandTransportInterface.php23
-rw-r--r--library/Icingadb/Common/Auth.php389
-rw-r--r--library/Icingadb/Common/BaseFilter.php13
-rw-r--r--library/Icingadb/Common/BaseStatusBar.php55
-rw-r--r--library/Icingadb/Common/CaptionDisabled.php31
-rw-r--r--library/Icingadb/Common/CommandActions.php308
-rw-r--r--library/Icingadb/Common/Database.php102
-rw-r--r--library/Icingadb/Common/DetailActions.php145
-rw-r--r--library/Icingadb/Common/HostLink.php27
-rw-r--r--library/Icingadb/Common/HostLinks.php76
-rw-r--r--library/Icingadb/Common/HostStates.php107
-rw-r--r--library/Icingadb/Common/IcingaRedis.php323
-rw-r--r--library/Icingadb/Common/Icons.php30
-rw-r--r--library/Icingadb/Common/Links.php143
-rw-r--r--library/Icingadb/Common/ListItemCommonLayout.php26
-rw-r--r--library/Icingadb/Common/ListItemDetailedLayout.php23
-rw-r--r--library/Icingadb/Common/ListItemMinimalLayout.php26
-rw-r--r--library/Icingadb/Common/LoadMore.php111
-rw-r--r--library/Icingadb/Common/Macros.php120
-rw-r--r--library/Icingadb/Common/NoSubjectLink.php35
-rw-r--r--library/Icingadb/Common/ObjectInspectionDetail.php348
-rw-r--r--library/Icingadb/Common/ObjectLinkDisabled.php35
-rw-r--r--library/Icingadb/Common/SearchControls.php69
-rw-r--r--library/Icingadb/Common/ServiceLink.php40
-rw-r--r--library/Icingadb/Common/ServiceLinks.php108
-rw-r--r--library/Icingadb/Common/ServiceStates.php129
-rw-r--r--library/Icingadb/Common/StateBadges.php195
-rw-r--r--library/Icingadb/Common/TicketLinks.php56
-rw-r--r--library/Icingadb/Common/ViewMode.php35
-rw-r--r--library/Icingadb/Compat/CompatHost.php103
-rw-r--r--library/Icingadb/Compat/CompatObject.php373
-rw-r--r--library/Icingadb/Compat/CompatService.php156
-rw-r--r--library/Icingadb/Compat/UrlMigrator.php1353
-rw-r--r--library/Icingadb/Data/CsvResultSet.php85
-rw-r--r--library/Icingadb/Data/JsonResultSet.php80
-rw-r--r--library/Icingadb/Data/PivotTable.php441
-rw-r--r--library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php83
-rw-r--r--library/Icingadb/Hook/Common/HookUtils.php39
-rw-r--r--library/Icingadb/Hook/Common/TotalSlaReportUtils.php56
-rw-r--r--library/Icingadb/Hook/CustomVarRendererHook.php102
-rw-r--r--library/Icingadb/Hook/EventDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php146
-rw-r--r--library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php121
-rw-r--r--library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php101
-rw-r--r--library/Icingadb/Hook/HostActionsHook.php21
-rw-r--r--library/Icingadb/Hook/HostDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Hook/HostsDetailExtensionHook.php28
-rw-r--r--library/Icingadb/Hook/IcingadbSupportHook.php52
-rw-r--r--library/Icingadb/Hook/PluginOutputHook.php63
-rw-r--r--library/Icingadb/Hook/ServiceActionsHook.php21
-rw-r--r--library/Icingadb/Hook/ServiceDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Hook/ServicesDetailExtensionHook.php28
-rw-r--r--library/Icingadb/Hook/TabHook.php82
-rw-r--r--library/Icingadb/Hook/TabHook/HookActions.php148
-rw-r--r--library/Icingadb/Hook/UserDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Hook/UsergroupDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Model/AcknowledgementHistory.php102
-rw-r--r--library/Icingadb/Model/ActionUrl.php62
-rw-r--r--library/Icingadb/Model/Behavior/ActionAndNoteUrl.php52
-rw-r--r--library/Icingadb/Model/Behavior/Bitmask.php83
-rw-r--r--library/Icingadb/Model/Behavior/BoolCast.php31
-rw-r--r--library/Icingadb/Model/Behavior/FlattenedObjectVars.php77
-rw-r--r--library/Icingadb/Model/Behavior/ReRoute.php83
-rw-r--r--library/Icingadb/Model/Checkcommand.php86
-rw-r--r--library/Icingadb/Model/CheckcommandArgument.php75
-rw-r--r--library/Icingadb/Model/CheckcommandCustomvar.php52
-rw-r--r--library/Icingadb/Model/CheckcommandEnvvar.php61
-rw-r--r--library/Icingadb/Model/Comment.php114
-rw-r--r--library/Icingadb/Model/CommentHistory.php107
-rw-r--r--library/Icingadb/Model/Customvar.php72
-rw-r--r--library/Icingadb/Model/CustomvarFlat.php167
-rw-r--r--library/Icingadb/Model/Downtime.php144
-rw-r--r--library/Icingadb/Model/DowntimeHistory.php128
-rw-r--r--library/Icingadb/Model/Endpoint.php69
-rw-r--r--library/Icingadb/Model/Environment.php105
-rw-r--r--library/Icingadb/Model/Eventcommand.php86
-rw-r--r--library/Icingadb/Model/EventcommandArgument.php75
-rw-r--r--library/Icingadb/Model/EventcommandCustomvar.php52
-rw-r--r--library/Icingadb/Model/EventcommandEnvvar.php61
-rw-r--r--library/Icingadb/Model/FlappingHistory.php91
-rw-r--r--library/Icingadb/Model/History.php127
-rw-r--r--library/Icingadb/Model/Host.php234
-rw-r--r--library/Icingadb/Model/HostCustomvar.php52
-rw-r--r--library/Icingadb/Model/HostState.php81
-rw-r--r--library/Icingadb/Model/Hostgroup.php92
-rw-r--r--library/Icingadb/Model/HostgroupCustomvar.php52
-rw-r--r--library/Icingadb/Model/HostgroupMember.php53
-rw-r--r--library/Icingadb/Model/Hostgroupsummary.php213
-rw-r--r--library/Icingadb/Model/HoststateSummary.php78
-rw-r--r--library/Icingadb/Model/IconImage.php55
-rw-r--r--library/Icingadb/Model/Instance.php78
-rw-r--r--library/Icingadb/Model/LastHostComment.php19
-rw-r--r--library/Icingadb/Model/LastServiceComment.php19
-rw-r--r--library/Icingadb/Model/NotesUrl.php62
-rw-r--r--library/Icingadb/Model/Notification.php130
-rw-r--r--library/Icingadb/Model/NotificationCustomvar.php52
-rw-r--r--library/Icingadb/Model/NotificationHistory.php115
-rw-r--r--library/Icingadb/Model/NotificationUser.php49
-rw-r--r--library/Icingadb/Model/NotificationUsergroup.php49
-rw-r--r--library/Icingadb/Model/Notificationcommand.php87
-rw-r--r--library/Icingadb/Model/NotificationcommandArgument.php75
-rw-r--r--library/Icingadb/Model/NotificationcommandCustomvar.php52
-rw-r--r--library/Icingadb/Model/NotificationcommandEnvvar.php61
-rw-r--r--library/Icingadb/Model/Service.php225
-rw-r--r--library/Icingadb/Model/ServiceCustomvar.php52
-rw-r--r--library/Icingadb/Model/ServiceState.php76
-rw-r--r--library/Icingadb/Model/Servicegroup.php91
-rw-r--r--library/Icingadb/Model/ServicegroupCustomvar.php52
-rw-r--r--library/Icingadb/Model/ServicegroupMember.php49
-rw-r--r--library/Icingadb/Model/ServicegroupSummary.php167
-rw-r--r--library/Icingadb/Model/ServicestateSummary.php99
-rw-r--r--library/Icingadb/Model/State.php177
-rw-r--r--library/Icingadb/Model/StateHistory.php101
-rw-r--r--library/Icingadb/Model/Timeperiod.php91
-rw-r--r--library/Icingadb/Model/TimeperiodCustomvar.php52
-rw-r--r--library/Icingadb/Model/TimeperiodOverrideExclude.php51
-rw-r--r--library/Icingadb/Model/TimeperiodOverrideInclude.php51
-rw-r--r--library/Icingadb/Model/TimeperiodRange.php58
-rw-r--r--library/Icingadb/Model/User.php134
-rw-r--r--library/Icingadb/Model/UserCustomvar.php52
-rw-r--r--library/Icingadb/Model/Usergroup.php95
-rw-r--r--library/Icingadb/Model/UsergroupCustomvar.php52
-rw-r--r--library/Icingadb/Model/UsergroupMember.php49
-rw-r--r--library/Icingadb/Model/Vars.php28
-rw-r--r--library/Icingadb/Model/Zone.php82
-rw-r--r--library/Icingadb/ProvidedHook/ApplicationState.php111
-rw-r--r--library/Icingadb/ProvidedHook/CreateHostSlaReport.php37
-rw-r--r--library/Icingadb/ProvidedHook/CreateHostsSlaReport.php39
-rw-r--r--library/Icingadb/ProvidedHook/CreateServiceSlaReport.php40
-rw-r--r--library/Icingadb/ProvidedHook/CreateServicesSlaReport.php38
-rw-r--r--library/Icingadb/ProvidedHook/IcingaHealth.php115
-rw-r--r--library/Icingadb/ProvidedHook/RedisHealth.php55
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php68
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php72
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/SlaReport.php297
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php19
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php19
-rw-r--r--library/Icingadb/ProvidedHook/X509/Sni.php55
-rw-r--r--library/Icingadb/Redis/VolatileStateResults.php170
-rw-r--r--library/Icingadb/Setup/ApiTransportPage.php128
-rw-r--r--library/Icingadb/Setup/ApiTransportStep.php102
-rw-r--r--library/Icingadb/Setup/DbResourcePage.php145
-rw-r--r--library/Icingadb/Setup/DbResourceStep.php148
-rw-r--r--library/Icingadb/Setup/IcingaDbWizard.php89
-rw-r--r--library/Icingadb/Setup/RedisPage.php68
-rw-r--r--library/Icingadb/Setup/RedisStep.php205
-rw-r--r--library/Icingadb/Setup/WelcomePage.php56
-rw-r--r--library/Icingadb/Util/FeatureStatus.php50
-rw-r--r--library/Icingadb/Util/ObjectSuggestionsCursor.php25
-rw-r--r--library/Icingadb/Util/PerfData.php703
-rw-r--r--library/Icingadb/Util/PerfDataFormat.php171
-rw-r--r--library/Icingadb/Util/PerfDataSet.php172
-rw-r--r--library/Icingadb/Util/PluginOutput.php260
-rw-r--r--library/Icingadb/Util/ThresholdRange.php213
-rw-r--r--library/Icingadb/Web/Control/GridViewModeSwitcher.php38
-rw-r--r--library/Icingadb/Web/Control/ProblemToggle.php74
-rw-r--r--library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php406
-rw-r--r--library/Icingadb/Web/Control/ViewModeSwitcher.php219
-rw-r--r--library/Icingadb/Web/Controller.php542
-rw-r--r--library/Icingadb/Web/Navigation/Action.php134
-rw-r--r--library/Icingadb/Web/Navigation/IcingadbHostAction.php9
-rw-r--r--library/Icingadb/Web/Navigation/IcingadbServiceAction.php9
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php35
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php173
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php36
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php66
-rw-r--r--library/Icingadb/Widget/AttemptBall.php31
-rw-r--r--library/Icingadb/Widget/CheckAttempt.php54
-rw-r--r--library/Icingadb/Widget/Detail/CheckStatistics.php373
-rw-r--r--library/Icingadb/Widget/Detail/CommentDetail.php140
-rw-r--r--library/Icingadb/Widget/Detail/CustomVarTable.php268
-rw-r--r--library/Icingadb/Widget/Detail/DowntimeCard.php258
-rw-r--r--library/Icingadb/Widget/Detail/DowntimeDetail.php206
-rw-r--r--library/Icingadb/Widget/Detail/EventDetail.php651
-rw-r--r--library/Icingadb/Widget/Detail/HostDetail.php58
-rw-r--r--library/Icingadb/Widget/Detail/HostInspectionDetail.php21
-rw-r--r--library/Icingadb/Widget/Detail/HostMetaInfo.php77
-rw-r--r--library/Icingadb/Widget/Detail/HostStatistics.php61
-rw-r--r--library/Icingadb/Widget/Detail/MultiselectQuickActions.php194
-rw-r--r--library/Icingadb/Widget/Detail/ObjectDetail.php596
-rw-r--r--library/Icingadb/Widget/Detail/ObjectStatistics.php59
-rw-r--r--library/Icingadb/Widget/Detail/ObjectsDetail.php190
-rw-r--r--library/Icingadb/Widget/Detail/PerfDataTable.php130
-rw-r--r--library/Icingadb/Widget/Detail/QuickActions.php148
-rw-r--r--library/Icingadb/Widget/Detail/ServiceDetail.php37
-rw-r--r--library/Icingadb/Widget/Detail/ServiceInspectionDetail.php21
-rw-r--r--library/Icingadb/Widget/Detail/ServiceMetaInfo.php61
-rw-r--r--library/Icingadb/Widget/Detail/ServiceStatistics.php64
-rw-r--r--library/Icingadb/Widget/Detail/UserDetail.php188
-rw-r--r--library/Icingadb/Widget/Detail/UsergroupDetail.php98
-rw-r--r--library/Icingadb/Widget/Health.php66
-rw-r--r--library/Icingadb/Widget/HostStateBadges.php45
-rw-r--r--library/Icingadb/Widget/HostStatusBar.php21
-rw-r--r--library/Icingadb/Widget/HostSummaryDonut.php78
-rw-r--r--library/Icingadb/Widget/IconImage.php74
-rw-r--r--library/Icingadb/Widget/ItemList/BaseCommentListItem.php131
-rw-r--r--library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php212
-rw-r--r--library/Icingadb/Widget/ItemList/BaseHistoryListItem.php405
-rw-r--r--library/Icingadb/Widget/ItemList/BaseHostListItem.php56
-rw-r--r--library/Icingadb/Widget/ItemList/BaseNotificationListItem.php189
-rw-r--r--library/Icingadb/Widget/ItemList/BaseServiceListItem.php70
-rw-r--r--library/Icingadb/Widget/ItemList/CommandTransportList.php26
-rw-r--r--library/Icingadb/Widget/ItemList/CommandTransportListItem.php71
-rw-r--r--library/Icingadb/Widget/ItemList/CommentList.php49
-rw-r--r--library/Icingadb/Widget/ItemList/CommentListItem.php12
-rw-r--r--library/Icingadb/Widget/ItemList/CommentListItemMinimal.php21
-rw-r--r--library/Icingadb/Widget/ItemList/DowntimeList.php49
-rw-r--r--library/Icingadb/Widget/ItemList/DowntimeListItem.php23
-rw-r--r--library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php21
-rw-r--r--library/Icingadb/Widget/ItemList/HistoryList.php57
-rw-r--r--library/Icingadb/Widget/ItemList/HistoryListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php18
-rw-r--r--library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php27
-rw-r--r--library/Icingadb/Widget/ItemList/HostDetailHeader.php67
-rw-r--r--library/Icingadb/Widget/ItemList/HostList.php39
-rw-r--r--library/Icingadb/Widget/ItemList/HostListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/HostListItemDetailed.php108
-rw-r--r--library/Icingadb/Widget/ItemList/HostListItemMinimal.php18
-rw-r--r--library/Icingadb/Widget/ItemList/NotificationList.php55
-rw-r--r--library/Icingadb/Widget/ItemList/NotificationListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php18
-rw-r--r--library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php27
-rw-r--r--library/Icingadb/Widget/ItemList/PageSeparatorItem.php36
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceDetailHeader.php67
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceList.php36
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php112
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php18
-rw-r--r--library/Icingadb/Widget/ItemList/StateList.php60
-rw-r--r--library/Icingadb/Widget/ItemList/StateListItem.php140
-rw-r--r--library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php60
-rw-r--r--library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php60
-rw-r--r--library/Icingadb/Widget/ItemTable/BaseStateRowItem.php107
-rw-r--r--library/Icingadb/Widget/ItemTable/GridCellLayout.php39
-rw-r--r--library/Icingadb/Widget/ItemTable/HostItemTable.php31
-rw-r--r--library/Icingadb/Widget/ItemTable/HostRowItem.php51
-rw-r--r--library/Icingadb/Widget/ItemTable/HostgroupGridCell.php114
-rw-r--r--library/Icingadb/Widget/ItemTable/HostgroupTable.php38
-rw-r--r--library/Icingadb/Widget/ItemTable/HostgroupTableRow.php55
-rw-r--r--library/Icingadb/Widget/ItemTable/ServiceItemTable.php31
-rw-r--r--library/Icingadb/Widget/ItemTable/ServiceRowItem.php64
-rw-r--r--library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php204
-rw-r--r--library/Icingadb/Widget/ItemTable/ServicegroupTable.php38
-rw-r--r--library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php42
-rw-r--r--library/Icingadb/Widget/ItemTable/StateItemTable.php216
-rw-r--r--library/Icingadb/Widget/ItemTable/StateRowItem.php124
-rw-r--r--library/Icingadb/Widget/ItemTable/TableRowLayout.php26
-rw-r--r--library/Icingadb/Widget/ItemTable/UserTable.php27
-rw-r--r--library/Icingadb/Widget/ItemTable/UserTableRow.php61
-rw-r--r--library/Icingadb/Widget/ItemTable/UsergroupTable.php27
-rw-r--r--library/Icingadb/Widget/ItemTable/UsergroupTableRow.php61
-rw-r--r--library/Icingadb/Widget/MarkdownLine.php28
-rw-r--r--library/Icingadb/Widget/MarkdownText.php28
-rw-r--r--library/Icingadb/Widget/Notice.php36
-rw-r--r--library/Icingadb/Widget/PluginOutputContainer.php22
-rw-r--r--library/Icingadb/Widget/ServiceStateBadges.php46
-rw-r--r--library/Icingadb/Widget/ServiceStatusBar.php24
-rw-r--r--library/Icingadb/Widget/ServiceSummaryDonut.php81
-rw-r--r--library/Icingadb/Widget/ShowMore.php64
-rw-r--r--library/Icingadb/Widget/StateBadge.php10
-rw-r--r--library/Icingadb/Widget/StateChange.php133
-rw-r--r--library/Icingadb/Widget/TagList.php35
-rw-r--r--module.info6
-rw-r--r--phpstan-baseline.neon8346
-rw-r--r--phpstan.neon28
-rw-r--r--phpunit.xml16
-rw-r--r--public/css/common.less405
-rw-r--r--public/css/form/schedule-service-downtime-form.less21
-rw-r--r--public/css/list/action-list.less14
-rw-r--r--public/css/list/comment-list.less50
-rw-r--r--public/css/list/downtime-list.less93
-rw-r--r--public/css/list/item-list.less154
-rw-r--r--public/css/list/list-item.less77
-rw-r--r--public/css/list/state-item-table.less201
-rw-r--r--public/css/list/state-row-item.less42
-rw-r--r--public/css/list/user-list.less26
-rw-r--r--public/css/markdown.less80
-rw-r--r--public/css/mixin/progress-bar.less217
-rw-r--r--public/css/mixin/state-badges.less31
-rw-r--r--public/css/mixins.less5
-rw-r--r--public/css/view/service-grid.less60
-rw-r--r--public/css/widget/actions.less20
-rw-r--r--public/css/widget/check-attempt.less17
-rw-r--r--public/css/widget/check-statistics.less192
-rw-r--r--public/css/widget/comment-popup.less74
-rw-r--r--public/css/widget/custom-var-table.less60
-rw-r--r--public/css/widget/donut-container.less24
-rw-r--r--public/css/widget/downtime-card.less10
-rw-r--r--public/css/widget/group-grid.less42
-rw-r--r--public/css/widget/host-state-badges.less3
-rw-r--r--public/css/widget/key-value-list.less19
-rw-r--r--public/css/widget/migrate-popup.less181
-rw-r--r--public/css/widget/monitoring-health.less136
-rw-r--r--public/css/widget/notice.less23
-rw-r--r--public/css/widget/object-features.less53
-rw-r--r--public/css/widget/object-inspection.less17
-rw-r--r--public/css/widget/object-meta-info.less95
-rw-r--r--public/css/widget/object-statistics.less44
-rw-r--r--public/css/widget/performance-data-table.less62
-rw-r--r--public/css/widget/quick-actions.less47
-rw-r--r--public/css/widget/service-state-badges.less3
-rw-r--r--public/css/widget/state-change.less128
-rw-r--r--public/css/widget/table-layout.less72
-rw-r--r--public/css/widget/tag-list.less31
-rw-r--r--public/css/widget/view-mode-switcher.less45
-rw-r--r--public/js/action-list.js788
-rw-r--r--public/js/migrate.js654
-rw-r--r--public/js/progress-bar.js110
-rw-r--r--run.php43
-rw-r--r--test/php/Lib/PerfdataSetWithPublicData.php12
-rw-r--r--test/php/application/clicommands/MigrateCommandTest.php1727
-rw-r--r--test/php/library/Icingadb/Common/MacrosTest.php174
-rw-r--r--test/php/library/Icingadb/Common/StateBadgesTest.php86
-rw-r--r--test/php/library/Icingadb/Model/CustomvarFlatTest.php121
-rw-r--r--test/php/library/Icingadb/Util/PerfdataSetTest.php120
-rw-r--r--test/php/library/Icingadb/Util/PerfdataTest.php591
-rw-r--r--test/php/library/Icingadb/Util/ThresholdRangeTest.php343
425 files changed, 55055 insertions, 0 deletions
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..e6cc230
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1,17 @@
+Alexander A. Klimov <alexander.klimov@icinga.com>
+Eric Lippmann <eric.lippmann@icinga.com>
+Feu Mourek <feu.mourek@icinga.com>
+Florian Strohmaier <florian.strohmaier@icinga.com>
+Johannes Meyer <johannes.meyer@icinga.com>
+Jonada Hoxha <jonada.hoxha@icinga.com>
+Loei Petrus Marogi <LoeiPetrus.Marogi@netways.de>
+Marius Hein <marius.hein@icinga.com>
+Noah Hilverling <noah.hilverling@icinga.com>
+Patrick Dolinic <patrick.dolinic@netways.de>
+Ravi Kumar Kempapura Srinivasa <ravi.srinivasa@icinga.com>
+stupiddr <natedawg@prolinepaintball.net>
+Sukhwinder Dhillon <sukhwinder.dhillon@icinga.com>
+Timm Ortloff <timm.ortloff@icinga.com>
+Tobias Tiederle <ttiederle@fimltd.org>
+VerboEse <verboEse@users.noreply.github.com>
+Yonas Habteab <yonas.habteab@icinga.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6ee118f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,42 @@
+# Icinga DB Web Changelog
+
+Please make sure to always read our [Upgrading](https://icinga.com/docs/icinga-db-web/latest/doc/05-Upgrading/)
+documentation before switching to a new version.
+
+## 1.1.1 (2023-11-15)
+
+Included changes can be found on the milestone: https://github.com/Icinga/icingadb-web/milestone/5?closed=1
+And a detailed description about the most important ones on our blog: https://icinga.com/blog/2023/11/16/releasing-icinga-db-web-v1-1-1-and-icinga-web-2-12-1/
+
+## 1.1.0 (2023-09-28)
+
+Included changes can be found on the milestone: https://github.com/Icinga/icingadb-web/milestone/2?closed=1
+And a detailed description about the most important ones on our blog: https://icinga.com/blog/2023/09/28/releasing-icinga-db-web-v1-1/
+
+## 1.0.2 (2022-11-04)
+
+You can find all issues related to this release on the respective [Milestone](https://github.com/Icinga/icingadb-web/milestone/4?closed=1).
+
+Notable fixes in this release are that the *GenericTTS* module is now supported and that the legacy integration
+of modules with no official support for Icinga DB Web is working again, even if the *monitoring* module is disabled.
+Action and Note URLs, which disappeared with v1.0.1, are also visible again.
+
+Some enhancements also found their way in this release. They include improved compatibility with Icinga DB's
+asynchronous behavior and its migration tool included in the v1.1 release.
+
+## 1.0.1 (2022-09-08)
+
+Here are Fixes: https://github.com/Icinga/icingadb-web/milestone/3?closed=1
+Here someone blogged about them: https://icinga.com/blog/2022/09/08/releasing-icinga-db-web-v1-0-1/
+
+## 1.0.0 (2022-06-30)
+
+First stable release
+
+## 1.0.0 RC2 (2021-11-12)
+
+Second release candidate
+
+## 1.0.0 RC1 (2020-03-13)
+
+Initial release
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..3598597
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,253 @@
+# Contributing
+
+Icinga is an open source project and lives from your ideas and contributions.
+
+There are many ways to contribute, from improving the documentation, submitting
+bug reports and features requests or writing code to add enhancements or fix bugs.
+
+#### Table of Contents
+
+1. [Introduction](#introduction)
+2. [Fork the Project](#fork-the-project)
+3. [Branches](#branches)
+4. [Commits](#commits)
+5. [Pull Requests](#pull-requests)
+6. [Testing](#testing)
+7. [Source Code Patches](#source-code-patches)
+8. [Documentation Patches](#documentation-patches)
+
+## Introduction
+
+Please consider our [roadmap](https://github.com/Icinga/icingadb-web/milestones) and
+[open issues](https://github.com/icinga/icingadb-web/issues) when you start contributing
+to the project.
+
+Before starting your work on Icinga DB Web, you should [fork the project](https://help.github.com/articles/fork-a-repo/)
+to your GitHub account. This allows you to freely experiment with your changes.
+When your changes are complete, submit a [pull request](https://help.github.com/articles/using-pull-requests/).
+All pull requests will be reviewed and merged if they suit some general guidelines:
+
+* Changes are located in a topic branch
+* For new functionality, proper tests are written
+* Changes should follow the existing coding style and standards
+
+Please continue reading in the following sections for a step by step guide.
+
+## Fork the Project
+
+[Fork the project](https://help.github.com/articles/fork-a-repo/) to your GitHub account
+and clone the repository:
+
+```
+git clone git@github.com:jdoe/icingadb-web.git
+cd icingadb-web
+```
+
+Add a new remote `upstream` with this repository as value.
+
+```
+git remote add upstream https://github.com/icinga/icingadb-web.git
+```
+
+You can pull updates to your fork's default branch:
+
+```
+git fetch --all
+git pull upstream HEAD
+```
+
+Please continue to learn about [branches](#branches).
+
+## Branches
+
+Choosing a proper name for a branch helps us identify its purpose and possibly
+find an associated bug or feature.
+Generally a branch name should include a topic such as `fix` or `feature` followed
+by a description and an issue number if applicable. Branches should have only changes
+relevant to a specific issue.
+
+```
+git checkout -b fix/service-template-typo-1234
+git checkout -b feature/config-handling-1235
+```
+
+Continue to apply your changes and test them. More details on specific changes:
+
+* [Source Code Patches](#source-code-patches)
+* [Documentation Patches](#documentation-patches)
+
+## Commits
+
+Once you've finished your work in a branch, please ensure to commit
+your changes. A good commit message includes a short topic, additional body
+and a reference to the issue you wish to solve (if existing).
+
+Fixes:
+
+```
+Fix missing style in detail view
+
+refs #4567
+```
+
+Features:
+
+```
+Add DateTime picker
+
+refs #1234
+```
+
+You can add multiple commits during your journey to finish your patch.
+Don't worry, you can squash those changes into a single commit later on.
+
+## Pull Requests
+
+Once you've committed your changes, please update your local default
+branch and rebase your fix/feature branch against it before submitting a PR.
+
+```
+git checkout main
+git pull upstream HEAD
+
+git checkout fix/style-detail-view-5678
+git rebase main
+```
+
+Once you've resolved any conflicts, push the branch to your remote repository.
+It might be necessary to force push after rebasing - use with care!
+
+New branch:
+```
+git push --set-upstream origin fix/style-detail-view-5678
+```
+
+Existing branch:
+```
+git push -f origin fix/style-detail-view-5678
+```
+
+You can now either use the [hub](https://hub.github.com) CLI tool to create a PR, or navigate
+to your GitHub repository and create a PR there.
+
+The pull request should again contain a telling subject and a reference
+with `fixes` to an existing issue id if any. That allows developers
+to automatically resolve the issues once your PR gets merged.
+
+```
+hub pull-request
+
+<a telling subject>
+
+fixes #1234
+```
+
+Thanks a lot for your contribution!
+
+
+### Rebase a Branch
+
+If you accidentally sent in a PR which was not rebased against the upstream default branch,
+developers might ask you to rebase your PR.
+
+First off, fetch and pull the default branch.
+
+```
+git checkout main
+git fetch --all
+git pull upstream HEAD
+```
+
+Then change to your working branch and start rebasing it against the default:
+
+```
+git checkout fix/style-detail-view-5678
+git rebase main
+```
+
+If you are running into a conflict, rebase will stop and ask you to fix the problems.
+
+```
+git status
+
+ both modified: path/to/conflict.php
+```
+
+Edit the file and search for `>>>`. Fix, build, test and save as needed.
+
+Add the modified file(s) and continue rebasing.
+
+```
+git add path/to/conflict.php
+git rebase --continue
+```
+
+Once succeeded ensure to push your changed history remotely.
+
+```
+git push -f origin fix/style-detail-view-5678
+```
+
+
+If you fear to break things, do the rebase in a backup branch first and later replace your current branch.
+
+```
+git checkout fix/style-detail-view-5678
+git checkout -b fix/style-detail-view-5678-rebase
+
+git rebase main
+
+git branch -D fix/style-detail-view-5678
+git checkout -b fix/style-detail-view-5678
+
+git push -f origin fix/style-detail-view-5678
+```
+
+### Squash Commits
+
+> **Note:**
+>
+> Be careful with squashing. This might lead to non-recoverable mistakes.
+>
+> This is for advanced Git users.
+
+Say you want to squash the last 3 commits in your branch into a single one.
+
+Start an interactive (`-i`) rebase from current HEAD minus three commits (`HEAD~3`).
+
+```
+git rebase -i HEAD~3
+```
+
+Git opens your preferred editor. `pick` the commit in the first line, change `pick` to `squash` on the other lines.
+
+```
+pick e4bf04e47 Fix style detail view
+squash d7b939d99 Tests
+squash b37fd5377 Doc updates
+```
+
+Save and let rebase to its job. Then force push the changes to the remote origin.
+
+```
+git push -f origin fix/style-detail-view-5678
+```
+
+
+## Testing
+
+TBD
+
+## Source Code Patches
+
+Icinga DB Web is written in PHP, LESS and JavaScript.
+
+## Documentation Patches
+
+The documentation is written in GitHub flavored [Markdown](https://guides.github.com/features/mastering-markdown/).
+It is located in the `doc/` directory and can be edited with your preferred editor. You can also
+edit it online on GitHub.
+
+```
+vim doc/02-Installation.md
+```
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d159169
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2915cc9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,69 @@
+# Icinga DB Web
+
+[![PHP Support](https://img.shields.io/badge/php-%3E%3D%207.2-777BB4?logo=PHP)](https://php.net/)
+![Build Status](https://github.com/Icinga/icingadb-web/actions/workflows/php.yml/badge.svg?branch=main)
+[![Github Tag](https://img.shields.io/github/tag/Icinga/icingadb-web.svg)](https://github.com/Icinga/icingadb-web/releases/latest)
+
+Icinga DB is a set of components for publishing, synchronizing and
+visualizing monitoring data in the Icinga ecosystem, consisting of:
+
+* Icinga DB Web which connects to both a Redis server and a database to view and work with
+ most up-to-date monitoring data
+* Icinga 2 with its [Icinga DB feature](https://icinga.com/docs/icinga-2/latest/doc/14-features/#icinga-db) enabled,
+ responsible for publishing the data to the Redis server, i.e. configuration and its runtime updates, check results, state changes,
+ downtimes, acknowledgements, notifications, and other events such as flapping
+* And the [Icinga DB daemon](https://icinga.com/docs/icinga-db),
+ which synchronizes the data between the Redis server and the database
+
+![Icinga DB Architecture](doc/res/icingadb-architecture.png)
+
+## Documentation
+
+Icinga DB Web documentation is available at [icinga.com/docs](https://icinga.com/docs/icinga-db-web/latest/).
+
+## Features
+
+Icinga DB Web offers a modern and streamlined design to provide a clear and
+concise view of your monitoring environment, also with dark and light mode support.
+
+![Icinga DB Dashboard](doc/res/icingadb-dashboard.png)
+
+### Various List Layouts
+
+The view switcher allows to control the level of detail displayed in host and service list views:
+
+![View Switcher Preview](doc/res/view-switcher-preview.png)
+
+### Search with Autocomplete
+
+The search bar in list views can be used for everything from simple searches to creating complex filters.
+It allows full keyboard control and also supports contextual auto-completion.
+In addition, there is an editor for easier filter creation.
+
+![Searchbar Completion Preview](doc/res/searchbar-completion-preview.png)
+
+### Clean Detail Views
+
+Host and service detail views are structured to make best use of available space.
+Related information is grouped and important information is at the top for instant access without having to scroll down.
+
+![Service Detail Preview](doc/res/service-detail-preview.png)
+
+### Modal Dialogs
+
+Any interaction that requires user input, such as acknowledging problems, scheduling downtimes, etc.,
+shows a modal dialog over the current view to preserve context and focus on interaction.
+
+![Modal Dialog Preview](doc/res/modal-dialog-preview.png)
+
+### Bulk Operations
+
+Bulk interactions such as scheduling downtimes for multiple objects, acknowledging multiple problems, etc.
+are easily accomplished with the `Continue With` control that operates on filtered lists.
+
+![Continue With Preview](doc/res/continue-with-preview.png)
+
+## License
+
+Icinga DB Web and the Icinga DB Web documentation are licensed under the terms of the
+[GNU General Public License Version 2](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..31283ae
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,9 @@
+# Security Policy
+
+## Supported Versions
+
+The latest two minors. If it's a critical threat and the latest minor is just a few weeks old, the third latest minor may also get an update.
+
+## Reporting a Vulnerability
+
+Please head [here](https://icinga.com/company/contact/security-issues/).
diff --git a/application/clicommands/MigrateCommand.php b/application/clicommands/MigrateCommand.php
new file mode 100644
index 0000000..6d034ee
--- /dev/null
+++ b/application/clicommands/MigrateCommand.php
@@ -0,0 +1,835 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Clicommands;
+
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Cli\Command;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\NotReadableError;
+use Icinga\Exception\NotWritableError;
+use Icinga\Module\Icingadb\Compat\UrlMigrator;
+use Icinga\Util\DirectoryIterator;
+use Icinga\Web\Request;
+use ipl\Stdlib\Str;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class MigrateCommand extends Command
+{
+ /** @var bool Skip the migration, only perform transformations */
+ protected $skipMigration = false;
+
+ public function init(): void
+ {
+ Logger::getInstance()->setLevel(Logger::INFO);
+ }
+
+ /**
+ * Migrate monitoring navigation items to Icinga DB Web
+ *
+ * USAGE
+ *
+ * icingacli icingadb migrate navigation [options]
+ *
+ * REQUIRED OPTIONS:
+ *
+ * --user=<name> Migrate navigation items whose owner matches the given
+ * name or owners matching the given pattern. Wildcard
+ * matching by `*` possible.
+ *
+ * OPTIONS:
+ *
+ * --override Replace existing or already migrated items
+ * (Attention: Actions are not backed up)
+ *
+ * --no-backup Remove monitoring actions and don't back up menu items
+ */
+ public function navigationAction(): void
+ {
+ /** @var string $user */
+ $user = $this->params->getRequired('user');
+ $noBackup = $this->params->get('no-backup');
+
+ $preferencesPath = Config::resolvePath('preferences');
+ $sharedNavigation = Config::resolvePath('navigation');
+ if (! file_exists($preferencesPath) && ! file_exists($sharedNavigation)) {
+ Logger::info('There are no user navigation items to migrate');
+ return;
+ }
+
+ $rc = 0;
+ $directories = file_exists($preferencesPath) ? new DirectoryIterator($preferencesPath) : [];
+
+ $anythingChanged = false;
+
+ /** @var string $directory */
+ foreach ($directories as $directory) {
+ /** @var string $username */
+ $username = $directories->key() === false ? '' : $directories->key();
+ if (fnmatch($user, $username) === false) {
+ continue;
+ }
+
+ $menuItems = $this->readFromIni($directory . '/menu.ini', $rc);
+ $hostActions = $this->readFromIni($directory . '/host-actions.ini', $rc);
+ $serviceActions = $this->readFromIni($directory . '/service-actions.ini', $rc);
+ $icingadbHostActions = $this->readFromIni($directory . '/icingadb-host-actions.ini', $rc);
+ $icingadbServiceActions = $this->readFromIni($directory . '/icingadb-service-actions.ini', $rc);
+
+ $menuUpdated = false;
+ $originalMenuItems = $this->readFromIni($directory . '/menu.ini', $rc);
+
+ Logger::info(
+ 'Transforming legacy wildcard filters of existing Icinga DB Web items for user "%s"',
+ $username
+ );
+
+ if (! $menuItems->isEmpty()) {
+ $menuUpdated = $this->transformNavigationItems($menuItems, $username, $rc);
+ $anythingChanged |= $menuUpdated;
+ }
+
+ if (! $icingadbHostActions->isEmpty()) {
+ $anythingChanged |= $this->transformNavigationItems($icingadbHostActions, $username, $rc);
+ }
+
+ if (! $icingadbServiceActions->isEmpty()) {
+ $anythingChanged |= $this->transformNavigationItems(
+ $icingadbServiceActions,
+ $username,
+ $rc
+ );
+ }
+
+ if (! $this->skipMigration) {
+ Logger::info('Migrating monitoring navigation items for user "%s" to Icinga DB Web', $username);
+
+ if (! $menuItems->isEmpty()) {
+ $menuUpdated = $this->migrateNavigationItems($menuItems, $username, $directory . '/menu.ini', $rc);
+ $anythingChanged |= $menuUpdated;
+ }
+
+ if (! $hostActions->isEmpty()) {
+ $anythingChanged |= $this->migrateNavigationItems(
+ $hostActions,
+ $username,
+ $directory . '/icingadb-host-actions.ini',
+ $rc
+ );
+ }
+
+ if (! $serviceActions->isEmpty()) {
+ $anythingChanged |= $this->migrateNavigationItems(
+ $serviceActions,
+ $username,
+ $directory . '/icingadb-service-actions.ini',
+ $rc
+ );
+ }
+ }
+
+ if ($menuUpdated && ! $noBackup) {
+ $this->createBackupIni("$directory/menu", $originalMenuItems);
+ }
+ }
+
+ // Start migrating shared navigation items
+ $menuItems = $this->readFromIni($sharedNavigation . '/menu.ini', $rc);
+ $hostActions = $this->readFromIni($sharedNavigation . '/host-actions.ini', $rc);
+ $serviceActions = $this->readFromIni($sharedNavigation . '/service-actions.ini', $rc);
+ $icingadbHostActions = $this->readFromIni($sharedNavigation . '/icingadb-host-actions.ini', $rc);
+ $icingadbServiceActions = $this->readFromIni($sharedNavigation . '/icingadb-service-actions.ini', $rc);
+
+ $menuUpdated = false;
+ $originalMenuItems = $this->readFromIni($sharedNavigation . '/menu.ini', $rc);
+
+ Logger::info('Transforming legacy wildcard filters of existing shared Icinga DB Web navigation items');
+
+ if (! $menuItems->isEmpty()) {
+ $menuUpdated = $this->transformNavigationItems($menuItems, $user, $rc);
+ $anythingChanged |= $menuUpdated;
+ }
+
+ if (! $icingadbHostActions->isEmpty()) {
+ $anythingChanged |= $this->transformNavigationItems($icingadbHostActions, $user, $rc);
+ }
+
+ if (! $icingadbServiceActions->isEmpty()) {
+ $anythingChanged |= $this->transformNavigationItems(
+ $icingadbServiceActions,
+ $user,
+ $rc
+ );
+ }
+
+ if (! $this->skipMigration) {
+ Logger::info('Migrating shared monitoring navigation items to the Icinga DB Web items');
+
+ if (! $menuItems->isEmpty()) {
+ $menuUpdated = $this->migrateNavigationItems($menuItems, $user, $sharedNavigation . '/menu.ini', $rc);
+ $anythingChanged |= $menuUpdated;
+ }
+
+ if (! $hostActions->isEmpty()) {
+ $anythingChanged |= $this->migrateNavigationItems(
+ $hostActions,
+ $user,
+ $sharedNavigation . '/icingadb-host-actions.ini',
+ $rc
+ );
+ }
+
+ if (! $serviceActions->isEmpty()) {
+ $anythingChanged |= $this->migrateNavigationItems(
+ $serviceActions,
+ $user,
+ $sharedNavigation . '/icingadb-service-actions.ini',
+ $rc
+ );
+ }
+ }
+
+ if ($menuUpdated && ! $noBackup) {
+ $this->createBackupIni("$sharedNavigation/menu", $originalMenuItems);
+ }
+
+ if ($rc > 0) {
+ if ($this->skipMigration) {
+ Logger::error('Failed to transform some icingadb navigation items');
+ } else {
+ Logger::error('Failed to migrate some monitoring navigation items');
+ }
+
+ exit($rc);
+ }
+
+ if (! $anythingChanged) {
+ Logger::info('Nothing to do');
+ } elseif ($this->skipMigration) {
+ Logger::info('Successfully transformed all icingadb navigation item filters');
+ } else {
+ Logger::info('Successfully migrated all monitoring navigation items');
+ }
+ }
+
+
+ /**
+ * Migrate monitoring restrictions and permissions to Icinga DB Web
+ *
+ * Migrated roles do not grant general or full access to users afterward.
+ * It is recommended to review any changes made by this command, before
+ * manually granting access.
+ *
+ * USAGE
+ *
+ * icingacli icingadb migrate role [options]
+ *
+ * REQUIRED OPTIONS: (Use either, not both)
+ *
+ * --group=<name> Update roles that are assigned to the given group or to
+ * groups matching the pattern. Wildcard matching by `*`
+ * possible.
+ *
+ * --role=<name> Update role with the given name or roles whose names
+ * match the pattern. Wildcard matching by `*` possible.
+ *
+ * OPTIONS:
+ *
+ * --override Reset any existing Icinga DB Web rules
+ *
+ * --no-backup Don't back up roles
+ */
+ public function roleAction(): void
+ {
+ /** @var ?bool $override */
+ $override = $this->params->get('override');
+ $noBackup = $this->params->get('no-backup');
+
+ /** @var ?string $groupName */
+ $groupName = $this->params->get('group');
+ /** @var ?string $roleName */
+ $roleName = $this->params->get('role');
+
+ if ($roleName === null && $groupName === null) {
+ $this->fail("One of the parameters 'group' or 'role' must be supplied");
+ } elseif ($roleName !== null && $groupName !== null) {
+ $this->fail("Use either 'group' or 'role'. Both cannot be used as role overrules group.");
+ }
+
+ $rc = 0;
+ $changed = false;
+
+ $restrictions = Config::$configDir . '/roles.ini';
+ $rolesConfig = $this->readFromIni($restrictions, $rc);
+ $monitoringRestriction = 'monitoring/filter/objects';
+ $monitoringPropertyBlackList = 'monitoring/blacklist/properties';
+ $icingadbRestrictions = [
+ 'objects' => 'icingadb/filter/objects',
+ 'hosts' => 'icingadb/filter/hosts',
+ 'services' => 'icingadb/filter/services'
+ ];
+
+ $icingadbPropertyDenyList = 'icingadb/denylist/variables';
+ foreach ($rolesConfig as $name => $role) {
+ /** @var string[] $role */
+ $role = iterator_to_array($role);
+
+ if ($roleName === '*' || $groupName === '*') {
+ $roleMatch = true;
+ } elseif ($roleName !== null && fnmatch($roleName, $name)) {
+ $roleMatch = true;
+ } elseif ($groupName !== null && isset($role['groups'])) {
+ $roleGroups = array_map('trim', explode(',', $role['groups']));
+ $roleMatch = false;
+ foreach ($roleGroups as $roleGroup) {
+ if (fnmatch($groupName, $roleGroup)) {
+ $roleMatch = true;
+ break;
+ }
+ }
+ } else {
+ $roleMatch = false;
+ }
+
+ if ($roleMatch && ! $this->skipMigration && $this->shouldUpdateRole($role, $override)) {
+ if (isset($role[$monitoringRestriction])) {
+ Logger::info(
+ 'Migrating monitoring restriction filter for role "%s" to the Icinga DB Web restrictions',
+ $name
+ );
+ $transformedFilter = UrlMigrator::transformFilter(
+ QueryString::parse($role[$monitoringRestriction])
+ );
+
+ if ($transformedFilter) {
+ $role[$icingadbRestrictions['objects']] = QueryString::render($transformedFilter);
+ $changed = true;
+ }
+ }
+
+ if (isset($role[$monitoringPropertyBlackList])) {
+ Logger::info(
+ 'Migrating monitoring blacklisted properties for role "%s" to the Icinga DB Web deny list',
+ $name
+ );
+
+ $icingadbProperties = [];
+ foreach (explode(',', $role[$monitoringPropertyBlackList]) as $property) {
+ $icingadbProperties[] = preg_replace('/^(?:host|service)\.vars\./i', '', $property, 1);
+ }
+
+ $role[$icingadbPropertyDenyList] = str_replace(
+ '**',
+ '*',
+ implode(',', array_unique($icingadbProperties))
+ );
+
+ $changed = true;
+ }
+
+ if (isset($role['permissions'])) {
+ $updatedPermissions = [];
+ Logger::info(
+ 'Migrating monitoring permissions for role "%s" to the Icinga DB Web permissions',
+ $name
+ );
+
+ if (strpos($role['permissions'], 'monitoring')) {
+ $monitoringProtection = Config::module('monitoring')
+ ->get('security', 'protected_customvars');
+
+ if ($monitoringProtection !== null) {
+ $role['icingadb/protect/variables'] = $monitoringProtection;
+ $changed = true;
+ }
+ }
+
+ foreach (explode(',', $role['permissions']) as $permission) {
+ if (Str::startsWith($permission, 'icingadb/') || $permission === 'module/icingadb') {
+ continue;
+ } elseif (Str::startsWith($permission, 'monitoring/command/')) {
+ $changed = true;
+ $updatedPermissions[] = $permission;
+ $updatedPermissions[] = str_replace('monitoring/', 'icingadb/', $permission);
+ } elseif ($permission === 'no-monitoring/contacts') {
+ $changed = true;
+ $updatedPermissions[] = $permission;
+ $role['icingadb/denylist/routes'] = 'users,usergroups';
+ } else {
+ $updatedPermissions[] = $permission;
+ }
+ }
+
+ $role['permissions'] = implode(',', $updatedPermissions);
+ }
+
+ if (isset($role['refusals']) && is_string($role['refusals'])) {
+ $updatedRefusals = [];
+ Logger::info(
+ 'Migrating monitoring refusals for role "%s" to the Icinga DB Web refusals',
+ $name
+ );
+
+ foreach (explode(',', $role['refusals']) as $refusal) {
+ if (Str::startsWith($refusal, 'icingadb/') || $refusal === 'module/icingadb') {
+ continue;
+ } elseif (Str::startsWith($refusal, 'monitoring/command/')) {
+ $changed = true;
+ $updatedRefusals[] = $refusal;
+ $updatedRefusals[] = str_replace('monitoring/', 'icingadb/', $refusal);
+ } else {
+ $updatedRefusals[] = $refusal;
+ }
+ }
+
+ $role['refusals'] = implode(',', $updatedRefusals);
+ }
+ }
+
+ if ($roleMatch) {
+ foreach ($icingadbRestrictions as $object => $icingadbRestriction) {
+ if (isset($role[$icingadbRestriction]) && is_string($role[$icingadbRestriction])) {
+ $filter = QueryString::parse($role[$icingadbRestriction]);
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+
+ if ($filter) {
+ $filter = QueryString::render($filter);
+ if ($filter !== $role[$icingadbRestriction]) {
+ Logger::info(
+ 'Icinga Db Web restriction of role "%s" for %s changed from "%s" to "%s"',
+ $name,
+ $object,
+ $role[$icingadbRestriction],
+ $filter
+ );
+
+ $role[$icingadbRestriction] = $filter;
+ $changed = true;
+ }
+ }
+ }
+ }
+ }
+
+ $rolesConfig->setSection($name, $role);
+ }
+
+ if ($changed) {
+ if (! $noBackup) {
+ $this->createBackupIni(Config::$configDir . '/roles');
+ }
+
+ try {
+ $rolesConfig->saveIni();
+ } catch (NotWritableError $error) {
+ Logger::error($error);
+ if ($this->skipMigration) {
+ Logger::error('Failed to transform icingadb restrictions');
+ } else {
+ Logger::error('Failed to migrate monitoring restrictions');
+ }
+
+ exit(256);
+ }
+
+ if ($this->skipMigration) {
+ Logger::info('Successfully transformed all icingadb restrictions');
+ } else {
+ Logger::info('Successfully migrated monitoring restrictions and permissions in roles');
+ }
+ } else {
+ Logger::info('Nothing to do');
+ }
+ }
+
+ /**
+ * Migrate monitoring dashboards to Icinga DB Web
+ *
+ * USAGE
+ *
+ * icingacli icingadb migrate dashboard [options]
+ *
+ * REQUIRED OPTIONS:
+ *
+ * --user=<name> Migrate dashboards whose owner matches the given
+ * name or owners matching the given pattern. Wildcard
+ * matching by `*` possible.
+ *
+ * OPTIONS:
+ *
+ * --no-backup Don't back up dashboards
+ */
+ public function dashboardAction(): void
+ {
+ /** @var string $user */
+ $user = $this->params->getRequired('user');
+ $noBackup = $this->params->get('no-backup');
+
+ $dashboardsPath = Config::resolvePath('dashboards');
+ if (! file_exists($dashboardsPath)) {
+ Logger::info('There are no dashboards to migrate');
+ return;
+ }
+
+ $rc = 0;
+ $directories = new DirectoryIterator($dashboardsPath);
+
+ $anythingChanged = false;
+
+ /** @var string $directory */
+ foreach ($directories as $directory) {
+ /** @var string $userName */
+ $userName = $directories->key() === false ? '' : $directories->key();
+ if (fnmatch($user, $userName) === false) {
+ continue;
+ }
+
+ $dashboardsConfig = $this->readFromIni($directory . '/dashboard.ini', $rc);
+ $backupConfig = $this->readFromIni($directory . '/dashboard.ini', $rc);
+
+ Logger::info(
+ 'Migrating monitoring dashboards to Icinga DB Web dashboards for user "%s"',
+ $userName
+ );
+
+ $changed = false;
+ /** @var ConfigObject $dashboardConfig */
+ foreach ($dashboardsConfig->getConfigObject() as $name => $dashboardConfig) {
+ /** @var ?string $dashboardUrlString */
+ $dashboardUrlString = $dashboardConfig->get('url');
+ if ($dashboardUrlString !== null) {
+ $dashBoardUrl = Url::fromPath($dashboardUrlString, [], new Request());
+ if (! $this->skipMigration && Str::startsWith(ltrim($dashboardUrlString, '/'), 'monitoring/')) {
+ $dashboardConfig->url = UrlMigrator::transformUrl($dashBoardUrl)->getRelativeUrl();
+ $changed = true;
+ }
+
+ if (Str::startsWith(ltrim($dashboardUrlString, '/'), 'icingadb/')) {
+ $finalUrl = $dashBoardUrl->onlyWith(['sort', 'limit', 'view', 'columns', 'page']);
+ $params = $dashBoardUrl->without(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
+ $filter = QueryString::parse($params->toString());
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+ if ($filter) {
+ $oldFilterString = $params->toString();
+ $newFilterString = QueryString::render($filter);
+
+ if ($oldFilterString !== $newFilterString) {
+ Logger::info(
+ 'Icinga Db Web filter of dashboard "%s" has changed from "%s" to "%s"',
+ $name,
+ $params->toString(),
+ QueryString::render($filter)
+ );
+ $finalUrl->setFilter($filter);
+
+ $dashboardConfig->url = $finalUrl->getRelativeUrl();
+ $changed = true;
+ }
+ }
+ }
+ }
+ }
+
+
+ if ($changed && $noBackup === null) {
+ $this->createBackupIni("$directory/dashboard", $backupConfig);
+ }
+
+ if ($changed) {
+ $anythingChanged = true;
+ }
+
+ try {
+ $dashboardsConfig->saveIni();
+ } catch (NotWritableError $error) {
+ Logger::error($error);
+ $rc = 256;
+ }
+ }
+
+ if ($rc > 0) {
+ if ($this->skipMigration) {
+ Logger::error('Failed to transform some icingadb dashboards');
+ } else {
+ Logger::error('Failed to migrate some monitoring dashboards');
+ }
+
+ exit($rc);
+ }
+
+ if (! $anythingChanged) {
+ Logger::info('Nothing to do');
+ } elseif ($this->skipMigration) {
+ Logger::info('Successfully transformed all icingadb dashboards');
+ } else {
+ Logger::info('Successfully migrated dashboards for all the matched users');
+ }
+ }
+
+ /**
+ * Migrate Icinga DB Web wildcard filters of navigation items, dashboards and roles
+ *
+ * USAGE
+ *
+ * icingacli icingadb migrate filter
+ *
+ * OPTIONS:
+ *
+ * --no-backup Don't back up menu items, dashboards and roles
+ */
+ public function filterAction(): void
+ {
+ $this->skipMigration = true;
+
+ $this->params->set('user', '*');
+ $this->navigationAction();
+ $this->dashboardAction();
+
+ $this->params->set('role', '*');
+ $this->roleAction();
+ }
+
+ private function transformNavigationItems(Config $config, string $owner, int &$rc): bool
+ {
+ $updated = false;
+ /** @var ConfigObject $newConfigObject */
+ foreach ($config->getConfigObject() as $section => $newConfigObject) {
+ /** @var string $configOwner */
+ $configOwner = $newConfigObject->get('owner') ?? '';
+ if ($configOwner && $configOwner !== $owner) {
+ continue;
+ }
+
+ if (
+ $newConfigObject->get('type') === 'icingadb-host-action'
+ || $newConfigObject->get('type') === 'icingadb-service-action'
+ ) {
+ /** @var ?string $legacyFilter */
+ $legacyFilter = $newConfigObject->get('filter');
+ if ($legacyFilter !== null) {
+ $filter = QueryString::parse($legacyFilter);
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+ if ($filter) {
+ $filter = QueryString::render($filter);
+ if ($legacyFilter !== $filter) {
+ $newConfigObject->filter = $filter;
+ $updated = true;
+ Logger::info(
+ 'Icinga DB Web filter of action "%s" is changed from %s to "%s"',
+ $section,
+ $legacyFilter,
+ $filter
+ );
+ }
+ }
+ }
+ }
+
+ /** @var string $url */
+ $url = $newConfigObject->get('url');
+ if ($url && Str::startsWith(ltrim($url, '/'), 'icingadb/')) {
+ $url = Url::fromPath($url, [], new Request());
+ $finalUrl = $url->onlyWith(['sort', 'limit', 'view', 'columns', 'page']);
+ $params = $url->without(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
+ $filter = QueryString::parse($params->toString());
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+ if ($filter) {
+ $oldFilterString = $params->toString();
+ $newFilterString = QueryString::render($filter);
+
+ if ($oldFilterString !== $newFilterString) {
+ Logger::info(
+ 'Icinga Db Web filter of navigation item "%s" has changed from "%s" to "%s"',
+ $section,
+ $oldFilterString,
+ $newFilterString
+ );
+
+ $newConfigObject->url = $finalUrl->setFilter($filter)->getRelativeUrl();
+ $updated = true;
+ }
+ }
+ }
+ }
+
+ if ($updated) {
+ try {
+ $config->saveIni();
+ } catch (NotWritableError $error) {
+ Logger::error($error);
+ $rc = 256;
+
+ return false;
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Migrate the given config to the given new config path
+ *
+ * @param Config $config
+ * @param string $owner
+ * @param string $path
+ * @param int $rc
+ *
+ * @return bool
+ */
+ private function migrateNavigationItems(Config $config, string $owner, string $path, int &$rc): bool
+ {
+ $deleteLegacyFiles = $this->params->get('no-backup');
+ $override = $this->params->get('override');
+ $newConfig = $config->getConfigFile() === $path ? $config : $this->readFromIni($path, $rc);
+
+ $updated = false;
+ /** @var ConfigObject $configObject */
+ foreach ($config->getConfigObject() as $configObject) {
+ /** @var string $configOwner */
+ $configOwner = $configObject->get('owner') ?? '';
+ if ($configOwner && $configOwner !== $owner) {
+ continue;
+ }
+
+ $migrateFilter = false;
+ if ($configObject->type === 'host-action') {
+ $updated = true;
+ $migrateFilter = true;
+ $configObject->type = 'icingadb-host-action';
+ } elseif ($configObject->type === 'service-action') {
+ $updated = true;
+ $migrateFilter = true;
+ $configObject->type = 'icingadb-service-action';
+ }
+
+ /** @var ?string $urlString */
+ $urlString = $configObject->get('url');
+ if ($urlString !== null) {
+ $urlString = str_replace(
+ ['$SERVICEDESC$', '$HOSTNAME$', '$HOSTADDRESS$', '$HOSTADDRESS6$'],
+ ['$service.name$', '$host.name$', '$host.address$', '$host.address6$'],
+ $urlString
+ );
+ if ($urlString !== $configObject->url) {
+ $configObject->url = $urlString;
+ $updated = true;
+ }
+
+ $url = Url::fromPath($urlString, [], new Request());
+
+ try {
+ $urlString = UrlMigrator::transformUrl($url)->getRelativeUrl();
+ $configObject->url = $urlString;
+ $updated = true;
+ } catch (\InvalidArgumentException $err) {
+ // Do nothing
+ }
+ }
+
+ /** @var ?string $legacyFilter */
+ $legacyFilter = $configObject->get('filter');
+ if ($migrateFilter && $legacyFilter) {
+ $updated = true;
+ $filter = QueryString::parse($legacyFilter);
+ $filter = UrlMigrator::transformFilter($filter);
+ if ($filter !== false) {
+ $configObject->filter = QueryString::render($filter);
+ } else {
+ unset($configObject->filter);
+ }
+ }
+
+ $section = $config->key();
+ if (! $newConfig->hasSection($section) || $newConfig === $config || $override) {
+ $newConfig->setSection($section, $configObject);
+ }
+ }
+
+ if ($updated) {
+ try {
+ $newConfig->saveIni();
+
+ // Remove the legacy file only if explicitly requested
+ if ($deleteLegacyFiles && $newConfig !== $config) {
+ unlink($config->getConfigFile());
+ }
+ } catch (NotWritableError $error) {
+ Logger::error($error);
+ $rc = 256;
+
+ return false;
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Get the navigation items config from the given ini path
+ *
+ * @param string $path Absolute path of the ini file
+ * @param int $rc The return code used to exit the action
+ *
+ * @return Config
+ */
+ private function readFromIni($path, &$rc)
+ {
+ try {
+ $config = Config::fromIni($path);
+ } catch (NotReadableError $error) {
+ Logger::error($error);
+
+ $config = new Config();
+ $rc = 128;
+ }
+
+ return $config;
+ }
+
+ private function createBackupIni(string $path, Config $config = null): void
+ {
+ $counter = 0;
+ while (true) {
+ $filepath = $counter > 0
+ ? "$path.backup$counter.ini"
+ : "$path.backup.ini";
+
+ if (! file_exists($filepath)) {
+ if ($config) {
+ $config->saveIni($filepath);
+ } else {
+ copy("$path.ini", $filepath);
+ }
+
+ break;
+ } else {
+ $counter++;
+ }
+ }
+ }
+
+ /**
+ * Checks if the given role should be updated
+ *
+ * @param string[] $role
+ * @param bool $override
+ *
+ * @return bool
+ */
+ private function shouldUpdateRole(array $role, ?bool $override): bool
+ {
+ return ! (
+ isset($role['icingadb/filter/objects'])
+ || isset($role['icingadb/filter/hosts'])
+ || isset($role['icingadb/filter/services'])
+ || isset($role['icingadb/denylist/routes'])
+ || isset($role['icingadb/denylist/variables'])
+ || isset($role['icingadb/protect/variables'])
+ || (isset($role['permissions']) && str_contains($role['permissions'], 'icingadb'))
+ )
+ || $override;
+ }
+}
diff --git a/application/controllers/CommandTransportController.php b/application/controllers/CommandTransportController.php
new file mode 100644
index 0000000..fe00537
--- /dev/null
+++ b/application/controllers/CommandTransportController.php
@@ -0,0 +1,154 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Application\Config;
+use Icinga\Data\Filter\Filter;
+use Icinga\Forms\ConfirmRemovalForm;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransportConfig;
+use Icinga\Module\Icingadb\Forms\ApiTransportForm;
+use Icinga\Module\Icingadb\Widget\ItemList\CommandTransportList;
+use Icinga\Web\Notification;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\ButtonLink;
+
+class CommandTransportController extends ConfigController
+{
+ public function init()
+ {
+ $this->assertPermission('config/modules');
+ }
+
+ public function indexAction()
+ {
+ $list = new CommandTransportList((new CommandTransportConfig())->select());
+
+ $this->addControl(
+ (new ButtonLink(
+ t('Create Command Transport'),
+ 'icingadb/command-transport/add',
+ 'plus'
+ ))->setBaseTarget('_next')
+ );
+
+ $this->addContent($list);
+
+ $this->mergeTabs($this->Module()->getConfigTabs());
+ $this->getTabs()->disableLegacyExtensions();
+ $this->setTitle($this->getTabs()
+ ->activate('command-transports')
+ ->getActiveTab()
+ ->getLabel());
+ }
+
+ public function showAction()
+ {
+ $transportName = $this->params->getRequired('name');
+
+ $transportConfig = (new CommandTransportConfig())
+ ->select()
+ ->where('name', $transportName)
+ ->fetchRow();
+ if ($transportConfig === false) {
+ $this->httpNotFound(t('Unknown transport'));
+ }
+
+ $form = new ApiTransportForm();
+ $form->populate((array) $transportConfig);
+ $form->on(ApiTransportForm::ON_SUCCESS, function (ApiTransportForm $form) use ($transportName) {
+ (new CommandTransportConfig())->update(
+ 'transport',
+ $form->getValues(),
+ Filter::where('name', $transportName)
+ );
+
+ Notification::success(sprintf(t('Updated command transport "%s" successfully'), $transportName));
+
+ $this->redirectNow('icingadb/command-transport');
+ });
+
+ $form->handleRequest(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+
+ $this->addTitleTab($this->translate('Command Transport: %s'), $transportName);
+ $this->getTabs()->disableLegacyExtensions();
+ }
+
+ public function addAction()
+ {
+ $form = new ApiTransportForm();
+ $form->on(ApiTransportForm::ON_SUCCESS, function (ApiTransportForm $form) {
+ (new CommandTransportConfig())->insert('transport', $form->getValues());
+
+ Notification::success(t('Created command transport successfully'));
+
+ $this->redirectNow('icingadb/command-transport');
+ });
+
+ $form->handleRequest(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+
+ $this->addTitleTab($this->translate('Add Command Transport'));
+ $this->getTabs()->disableLegacyExtensions();
+ }
+
+ public function removeAction()
+ {
+ $transportName = $this->params->getRequired('name');
+
+ $form = new ConfirmRemovalForm();
+ $form->setOnSuccess(function () use ($transportName) {
+ (new CommandTransportConfig())->delete(
+ 'transport',
+ Filter::where('name', $transportName)
+ );
+
+ Notification::success(sprintf(t('Removed command transport "%s" successfully'), $transportName));
+
+ $this->redirectNow('icingadb/command-transport');
+ });
+
+ $form->handleRequest();
+
+ $this->addContent(HtmlString::create($form->render()));
+
+ $this->setTitle($this->translate('Remove Command Transport: %s'), $transportName);
+ $this->getTabs()->disableLegacyExtensions();
+ }
+
+ public function sortAction()
+ {
+ $transportName = $this->params->getRequired('name');
+ $newPosition = (int) $this->params->getRequired('pos');
+
+ $config = $this->Config('commandtransports');
+ if (! $config->hasSection($transportName)) {
+ $this->httpNotFound(t('Unknown transport'));
+ }
+
+ if ($newPosition < 0 || $newPosition > $config->count()) {
+ $this->httpBadRequest(t('Position out of bounds'));
+ }
+
+ $transports = $config->getConfigObject()->toArray();
+ $transportNames = array_keys($transports);
+
+ array_splice($transportNames, array_search($transportName, $transportNames, true), 1);
+ array_splice($transportNames, $newPosition, 0, [$transportName]);
+
+ $sortedTransports = [];
+ foreach ($transportNames as $name) {
+ $sortedTransports[$name] = $transports[$name];
+ }
+
+ $newConfig = Config::fromArray($sortedTransports);
+ $newConfig->saveIni($config->getConfigFile());
+
+ $this->redirectNow('icingadb/command-transport');
+ }
+}
diff --git a/application/controllers/CommentController.php b/application/controllers/CommentController.php
new file mode 100644
index 0000000..b184d6b
--- /dev/null
+++ b/application/controllers/CommentController.php
@@ -0,0 +1,72 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\CommentDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+
+class CommentController extends Controller
+{
+ use CommandActions;
+
+ /** @var Comment The comment object */
+ protected $comment;
+
+ public function init()
+ {
+ $this->addTitleTab(t('Comment'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = Comment::on($this->getDb())->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'service.host',
+ 'service.host.state'
+ ]);
+ $query->filter(Filter::equal('comment.name', $name));
+
+ $this->applyRestrictions($query);
+
+ $comment = $query->first();
+ if ($comment === null) {
+ throw new NotFoundError(t('Comment not found'));
+ }
+
+ $this->comment = $comment;
+ }
+
+ public function indexAction()
+ {
+ $this->addControl((new CommentList([$this->comment]))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ ->setCaptionDisabled()
+ ->setNoSubjectLink());
+
+ $this->addContent((new CommentDetail($this->comment))->setTicketLinkEnabled());
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ protected function fetchCommandTargets(): array
+ {
+ return [$this->comment];
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::comment($this->comment);
+ }
+}
diff --git a/application/controllers/CommentsController.php b/application/controllers/CommentsController.php
new file mode 100644
index 0000000..2358423
--- /dev/null
+++ b/application/controllers/CommentsController.php
@@ -0,0 +1,197 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteCommentForm;
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class CommentsController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Comments'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $comments = Comment::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($comments);
+ $sortControl = $this->createSortControl(
+ $comments,
+ [
+ 'comment.entry_time desc' => t('Entry Time'),
+ 'host.display_name' => t('Host'),
+ 'service.display_name' => t('Service'),
+ 'comment.author' => t('Author'),
+ 'comment.expire_time desc' => t('Expire Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $searchBar = $this->createSearchBar($comments, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($comments, $filter);
+
+ $comments->peekAhead($compact);
+
+ yield $this->export($comments);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::commentsDetails(), $searchBar);
+
+ $results = $comments->execute();
+
+ $this->addContent((new CommentList($results))->setViewMode($viewModeSwitcher->getViewMode()));
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d comments'),
+ $comments->count()
+ ))
+ );
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function deleteAction()
+ {
+ $this->setTitle(t('Remove Comments'));
+
+ $db = $this->getDb();
+
+ $comments = Comment::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $this->filter($comments);
+
+ $form = (new DeleteCommentForm())
+ ->setObjects($comments)
+ ->setRedirectUrl(Links::comments()->getAbsoluteUrl())
+ ->on(DeleteCommentForm::ON_SUCCESS, function ($form) {
+ // This forces the column to reload nearly instantly after the redirect
+ // and ensures the effect of the command is visible to the user asap
+ $this->getResponse()->setAutoRefreshInterval(1);
+
+ $this->redirectNow($form->getRedirectUrl());
+ })
+ ->handleRequest(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+ }
+
+ public function detailsAction()
+ {
+ $this->addTitleTab(t('Comments'));
+
+ $db = $this->getDb();
+
+ $comments = Comment::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $comments->limit(3)->peekAhead();
+
+ $this->filter($comments);
+
+ yield $this->export($comments);
+
+ $rs = $comments->execute();
+
+ $this->addControl((new CommentList($rs))->setViewMode('minimal'));
+
+ $this->addControl(new ShowMore(
+ $rs,
+ Links::comments()->setFilter($this->getFilter()),
+ sprintf(t('Show all %d comments'), $comments->count())
+ ));
+
+ $this->addContent(
+ (new DeleteCommentForm())
+ ->setObjects($comments)
+ ->setAction(
+ Links::commentsDelete()
+ ->setFilter($this->getFilter())
+ ->getAbsoluteUrl()
+ )
+ );
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Comment::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Comment::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php
new file mode 100644
index 0000000..182b7b6
--- /dev/null
+++ b/application/controllers/ConfigController.php
@@ -0,0 +1,63 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Forms\DatabaseConfigForm;
+use Icinga\Module\Icingadb\Forms\RedisConfigForm;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Web\Form;
+use Icinga\Web\Widget\Tab;
+use Icinga\Web\Widget\Tabs;
+use ipl\Html\HtmlString;
+
+class ConfigController extends Controller
+{
+ public function init()
+ {
+ $this->assertPermission('config/modules');
+
+ parent::init();
+ }
+
+ public function databaseAction()
+ {
+ $form = (new DatabaseConfigForm())
+ ->setIniConfig(Config::module('icingadb'));
+
+ $form->handleRequest();
+
+ $this->mergeTabs($this->Module()->getConfigTabs()->activate('database'));
+
+ $this->addFormToContent($form);
+ }
+
+ public function redisAction()
+ {
+ $form = (new RedisConfigForm())
+ ->setIniConfig($this->Config());
+
+ $form->handleRequest();
+
+ $this->mergeTabs($this->Module()->getConfigTabs()->activate('redis'));
+
+ $this->addFormToContent($form);
+ }
+
+ protected function addFormToContent(Form $form)
+ {
+ $this->addContent(new HtmlString($form->render()));
+ }
+
+ protected function mergeTabs(Tabs $tabs): self
+ {
+ /** @var Tab $tab */
+ foreach ($tabs->getTabs() as $tab) {
+ $this->tabs->add($tab->getName(), $tab);
+ }
+
+ return $this;
+ }
+}
diff --git a/application/controllers/DowntimeController.php b/application/controllers/DowntimeController.php
new file mode 100644
index 0000000..a0a7fa0
--- /dev/null
+++ b/application/controllers/DowntimeController.php
@@ -0,0 +1,84 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\DowntimeDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+
+class DowntimeController extends Controller
+{
+ use CommandActions;
+
+ /** @var Downtime */
+ protected $downtime;
+
+ public function init()
+ {
+ $this->addTitleTab(t('Downtime'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = Downtime::on($this->getDb())->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'service.host',
+ 'service.host.state',
+ 'parent',
+ 'parent.host',
+ 'parent.host.state',
+ 'parent.service',
+ 'parent.service.state',
+ 'triggered_by',
+ 'triggered_by.host',
+ 'triggered_by.host.state',
+ 'triggered_by.service',
+ 'triggered_by.service.state'
+ ]);
+ $query->filter(Filter::equal('downtime.name', $name));
+
+ $this->applyRestrictions($query);
+
+ $downtime = $query->first();
+ if ($downtime === null) {
+ throw new NotFoundError(t('Downtime not found'));
+ }
+
+ $this->downtime = $downtime;
+ }
+
+ public function indexAction()
+ {
+ $detail = new DowntimeDetail($this->downtime);
+
+ $this->addControl((new DowntimeList([$this->downtime]))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ ->setCaptionDisabled()
+ ->setNoSubjectLink());
+
+ $this->addContent($detail);
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ protected function fetchCommandTargets(): array
+ {
+ return [$this->downtime];
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::downtime($this->downtime);
+ }
+}
diff --git a/application/controllers/DowntimesController.php b/application/controllers/DowntimesController.php
new file mode 100644
index 0000000..c045ffb
--- /dev/null
+++ b/application/controllers/DowntimesController.php
@@ -0,0 +1,203 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteDowntimeForm;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class DowntimesController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Downtimes'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $downtimes = Downtime::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($downtimes);
+ $sortControl = $this->createSortControl(
+ $downtimes,
+ [
+ 'downtime.is_in_effect desc, downtime.start_time desc' => t('Is In Effect'),
+ 'downtime.entry_time' => t('Entry Time'),
+ 'host.display_name' => t('Host'),
+ 'service.display_name' => t('Service'),
+ 'downtime.author' => t('Author'),
+ 'downtime.start_time desc' => t('Start Time'),
+ 'downtime.end_time desc' => t('End Time'),
+ 'downtime.scheduled_start_time desc' => t('Scheduled Start Time'),
+ 'downtime.scheduled_end_time desc' => t('Scheduled End Time'),
+ 'downtime.duration desc' => t('Duration')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $searchBar = $this->createSearchBar($downtimes, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($downtimes, $filter);
+
+ $downtimes->peekAhead($compact);
+
+ yield $this->export($downtimes);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::downtimesDetails(), $searchBar);
+
+ $results = $downtimes->execute();
+
+ $this->addContent((new DowntimeList($results))->setViewMode($viewModeSwitcher->getViewMode()));
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d downtimes'),
+ $downtimes->count()
+ ))
+ );
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function deleteAction()
+ {
+ $this->setTitle(t('Cancel Downtimes'));
+
+ $db = $this->getDb();
+
+ $downtimes = Downtime::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $this->filter($downtimes);
+
+ $form = (new DeleteDowntimeForm())
+ ->setObjects($downtimes)
+ ->setRedirectUrl(Links::downtimes()->getAbsoluteUrl())
+ ->on(DeleteDowntimeForm::ON_SUCCESS, function ($form) {
+ // This forces the column to reload nearly instantly after the redirect
+ // and ensures the effect of the command is visible to the user asap
+ $this->getResponse()->setAutoRefreshInterval(1);
+
+ $this->redirectNow($form->getRedirectUrl());
+ })
+ ->handleRequest(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+ }
+
+ public function detailsAction()
+ {
+ $this->addTitleTab(t('Downtimes'));
+
+ $db = $this->getDb();
+
+ $downtimes = Downtime::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ]);
+
+ $downtimes->limit(3)->peekAhead();
+
+ $this->filter($downtimes);
+
+ yield $this->export($downtimes);
+
+ $rs = $downtimes->execute();
+
+ $this->addControl((new DowntimeList($rs))->setViewMode('minimal'));
+
+ $this->addControl(new ShowMore(
+ $rs,
+ Links::downtimes()->setFilter($this->getFilter()),
+ sprintf(t('Show all %d downtimes'), $downtimes->count())
+ ));
+
+ $this->addContent(
+ (new DeleteDowntimeForm())
+ ->setObjects($downtimes)
+ ->setAction(
+ Links::downtimesDelete()
+ ->setFilter($this->getFilter())
+ ->getAbsoluteUrl()
+ )
+ );
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Downtime::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Downtime::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ErrorController.php b/application/controllers/ErrorController.php
new file mode 100644
index 0000000..38621c0
--- /dev/null
+++ b/application/controllers/ErrorController.php
@@ -0,0 +1,97 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Controllers\ErrorController as IcingaErrorController;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Web\Layout\Content;
+use ipl\Web\Layout\Controls;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\Tabs;
+
+class ErrorController extends IcingaErrorController
+{
+ /** @var HtmlDocument */
+ protected $document;
+
+ /** @var Controls */
+ protected $controls;
+
+ /** @var Content */
+ protected $content;
+
+ /** @var Tabs */
+ protected $tabs;
+
+ protected function prepareInit()
+ {
+ $this->document = new HtmlDocument();
+ $this->document->setSeparator("\n");
+ $this->controls = new Controls();
+ $this->content = new Content();
+ $this->tabs = new Tabs();
+
+ $this->controls->setTabs($this->tabs);
+ $this->view->document = $this->document;
+ }
+
+ public function postDispatch()
+ {
+ $this->tabs->add(uniqid(), [
+ 'active' => true,
+ 'label' => $this->view->title,
+ 'url' => $this->getRequest()->getUrl()
+ ]);
+
+ if (! $this->content->isEmpty()) {
+ $this->document->prepend($this->content);
+ }
+
+ if (! $this->view->compact && ! $this->controls->isEmpty()) {
+ $this->document->prepend($this->controls);
+ }
+
+ parent::postDispatch();
+ }
+
+ protected function postDispatchXhr()
+ {
+ parent::postDispatchXhr();
+ $this->getResponse()->setHeader('X-Icinga-Module', $this->getModuleName(), true);
+ }
+
+ public function errorAction()
+ {
+ $error = $this->getParam('error_handler');
+ $exception = $error->exception;
+ /** @var \Exception $exception */
+
+ $message = $exception->getMessage();
+ if (substr($message, 0, 27) !== 'Cannot load resource config') {
+ $this->forward('error', 'error', 'default');
+ return;
+ } else {
+ $this->setParam('error_handler', null);
+ }
+
+ // TODO: Find a native way for ipl-html to support enriching text with html
+ $heading = Html::tag('h2', t('Database not configured'));
+ $intro = Html::tag('p', ['data-base-target' => '_next'], Html::sprintf(
+ 'You seem to not have configured a resource for Icinga DB yet. Please %s and then tell Icinga DB Web %s.',
+ new Link(
+ Html::tag('strong', 'create one'),
+ Url::fromPath('config/resource')
+ ),
+ new Link(
+ Html::tag('strong', 'which one it is'),
+ Url::fromPath('icingadb/config/database')
+ )
+ ));
+
+ $this->content->add([$heading, $intro]);
+ }
+}
diff --git a/application/controllers/EventController.php b/application/controllers/EventController.php
new file mode 100644
index 0000000..7108606
--- /dev/null
+++ b/application/controllers/EventController.php
@@ -0,0 +1,71 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use ArrayObject;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\EventDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\HistoryList;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\Filter;
+
+class EventController extends Controller
+{
+ /** @var History */
+ protected $event;
+
+ public function init()
+ {
+ $this->addTitleTab(t('Event'));
+
+ $id = $this->params->getRequired('id');
+
+ $query = History::on($this->getDb())
+ ->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'comment',
+ 'downtime',
+ 'downtime.parent',
+ 'downtime.parent.host',
+ 'downtime.parent.host.state',
+ 'downtime.parent.service',
+ 'downtime.parent.service.state',
+ 'downtime.triggered_by',
+ 'downtime.triggered_by.host',
+ 'downtime.triggered_by.host.state',
+ 'downtime.triggered_by.service',
+ 'downtime.triggered_by.service.state',
+ 'flapping',
+ 'notification',
+ 'acknowledgement',
+ 'state'
+ ])
+ ->filter(Filter::equal('id', hex2bin($id)));
+
+ $this->applyRestrictions($query);
+
+ $event = $query->first();
+ if ($event === null) {
+ $this->httpNotFound(t('Event not found'));
+ }
+
+ $this->event = $event;
+ }
+
+ public function indexAction()
+ {
+ $this->addControl((new HistoryList(new ResultSet(new ArrayObject([$this->event]))))
+ ->setViewMode('minimal')
+ ->setPageSize(1)
+ ->setCaptionDisabled()
+ ->setNoSubjectLink()
+ ->setDetailActionsDisabled());
+ $this->addContent((new EventDetail($this->event))->setTicketLinkEnabled());
+ }
+}
diff --git a/application/controllers/HealthController.php b/application/controllers/HealthController.php
new file mode 100644
index 0000000..52ba220
--- /dev/null
+++ b/application/controllers/HealthController.php
@@ -0,0 +1,115 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Command\Instance\ToggleInstanceFeatureCommand;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Forms\Command\Instance\ToggleInstanceFeaturesForm;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Model\Instance;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Health;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\Html;
+use ipl\Web\Url;
+
+class HealthController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Health'));
+
+ $db = $this->getDb();
+
+ $instance = Instance::on($db)->with(['endpoint']);
+ $hoststateSummary = HoststateSummary::on($db);
+ $servicestateSummary = ServicestateSummary::on($db);
+
+ $this->applyRestrictions($hoststateSummary);
+ $this->applyRestrictions($servicestateSummary);
+
+ yield $this->export($instance, $hoststateSummary, $servicestateSummary);
+
+ $instance = $instance->first();
+
+ if ($instance === null) {
+ $this->addContent(Html::tag('p', t(
+ 'It seems that Icinga DB is not running.'
+ . ' Make sure Icinga DB is running and writing into the database.'
+ )));
+
+ return;
+ }
+
+ $hoststateSummary = $hoststateSummary->first();
+ $servicestateSummary = $servicestateSummary->first();
+
+ $this->content->addAttributes(['class' => 'monitoring-health']);
+
+ $this->addContent(new Health($instance));
+ $this->addContent(Html::tag('section', ['class' => 'check-summary'], [
+ Html::tag('div', ['class' => 'col'], [
+ Html::tag('h3', t('Host Checks')),
+ Html::tag('div', ['class' => 'col-content'], [
+ new VerticalKeyValue(
+ t('Active'),
+ $hoststateSummary->hosts_active_checks_enabled
+ ),
+ new VerticalKeyValue(
+ t('Passive'),
+ $hoststateSummary->hosts_passive_checks_enabled
+ )
+ ])
+ ]),
+ Html::tag('div', ['class' => 'col'], [
+ Html::tag('h3', t('Service Checks')),
+ Html::tag('div', ['class' => 'col-content'], [
+ new VerticalKeyValue(
+ t('Active'),
+ $servicestateSummary->services_active_checks_enabled
+ ),
+ new VerticalKeyValue(
+ t('Passive'),
+ $servicestateSummary->services_passive_checks_enabled
+ )
+ ])
+ ])
+ ]));
+
+ $featureCommands = Html::tag(
+ 'section',
+ ['class' => 'instance-commands'],
+ Html::tag('h2', t('Feature Commands'))
+ );
+ $toggleInstanceFeaturesCommandForm = new ToggleInstanceFeaturesForm([
+ ToggleInstanceFeatureCommand::FEATURE_ACTIVE_HOST_CHECKS =>
+ $instance->icinga2_active_host_checks_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_ACTIVE_SERVICE_CHECKS =>
+ $instance->icinga2_active_service_checks_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_EVENT_HANDLERS =>
+ $instance->icinga2_event_handlers_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_FLAP_DETECTION =>
+ $instance->icinga2_flap_detection_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_NOTIFICATIONS =>
+ $instance->icinga2_notifications_enabled,
+ ToggleInstanceFeatureCommand::FEATURE_PERFORMANCE_DATA =>
+ $instance->icinga2_performance_data_enabled
+ ]);
+ $toggleInstanceFeaturesCommandForm->setObjects([$instance]);
+ $toggleInstanceFeaturesCommandForm->on(ToggleInstanceFeaturesForm::ON_SUCCESS, function () {
+ $this->getResponse()->setAutoRefreshInterval(1);
+
+ $this->redirectNow(Url::fromPath('icingadb/health')->getAbsoluteUrl());
+ });
+ $toggleInstanceFeaturesCommandForm->handleRequest(ServerRequest::fromGlobals());
+
+ $featureCommands->add($toggleInstanceFeaturesCommandForm);
+ $this->addContent($featureCommands);
+
+ $this->setAutorefreshInterval(30);
+ }
+}
diff --git a/application/controllers/HistoryController.php b/application/controllers/HistoryController.php
new file mode 100644
index 0000000..a1b873b
--- /dev/null
+++ b/application/controllers/HistoryController.php
@@ -0,0 +1,139 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\HistoryList;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class HistoryController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('History'));
+ $compact = $this->view->compact; // TODO: Find a less-legacy way..
+
+ $preserveParams = [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ];
+
+ $db = $this->getDb();
+
+ $history = History::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'comment',
+ 'downtime',
+ 'flapping',
+ 'notification',
+ 'acknowledgement',
+ 'state'
+ ]);
+
+ $before = $this->params->shift('before', time());
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($history);
+ $sortControl = $this->createSortControl(
+ $history,
+ [
+ 'history.event_time desc, history.event_type desc' => t('Event Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl, true);
+ $searchBar = $this->createSearchBar($history, $preserveParams);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $history->peekAhead();
+
+ $page = $paginationControl->getCurrentPageNumber();
+
+ if ($page > 1 && ! $compact) {
+ $history->resetOffset();
+ $history->limit($page * $limitControl->getLimit());
+ }
+
+ $history->filter(Filter::lessThanOrEqual('event_time', $before));
+ $this->filter($history, $filter);
+
+ $history->getWith()['history.host']->setJoinType('LEFT');
+ $history->filter(Filter::any(
+ // Because of LEFT JOINs, make sure we'll fetch history entries only for items which still exist:
+ Filter::like('host.id', '*'),
+ Filter::like('service.id', '*')
+ ));
+
+ yield $this->export($history);
+
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+
+ $url = Url::fromRequest()
+ ->onlyWith($preserveParams)
+ ->setFilter($filter);
+
+ $historyList = (new HistoryList($history->execute()))
+ ->setPageSize($limitControl->getLimit())
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ ->setLoadMoreUrl($url->setParam('before', $before));
+ if ($compact) {
+ $historyList->setPageNumber($page);
+ }
+
+ if ($compact && $page > 1) {
+ $this->document->addFrom($historyList);
+ } else {
+ $this->addContent($historyList);
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(History::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(History::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/HostController.php b/application/controllers/HostController.php
new file mode 100644
index 0000000..259dd33
--- /dev/null
+++ b/application/controllers/HostController.php
@@ -0,0 +1,293 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use ArrayIterator;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Command\Object\GetObjectCommand;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\HostLinks;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Hook\TabHook\HookActions;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\HostDetail;
+use Icinga\Module\Icingadb\Widget\Detail\HostInspectionDetail;
+use Icinga\Module\Icingadb\Widget\Detail\HostMetaInfo;
+use Icinga\Module\Icingadb\Widget\Detail\QuickActions;
+use Icinga\Module\Icingadb\Widget\ItemList\HostList;
+use Icinga\Module\Icingadb\Widget\ItemList\HistoryList;
+use Icinga\Module\Icingadb\Widget\ItemList\ServiceList;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+use ipl\Web\Widget\Tabs;
+
+class HostController extends Controller
+{
+ use CommandActions;
+ use HookActions;
+
+ /** @var Host The host object */
+ protected $host;
+
+ public function init()
+ {
+ $name = $this->params->getRequired('name');
+
+ $query = Host::on($this->getDb())->with(['state', 'icon_image', 'timeperiod']);
+ $query
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::equal('host.name', $name));
+
+ $this->applyRestrictions($query);
+
+ /** @var Host $host */
+ $host = $query->first();
+ if ($host === null) {
+ throw new NotFoundError(t('Host not found'));
+ }
+
+ $this->host = $host;
+ $this->loadTabsForObject($host);
+
+ $this->setTitleTab($this->getRequest()->getActionName());
+ $this->setTitle($host->display_name);
+ }
+
+ public function indexAction()
+ {
+ $serviceSummary = ServicestateSummary::on($this->getDb());
+ $serviceSummary->filter(Filter::equal('service.host_id', $this->host->id));
+
+ $this->applyRestrictions($serviceSummary);
+
+ if ($this->host->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->addControl((new HostList([$this->host]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl(new HostMetaInfo($this->host));
+ $this->addControl(new QuickActions($this->host));
+
+ $this->addContent(new HostDetail($this->host, $serviceSummary->first()));
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function sourceAction()
+ {
+ $this->assertPermission('icingadb/object/show-source');
+
+ $apiResult = (new CommandTransport())->send(
+ (new GetObjectCommand())
+ ->setObjects(new ArrayIterator([$this->host]))
+ );
+
+ if ($this->host->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->addControl((new HostList([$this->host]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addContent(new HostInspectionDetail(
+ $this->host,
+ reset($apiResult)
+ ));
+ }
+
+ public function historyAction()
+ {
+ $compact = $this->view->compact; // TODO: Find a less-legacy way..
+
+ if ($this->host->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $db = $this->getDb();
+
+ $history = History::on($db)->with([
+ 'host',
+ 'host.state',
+ 'comment',
+ 'downtime',
+ 'flapping',
+ 'notification',
+ 'acknowledgement',
+ 'state'
+ ]);
+
+ $history->filter(Filter::all(
+ Filter::equal('history.host_id', $this->host->id),
+ Filter::unlike('history.service_id', '*')
+ ));
+
+ $before = $this->params->shift('before', time());
+ $url = Url::fromRequest()->setParams(clone $this->params);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($history);
+ $sortControl = $this->createSortControl(
+ $history,
+ [
+ 'history.event_time desc, history.event_type desc' => t('Event Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl, true);
+
+ $history->peekAhead();
+
+ $page = $paginationControl->getCurrentPageNumber();
+
+ if ($page > 1 && ! $compact) {
+ $history->limit($page * $limitControl->getLimit());
+ }
+
+ $history->filter(Filter::lessThanOrEqual('event_time', $before));
+
+ yield $this->export($history);
+
+ $this->addControl((new HostList([$this->host]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+
+ $historyList = (new HistoryList($history->execute()))
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ ->setPageSize($limitControl->getLimit())
+ ->setLoadMoreUrl($url->setParam('before', $before));
+
+ if ($compact) {
+ $historyList->setPageNumber($page);
+ }
+
+ if ($compact && $page > 1) {
+ $this->document->addFrom($historyList);
+ } else {
+ $this->addContent($historyList);
+ }
+ }
+
+ public function servicesAction()
+ {
+ if ($this->host->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'state.last_comment',
+ 'icon_image',
+ 'host',
+ 'host.state'
+ ]);
+ $services
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::equal('host.id', $this->host->id));
+
+ $this->applyRestrictions($services);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($services);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $sortControl = $this->createSortControl(
+ $services,
+ [
+ 'service.display_name' => t('Name'),
+ 'service.state.severity desc,service.state.last_state_change desc' => t('Severity'),
+ 'service.state.soft_state' => t('Current State'),
+ 'service.state.last_state_change desc' => t('Last State Change')
+ ]
+ );
+
+ yield $this->export($services);
+
+ $serviceList = (new ServiceList($services))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+
+ $this->addControl((new HostList([$this->host]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+
+ $this->addContent($serviceList);
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ protected function createTabs(): Tabs
+ {
+ $tabs = $this->getTabs()
+ ->add('index', [
+ 'label' => t('Host'),
+ 'url' => Links::host($this->host)
+ ])
+ ->add('services', [
+ 'label' => t('Services'),
+ 'url' => HostLinks::services($this->host)
+ ])
+ ->add('history', [
+ 'label' => t('History'),
+ 'url' => HostLinks::history($this->host)
+ ]);
+
+ if ($this->hasPermission('icingadb/object/show-source')) {
+ $tabs->add('source', [
+ 'label' => t('Source'),
+ 'url' => Links::hostSource($this->host)
+ ]);
+ }
+
+ foreach ($this->loadAdditionalTabs() as $name => $tab) {
+ $tabs->add($name, $tab + ['urlParams' => ['name' => $this->host->name]]);
+ }
+
+ return $tabs;
+ }
+
+ protected function setTitleTab(string $name)
+ {
+ $tab = $this->createTabs()->get($name);
+
+ if ($tab !== null) {
+ $tab->setActive();
+
+ $this->setTitle($tab->getLabel());
+ }
+ }
+
+ protected function fetchCommandTargets(): array
+ {
+ return [$this->host];
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::host($this->host);
+ }
+
+ protected function getDefaultTabControls(): array
+ {
+ return [(new HostList([$this->host]))->setDetailActionsDisabled()->setNoSubjectLink()];
+ }
+}
diff --git a/application/controllers/HostgroupController.php b/application/controllers/HostgroupController.php
new file mode 100644
index 0000000..14fd0c1
--- /dev/null
+++ b/application/controllers/HostgroupController.php
@@ -0,0 +1,82 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Hostgroupsummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\HostList;
+use Icinga\Module\Icingadb\Widget\ItemTable\HostgroupTableRow;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter;
+
+class HostgroupController extends Controller
+{
+ /** @var Hostgroupsummary The host group object */
+ protected $hostgroup;
+
+ public function init()
+ {
+ $this->assertRouteAccess('hostgroups');
+
+ $this->addTitleTab(t('Host Group'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = Hostgroupsummary::on($this->getDb());
+
+ foreach ($query->getUnions() as $unionPart) {
+ $unionPart->filter(Filter::equal('hostgroup.name', $name));
+ }
+
+ $this->applyRestrictions($query);
+
+ $hostgroup = $query->first();
+ if ($hostgroup === null) {
+ throw new NotFoundError(t('Host group not found'));
+ }
+
+ $this->hostgroup = $hostgroup;
+ $this->setTitle($hostgroup->display_name);
+ }
+
+ public function indexAction()
+ {
+ $db = $this->getDb();
+
+ $hosts = Host::on($db)->with(['state', 'state.last_comment', 'icon_image']);
+ $hosts
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::equal('hostgroup.id', $this->hostgroup->id));
+ $this->applyRestrictions($hosts);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($hosts);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+
+ $hostList = (new HostList($hosts->execute()))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+
+ yield $this->export($hosts);
+
+ // ICINGAWEB_EXPORT_FORMAT is not set yet and $this->format is inaccessible, yeah...
+ if ($this->getRequest()->getParam('format') === 'pdf') {
+ $this->addContent(new HostgroupTableRow($this->hostgroup));
+ $this->addContent(Html::tag('h2', null, t('Hosts')));
+ } else {
+ $this->addControl(new HostgroupTableRow($this->hostgroup));
+ }
+
+ $this->addControl($paginationControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($limitControl);
+
+ $this->addContent($hostList);
+
+ $this->setAutorefreshInterval(10);
+ }
+}
diff --git a/application/controllers/HostgroupsController.php b/application/controllers/HostgroupsController.php
new file mode 100644
index 0000000..700c6fd
--- /dev/null
+++ b/application/controllers/HostgroupsController.php
@@ -0,0 +1,145 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\Hostgroup;
+use Icinga\Module\Icingadb\Model\Hostgroupsummary;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemTable\HostgroupTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class HostgroupsController extends Controller
+{
+ public function init()
+ {
+ parent::init();
+
+ $this->assertRouteAccess();
+ }
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Host Groups'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $hostgroups = Hostgroupsummary::on($db);
+
+ $this->handleSearchRequest($hostgroups);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($hostgroups);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+
+ $defaultSort = null;
+ if ($viewModeSwitcher->getViewMode() === 'grid') {
+ $hostgroups->without([
+ 'services_critical_handled',
+ 'services_critical_unhandled',
+ 'services_ok',
+ 'services_pending',
+ 'services_total',
+ 'services_unknown_handled',
+ 'services_unknown_unhandled',
+ 'services_warning_handled',
+ 'services_warning_unhandled',
+ ]);
+
+ $defaultSort = ['hosts_severity DESC', 'display_name'];
+ }
+
+ $sortControl = $this->createSortControl(
+ $hostgroups,
+ [
+ 'display_name' => t('Name'),
+ 'hosts_severity desc, display_name' => t('Severity'),
+ 'hosts_total desc' => t('Total Hosts'),
+ ],
+ $defaultSort
+ );
+
+ $searchBar = $this->createSearchBar($hostgroups, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($hostgroups, $filter);
+
+ $hostgroups->peekAhead($compact);
+
+ yield $this->export($hostgroups);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+
+ $results = $hostgroups->execute();
+
+ $this->addContent(
+ (new HostgroupTable($results))
+ ->setBaseFilter($filter)
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ );
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d hostgroups'),
+ $hostgroups->count()
+ ))
+ );
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(30);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Hostgroup::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Hostgroupsummary::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/HostsController.php b/application/controllers/HostsController.php
new file mode 100644
index 0000000..fff7139
--- /dev/null
+++ b/application/controllers/HostsController.php
@@ -0,0 +1,242 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Util\FeatureStatus;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\MultiselectQuickActions;
+use Icinga\Module\Icingadb\Widget\Detail\ObjectsDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\HostList;
+use Icinga\Module\Icingadb\Widget\HostStatusBar;
+use Icinga\Module\Icingadb\Widget\ItemTable\HostItemTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class HostsController extends Controller
+{
+ use CommandActions;
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Hosts'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $hosts = Host::on($db)->with(['state', 'icon_image', 'state.last_comment']);
+ $hosts->getWith()['host.state']->setJoinType('INNER');
+ $hosts->setResultSetClass(VolatileStateResults::class);
+
+ $this->handleSearchRequest($hosts, ['address', 'address6']);
+
+ $summary = null;
+ if (! $compact) {
+ $summary = HoststateSummary::on($db);
+ }
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($hosts);
+ $sortControl = $this->createSortControl(
+ $hosts,
+ [
+ 'host.display_name' => t('Name'),
+ 'host.state.severity desc,host.state.last_state_change desc' => t('Severity'),
+ 'host.state.soft_state' => t('Current State'),
+ 'host.state.last_state_change desc' => t('Last State Change')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $columns = $this->createColumnControl($hosts, $viewModeSwitcher);
+
+ $searchBar = $this->createSearchBar($hosts, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam(),
+ 'columns'
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $hosts->peekAhead($compact);
+
+ $this->filter($hosts, $filter);
+ if (! $compact) {
+ $this->filter($summary, $filter);
+ yield $this->export($hosts, $summary);
+ } else {
+ yield $this->export($hosts);
+ }
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::hostsDetails(), $searchBar);
+
+ $results = $hosts->execute();
+
+ if ($viewModeSwitcher->getViewMode() === 'tabular') {
+ $hostList = (new HostItemTable($results, HostItemTable::applyColumnMetaData($hosts, $columns)))
+ ->setSort($sortControl->getSort());
+ } else {
+ $hostList = (new HostList($results))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+ }
+
+ $this->addContent($hostList);
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d hosts'),
+ $hosts->count()
+ ))
+ );
+ } else {
+ /** @var HoststateSummary $hostsSummary */
+ $hostsSummary = $summary->first();
+ $this->addFooter((new HostStatusBar($hostsSummary))->setBaseFilter($filter));
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function detailsAction()
+ {
+ $this->addTitleTab(t('Hosts'));
+
+ $db = $this->getDb();
+
+ $hosts = Host::on($db)->with(['state', 'icon_image']);
+ $hosts->setResultSetClass(VolatileStateResults::class);
+ $summary = HoststateSummary::on($db)->with(['state']);
+
+ $this->filter($hosts);
+ $this->filter($summary);
+
+ $hosts->limit(3);
+ $hosts->peekAhead();
+
+ yield $this->export($hosts, $summary);
+
+ $results = $hosts->execute();
+ $summary = $summary->first();
+
+ $downtimes = Host::on($db)->with(['downtime']);
+ $downtimes->getWith()['host.downtime']->setJoinType('INNER');
+ $this->filter($downtimes);
+ $summary->downtimes_total = $downtimes->count();
+
+ $comments = Host::on($db)->with(['comment']);
+ $comments->getWith()['host.comment']->setJoinType('INNER');
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $comments->filter(Filter::equal('comment.object_type', 'host'));
+ $this->filter($comments);
+ $summary->comments_total = $comments->count();
+
+ $this->addControl(
+ (new HostList($results))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ );
+ $this->addControl(new ShowMore(
+ $results,
+ Links::hosts()->setFilter($this->getFilter()),
+ sprintf(t('Show all %d hosts'), $hosts->count())
+ ));
+ $this->addControl(
+ (new MultiselectQuickActions('host', $summary))
+ ->setBaseFilter($this->getFilter())
+ );
+
+ $this->addContent(
+ (new ObjectsDetail('host', $summary, $hosts))
+ ->setBaseFilter($this->getFilter())
+ );
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Host::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Host::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM,
+ 'columns'
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+
+ protected function fetchCommandTargets(): Query
+ {
+ $db = $this->getDb();
+
+ $hosts = Host::on($db)->with('state');
+ $hosts->setResultSetClass(VolatileStateResults::class);
+
+ switch ($this->getRequest()->getActionName()) {
+ case 'acknowledge':
+ $hosts->filter(Filter::equal('state.is_problem', 'y'))
+ ->filter(Filter::equal('state.is_acknowledged', 'n'));
+
+ break;
+ }
+
+ $this->filter($hosts);
+
+ return $hosts;
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::hostsDetails()->setFilter($this->getFilter());
+ }
+
+ protected function getFeatureStatus()
+ {
+ $summary = HoststateSummary::on($this->getDb());
+ $this->filter($summary);
+
+ return new FeatureStatus('host', $summary->first());
+ }
+}
diff --git a/application/controllers/MigrateController.php b/application/controllers/MigrateController.php
new file mode 100644
index 0000000..811b5d0
--- /dev/null
+++ b/application/controllers/MigrateController.php
@@ -0,0 +1,161 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Exception;
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Application\Hook;
+use Icinga\Application\Icinga;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Compat\UrlMigrator;
+use Icinga\Module\Icingadb\Forms\SetAsBackendForm;
+use Icinga\Module\Icingadb\Hook\IcingadbSupportHook;
+use Icinga\Module\Icingadb\Web\Controller;
+use ipl\Html\HtmlString;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class MigrateController extends Controller
+{
+ public function monitoringUrlAction()
+ {
+ $this->assertHttpMethod('post');
+ if (! $this->getRequest()->isApiRequest()) {
+ $this->httpBadRequest('No API request');
+ }
+
+ if (
+ ! preg_match('/([^;]*);?/', $this->getRequest()->getHeader('Content-Type'), $matches)
+ || $matches[1] !== 'application/json'
+ ) {
+ $this->httpBadRequest('No JSON content');
+ }
+
+ $urls = $this->getRequest()->getPost();
+
+ $result = [];
+ $errors = [];
+ foreach ($urls as $urlString) {
+ $url = Url::fromPath($urlString);
+ if (UrlMigrator::isSupportedUrl($url)) {
+ try {
+ $urlString = rawurldecode(UrlMigrator::transformUrl($url)->getAbsoluteUrl());
+ } catch (Exception $e) {
+ $errors[$urlString] = [
+ IcingaException::describe($e),
+ IcingaException::getConfidentialTraceAsString($e)
+ ];
+ $urlString = false;
+ }
+ }
+
+ $result[] = $urlString;
+ }
+
+ $response = $this->getResponse()->json();
+ if (empty($errors)) {
+ $response->setSuccessData($result);
+ } else {
+ $response->setFailData([
+ 'result' => $result,
+ 'errors' => $errors
+ ]);
+ }
+
+ $response->sendResponse();
+ }
+
+ public function searchUrlAction()
+ {
+ $this->assertHttpMethod('post');
+ if (! $this->getRequest()->isApiRequest()) {
+ $this->httpBadRequest('No API request');
+ }
+
+ if (
+ ! preg_match('/([^;]*);?/', $this->getRequest()->getHeader('Content-Type'), $matches)
+ || $matches[1] !== 'application/json'
+ ) {
+ $this->httpBadRequest('No JSON content');
+ }
+
+ $urls = $this->getRequest()->getPost();
+
+ $result = [];
+ foreach ($urls as $urlString) {
+ $url = Url::fromPath($urlString);
+ $params = $url->onlyWith(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
+ $filter = $url->without(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
+ $filter = QueryString::parse((string) $filter);
+ $filter = UrlMigrator::transformLegacyWildcardFilter($filter);
+ $result[] = rawurldecode($url->setParams($params)->setFilter($filter)->getAbsoluteUrl());
+ }
+
+ $response = $this->getResponse()->json();
+ $response->setSuccessData($result);
+
+ $response->sendResponse();
+ }
+
+ public function checkboxStateAction()
+ {
+ $this->assertHttpMethod('get');
+
+ $form = new SetAsBackendForm();
+ $form->setAction(Url::fromPath('icingadb/migrate/checkbox-submit')->getAbsoluteUrl());
+
+ $this->getDocument()->addHtml($form);
+ }
+
+ public function checkboxSubmitAction()
+ {
+ $this->assertHttpMethod('post');
+ $this->addPart(HtmlString::create('"bogus"'), 'Behavior:Migrate');
+
+ (new SetAsBackendForm())->handleRequest(ServerRequest::fromGlobals());
+ }
+
+ public function backendSupportAction()
+ {
+ $this->assertHttpMethod('post');
+ if (! $this->getRequest()->isApiRequest()) {
+ $this->httpBadRequest('No API request');
+ }
+
+ if (
+ ! preg_match('/([^;]*);?/', $this->getRequest()->getHeader('Content-Type'), $matches)
+ || $matches[1] !== 'application/json'
+ ) {
+ $this->httpBadRequest('No JSON content');
+ }
+
+ $moduleSupportStates = [];
+ if (
+ Icinga::app()->getModuleManager()->hasEnabled('monitoring')
+ && $this->Auth()->hasPermission('module/monitoring')
+ ) {
+ $supportList = [];
+ foreach (Hook::all('Icingadb/IcingadbSupport') as $hook) {
+ /** @var IcingadbSupportHook $hook */
+ $supportList[$hook->getModule()->getName()] = $hook->supportsIcingaDb();
+ }
+
+ $moduleSupportStates = [];
+ foreach ($this->getRequest()->getPost() as $moduleName) {
+ if (isset($supportList[$moduleName])) {
+ $moduleSupportStates[] = $supportList[$moduleName];
+ } else {
+ $moduleSupportStates[] = false;
+ }
+ }
+ }
+
+ $this->getResponse()
+ ->json()
+ ->setSuccessData($moduleSupportStates)
+ ->sendResponse();
+ }
+}
diff --git a/application/controllers/NotificationsController.php b/application/controllers/NotificationsController.php
new file mode 100644
index 0000000..2d23604
--- /dev/null
+++ b/application/controllers/NotificationsController.php
@@ -0,0 +1,136 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\NotificationHistory;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\NotificationList;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Sql\Sql;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class NotificationsController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Notifications'));
+ $compact = $this->view->compact;
+
+ $preserveParams = [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ];
+
+ $db = $this->getDb();
+
+ $notifications = NotificationHistory::on($db)->with([
+ 'history',
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state'
+ ]);
+
+ $this->handleSearchRequest($notifications);
+ $before = $this->params->shift('before', time());
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($notifications);
+ $sortControl = $this->createSortControl(
+ $notifications,
+ [
+ 'notification_history.send_time desc' => t('Send Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl, true);
+ $searchBar = $this->createSearchBar($notifications, $preserveParams);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $notifications->peekAhead();
+
+ $page = $paginationControl->getCurrentPageNumber();
+
+ if ($page > 1 && ! $compact) {
+ $notifications->resetOffset();
+ $notifications->limit($page * $limitControl->getLimit());
+ }
+
+ $notifications->filter(Filter::lessThanOrEqual('send_time', $before));
+ $this->filter($notifications, $filter);
+ $notifications->filter(Filter::any(
+ // Make sure we'll fetch service history entries only for services which still exist
+ Filter::unlike('service_id', '*'),
+ Filter::like('history.service.id', '*')
+ ));
+
+ yield $this->export($notifications);
+
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+
+ $url = Url::fromRequest()
+ ->onlyWith($preserveParams)
+ ->setFilter($filter);
+
+ $notificationList = (new NotificationList($notifications->execute()))
+ ->setPageSize($limitControl->getLimit())
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ ->setLoadMoreUrl($url->setParam('before', $before));
+
+ if ($compact) {
+ $notificationList->setPageNumber($page);
+ }
+
+ if ($compact && $page > 1) {
+ $this->document->addFrom($notificationList);
+ } else {
+ $this->addContent($notificationList);
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(NotificationHistory::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(NotificationHistory::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ServiceController.php b/application/controllers/ServiceController.php
new file mode 100644
index 0000000..8867e91
--- /dev/null
+++ b/application/controllers/ServiceController.php
@@ -0,0 +1,245 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use ArrayIterator;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Command\Object\GetObjectCommand;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\ServiceLinks;
+use Icinga\Module\Icingadb\Hook\TabHook\HookActions;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\QuickActions;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceDetail;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceInspectionDetail;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceMetaInfo;
+use Icinga\Module\Icingadb\Widget\ItemList\HistoryList;
+use Icinga\Module\Icingadb\Widget\ItemList\ServiceList;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+
+class ServiceController extends Controller
+{
+ use CommandActions;
+ use HookActions;
+
+ /** @var Service The service object */
+ protected $service;
+
+ public function init()
+ {
+ $name = $this->params->getRequired('name');
+ $hostName = $this->params->getRequired('host.name');
+
+ $query = Service::on($this->getDb())->with([
+ 'state',
+ 'icon_image',
+ 'host',
+ 'host.state',
+ 'timeperiod'
+ ]);
+ $query
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::all(
+ Filter::equal('service.name', $name),
+ Filter::equal('host.name', $hostName)
+ ));
+
+ $this->applyRestrictions($query);
+
+ /** @var Service $service */
+ $service = $query->first();
+ if ($service === null) {
+ throw new NotFoundError(t('Service not found'));
+ }
+
+ $this->service = $service;
+ $this->loadTabsForObject($service);
+
+ $this->setTitleTab($this->getRequest()->getActionName());
+ $this->setTitle(
+ t('%s on %s', '<service> on <host>'),
+ $service->display_name,
+ $service->host->display_name
+ );
+ }
+
+ public function indexAction()
+ {
+ if ($this->service->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->addControl((new ServiceList([$this->service]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl(new ServiceMetaInfo($this->service));
+ $this->addControl(new QuickActions($this->service));
+
+ $this->addContent(new ServiceDetail($this->service));
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function sourceAction()
+ {
+ $this->assertPermission('icingadb/object/show-source');
+
+ $apiResult = (new CommandTransport())->send(
+ (new GetObjectCommand())
+ ->setObjects(new ArrayIterator([$this->service]))
+ );
+
+ if ($this->service->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->addControl((new ServiceList([$this->service]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addContent(new ServiceInspectionDetail(
+ $this->service,
+ reset($apiResult)
+ ));
+ }
+
+ public function historyAction()
+ {
+ $compact = $this->view->compact; // TODO: Find a less-legacy way..
+
+ if ($this->service->state->is_overdue) {
+ $this->controls->addAttributes(['class' => 'overdue']);
+ }
+
+ $db = $this->getDb();
+
+ $history = History::on($db)->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.state',
+ 'comment',
+ 'downtime',
+ 'flapping',
+ 'notification',
+ 'acknowledgement',
+ 'state'
+ ]);
+ $history->filter(Filter::all(
+ Filter::equal('history.host_id', $this->service->host_id),
+ Filter::equal('history.service_id', $this->service->id)
+ ));
+
+ $before = $this->params->shift('before', time());
+ $url = Url::fromRequest()->setParams(clone $this->params);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($history);
+ $sortControl = $this->createSortControl(
+ $history,
+ [
+ 'history.event_time desc, history.event_type desc' => t('Event Time')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl, true);
+
+ $history->peekAhead();
+
+ $page = $paginationControl->getCurrentPageNumber();
+
+ if ($page > 1 && ! $compact) {
+ $history->limit($page * $limitControl->getLimit());
+ }
+
+ $history->filter(Filter::lessThanOrEqual('event_time', $before));
+
+ yield $this->export($history);
+
+ $this->addControl((new ServiceList([$this->service]))
+ ->setViewMode('objectHeader')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+
+ $historyList = (new HistoryList($history->execute()))
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ ->setPageSize($limitControl->getLimit())
+ ->setLoadMoreUrl($url->setParam('before', $before));
+
+ if ($compact) {
+ $historyList->setPageNumber($page);
+ }
+
+ if ($compact && $page > 1) {
+ $this->document->addFrom($historyList);
+ } else {
+ $this->addContent($historyList);
+ }
+ }
+
+ protected function createTabs()
+ {
+ $tabs = $this->getTabs()
+ ->add('index', [
+ 'label' => t('Service'),
+ 'url' => Links::service($this->service, $this->service->host)
+ ])
+ ->add('history', [
+ 'label' => t('History'),
+ 'url' => ServiceLinks::history($this->service, $this->service->host)
+ ]);
+
+ if ($this->hasPermission('icingadb/object/show-source')) {
+ $tabs->add('source', [
+ 'label' => t('Source'),
+ 'url' => Links::serviceSource($this->service, $this->service->host)
+ ]);
+ }
+
+ foreach ($this->loadAdditionalTabs() as $name => $tab) {
+ $tabs->add($name, $tab + ['urlParams' => [
+ 'name' => $this->service->name,
+ 'host.name' => $this->service->host->name
+ ]]);
+ }
+
+ return $tabs;
+ }
+
+ protected function setTitleTab(string $name)
+ {
+ $tab = $this->createTabs()->get($name);
+
+ if ($tab !== null) {
+ $tab->setActive();
+
+ $this->setTitle($tab->getLabel());
+ }
+ }
+
+ protected function fetchCommandTargets(): array
+ {
+ return [$this->service];
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::service($this->service, $this->service->host);
+ }
+
+ protected function getDefaultTabControls(): array
+ {
+ return [(new ServiceList([$this->service]))->setDetailActionsDisabled()->setNoSubjectLink()];
+ }
+}
diff --git a/application/controllers/ServicegroupController.php b/application/controllers/ServicegroupController.php
new file mode 100644
index 0000000..d6ebc19
--- /dev/null
+++ b/application/controllers/ServicegroupController.php
@@ -0,0 +1,89 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\ServicegroupSummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\ServiceList;
+use Icinga\Module\Icingadb\Widget\ItemTable\ServicegroupTableRow;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter;
+
+class ServicegroupController extends Controller
+{
+ /** @var ServicegroupSummary The service group object */
+ protected $servicegroup;
+
+ public function init()
+ {
+ $this->assertRouteAccess('servicegroups');
+
+ $this->addTitleTab(t('Service Group'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = ServicegroupSummary::on($this->getDb());
+
+ foreach ($query->getUnions() as $unionPart) {
+ $unionPart->filter(Filter::equal('servicegroup.name', $name));
+ }
+
+ $this->applyRestrictions($query);
+
+ $servicegroup = $query->first();
+ if ($servicegroup === null) {
+ throw new NotFoundError(t('Service group not found'));
+ }
+
+ $this->servicegroup = $servicegroup;
+ $this->setTitle($servicegroup->display_name);
+ }
+
+ public function indexAction()
+ {
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'state.last_comment',
+ 'icon_image',
+ 'host',
+ 'host.state'
+ ]);
+ $services
+ ->setResultSetClass(VolatileStateResults::class)
+ ->filter(Filter::equal('servicegroup.id', $this->servicegroup->id));
+
+ $this->applyRestrictions($services);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($services);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+
+ $serviceList = (new ServiceList($services->execute()))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+
+ yield $this->export($services);
+
+ // ICINGAWEB_EXPORT_FORMAT is not set yet and $this->format is inaccessible, yeah...
+ if ($this->getRequest()->getParam('format') === 'pdf') {
+ $this->addContent(new ServicegroupTableRow($this->servicegroup));
+ $this->addContent(Html::tag('h2', null, t('Services')));
+ } else {
+ $this->addControl(new ServicegroupTableRow($this->servicegroup));
+ }
+
+ $this->addControl($paginationControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($limitControl);
+
+ $this->addContent($serviceList);
+
+ $this->setAutorefreshInterval(10);
+ }
+}
diff --git a/application/controllers/ServicegroupsController.php b/application/controllers/ServicegroupsController.php
new file mode 100644
index 0000000..299d001
--- /dev/null
+++ b/application/controllers/ServicegroupsController.php
@@ -0,0 +1,133 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\Servicegroup;
+use Icinga\Module\Icingadb\Model\ServicegroupSummary;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemTable\ServicegroupTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class ServicegroupsController extends Controller
+{
+ public function init()
+ {
+ parent::init();
+
+ $this->assertRouteAccess();
+ }
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Service Groups'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $servicegroups = ServicegroupSummary::on($db);
+
+ $this->handleSearchRequest($servicegroups);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($servicegroups);
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+
+ $defaultSort = null;
+ if ($viewModeSwitcher->getViewMode() === 'grid') {
+ $defaultSort = ['services_severity DESC', 'display_name'];
+ }
+
+ $sortControl = $this->createSortControl(
+ $servicegroups,
+ [
+ 'display_name' => t('Name'),
+ 'services_severity desc, display_name' => t('Severity'),
+ 'services_total desc' => t('Total Services')
+ ],
+ $defaultSort
+ );
+
+ $searchBar = $this->createSearchBar($servicegroups, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($servicegroups, $filter);
+
+ $servicegroups->peekAhead($compact);
+
+ yield $this->export($servicegroups);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+
+ $results = $servicegroups->execute();
+
+ $this->addContent(
+ (new ServicegroupTable($results))
+ ->setBaseFilter($filter)
+ ->setViewMode($viewModeSwitcher->getViewMode())
+ );
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d servicegroups'),
+ $servicegroups->count()
+ ))
+ );
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(30);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Servicegroup::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(ServicegroupSummary::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php
new file mode 100644
index 0000000..c39f8b5
--- /dev/null
+++ b/application/controllers/ServicesController.php
@@ -0,0 +1,436 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Common\CommandActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Data\PivotTable;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Util\FeatureStatus;
+use Icinga\Module\Icingadb\Web\Control\ProblemToggle;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\MultiselectQuickActions;
+use Icinga\Module\Icingadb\Widget\Detail\ObjectsDetail;
+use Icinga\Module\Icingadb\Widget\ItemList\ServiceList;
+use Icinga\Module\Icingadb\Widget\ItemTable\ServiceItemTable;
+use Icinga\Module\Icingadb\Widget\ServiceStatusBar;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use Icinga\Util\Environment;
+use ipl\Html\HtmlString;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Url;
+
+class ServicesController extends Controller
+{
+ use CommandActions;
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Services'));
+ $compact = $this->view->compact;
+
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'state.last_comment',
+ 'host',
+ 'host.state',
+ 'icon_image'
+ ]);
+ $services->getWith()['service.state']->setJoinType('INNER');
+ $services->setResultSetClass(VolatileStateResults::class);
+
+ $this->handleSearchRequest($services);
+
+ $summary = null;
+ if (! $compact) {
+ $summary = ServicestateSummary::on($db);
+ }
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($services);
+ $sortControl = $this->createSortControl(
+ $services,
+ [
+ 'service.display_name' => t('Name'),
+ 'service.state.severity desc,service.state.last_state_change desc' => t('Severity'),
+ 'service.state.soft_state' => t('Current State'),
+ 'service.state.last_state_change desc' => t('Last State Change'),
+ 'host.display_name' => t('Host')
+ ]
+ );
+ $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl);
+ $columns = $this->createColumnControl($services, $viewModeSwitcher);
+
+ $searchBar = $this->createSearchBar($services, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam(),
+ $viewModeSwitcher->getViewModeParam(),
+ 'columns'
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $services->peekAhead($compact);
+
+ $this->filter($services, $filter);
+ if (! $compact) {
+ $this->filter($summary, $filter);
+ yield $this->export($services, $summary);
+ } else {
+ yield $this->export($services);
+ }
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($viewModeSwitcher);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::servicesDetails(), $searchBar);
+
+ $results = $services->execute();
+
+ if ($viewModeSwitcher->getViewMode() === 'tabular') {
+ $serviceList = (new ServiceItemTable($results, ServiceItemTable::applyColumnMetaData($services, $columns)))
+ ->setSort($sortControl->getSort());
+ } else {
+ $serviceList = (new ServiceList($results))
+ ->setViewMode($viewModeSwitcher->getViewMode());
+ }
+
+ $this->addContent($serviceList);
+
+ if ($compact) {
+ $this->addContent(
+ (new ShowMore($results, Url::fromRequest()->without(['showCompact', 'limit', 'view'])))
+ ->setBaseTarget('_next')
+ ->setAttribute('title', sprintf(
+ t('Show all %d services'),
+ $services->count()
+ ))
+ );
+ } else {
+ /** @var ServicestateSummary $servicesSummary */
+ $servicesSummary = $summary->first();
+ $this->addFooter((new ServiceStatusBar($servicesSummary))->setBaseFilter($filter));
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function detailsAction()
+ {
+ $this->addTitleTab(t('Services'));
+
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'icon_image',
+ 'host',
+ 'host.state'
+ ]);
+ $services->setResultSetClass(VolatileStateResults::class);
+ $summary = ServicestateSummary::on($db)->with(['state']);
+
+ $this->filter($services);
+ $this->filter($summary);
+
+ $services->limit(3);
+ $services->peekAhead();
+
+ yield $this->export($services, $summary);
+
+ $results = $services->execute();
+ $summary = $summary->first();
+
+ $downtimes = Service::on($db)->with(['downtime']);
+ $downtimes->getWith()['service.downtime']->setJoinType('INNER');
+ $this->filter($downtimes);
+ $summary->downtimes_total = $downtimes->count();
+
+ $comments = Service::on($db)->with(['comment']);
+ $comments->getWith()['service.comment']->setJoinType('INNER');
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $comments->filter(Filter::equal('comment.object_type', 'service'));
+ $this->filter($comments);
+ $summary->comments_total = $comments->count();
+
+ $this->addControl(
+ (new ServiceList($results))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ );
+ $this->addControl(new ShowMore(
+ $results,
+ Links::services()->setFilter($this->getFilter()),
+ sprintf(t('Show all %d services'), $services->count())
+ ));
+ $this->addControl(
+ (new MultiselectQuickActions('service', $summary))
+ ->setBaseFilter($this->getFilter())
+ );
+
+ $this->addContent(
+ (new ObjectsDetail('service', $summary, $services))
+ ->setBaseFilter($this->getFilter())
+ );
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Service::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Service::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM,
+ 'columns'
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+
+ public function gridAction()
+ {
+ Environment::raiseExecutionTime();
+
+ $db = $this->getDb();
+ $this->addTitleTab(t('Service Grid'));
+
+ $query = Service::on($db)->with([
+ 'state',
+ 'host',
+ 'host.state'
+ ]);
+ $query->setResultSetClass(VolatileStateResults::class);
+
+ $this->handleSearchRequest($query);
+
+ $this->params->shift('page'); // Handled by PivotTable internally
+ $this->params->shift('limit'); // Handled by PivotTable internally
+ $flipped = $this->params->shift('flipped', false);
+
+ $problemToggle = $this->createProblemToggle();
+ $sortControl = $this->createSortControl($query, [
+ 'service.display_name' => t('Service Name'),
+ 'host.display_name' => t('Host Name'),
+ ])->setDefault('service.display_name');
+ $searchBar = $this->createSearchBar($query, [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ $sortControl->getSortParam(),
+ 'flipped',
+ 'page',
+ 'problems'
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($query, $filter);
+
+ $this->addControl($problemToggle);
+ $this->addControl($sortControl);
+ $this->addControl($searchBar);
+ $continueWith = $this->createContinueWith(Links::servicesDetails(), $searchBar);
+
+ $pivotFilter = $problemToggle->isChecked() ?
+ Filter::equal('service.state.is_problem', 'y') : null;
+
+ $columns = [
+ 'id',
+ 'host.id',
+ 'host_name' => 'host.name',
+ 'host_display_name' => 'host.display_name',
+ 'name' => 'service.name',
+ 'display_name' => 'service.display_name',
+ 'service.state.is_handled',
+ 'service.state.output',
+ 'service.state.soft_state'
+ ];
+
+ if ($flipped) {
+ $pivot = (new PivotTable($query, 'host_name', 'name', $columns))
+ ->setXAxisFilter($pivotFilter)
+ ->setYAxisFilter($pivotFilter ? clone $pivotFilter : null)
+ ->setXAxisHeader('host_display_name')
+ ->setYAxisHeader('display_name');
+ } else {
+ $pivot = (new PivotTable($query, 'name', 'host_name', $columns))
+ ->setXAxisFilter($pivotFilter)
+ ->setYAxisFilter($pivotFilter ? clone $pivotFilter : null)
+ ->setXAxisHeader('display_name')
+ ->setYAxisHeader('host_display_name');
+ }
+
+ $this->view->horizontalPaginator = $pivot->paginateXAxis();
+ $this->view->verticalPaginator = $pivot->paginateYAxis();
+ list($pivotData, $pivotHeader) = $pivot->toArray();
+ $this->view->pivotData = $pivotData;
+ $this->view->pivotHeader = $pivotHeader;
+
+ /** Preserve filter and params in view links (the `BaseFilter` implementation for view scripts -.-) */
+ $this->view->baseUrl = Url::fromRequest()
+ ->onlyWith([
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ $sortControl->getSortParam(),
+ 'flipped',
+ 'page',
+ 'problems'
+ ]);
+ $preservedParams = $this->view->baseUrl->getParams();
+ $this->view->baseUrl->setFilter($filter);
+
+ $searchBar->setEditorUrl(Url::fromPath(
+ "icingadb/services/grid-search-editor"
+ )->setParams($preservedParams));
+
+ $this->view->controls = $this->controls;
+
+ if ($flipped) {
+ $this->getHelper('viewRenderer')->setScriptAction('grid-flipped');
+ }
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ // TODO: Everything up to addContent() (inclusive) can be removed once the grid is a widget
+ $this->view->controls = ''; // Relevant controls are transmitted separately
+ $viewRenderer = $this->getHelper('viewRenderer');
+ $viewRenderer->postDispatch();
+ $viewRenderer->setNoRender(false);
+
+ $content = trim($this->getResponse());
+ $this->getResponse()->clearBody($viewRenderer->getResponseSegment());
+
+ $this->addContent(HtmlString::create(substr($content, strpos($content, '>') + 1, -6)));
+
+ $this->sendMultipartUpdate($continueWith);
+ }
+
+ $this->setAutorefreshInterval(30);
+ }
+
+ public function gridSearchEditorAction()
+ {
+ $editor = $this->createSearchEditor(
+ Service::on($this->getDb()),
+ Url::fromPath('icingadb/services/grid'),
+ [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ 'flipped',
+ 'page',
+ 'problems'
+ ]
+ );
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+
+ protected function fetchCommandTargets(): Query
+ {
+ $db = $this->getDb();
+
+ $services = Service::on($db)->with([
+ 'state',
+ 'host',
+ 'host.state'
+ ]);
+ $services->setResultSetClass(VolatileStateResults::class);
+
+ switch ($this->getRequest()->getActionName()) {
+ case 'acknowledge':
+ $services->filter(Filter::equal('state.is_problem', 'y'))
+ ->filter(Filter::equal('state.is_acknowledged', 'n'));
+
+ break;
+ }
+
+ $this->filter($services);
+
+ return $services;
+ }
+
+ protected function getCommandTargetsUrl(): Url
+ {
+ return Links::servicesDetails()->setFilter($this->getFilter());
+ }
+
+ protected function getFeatureStatus()
+ {
+ $summary = ServicestateSummary::on($this->getDb());
+ $this->filter($summary);
+
+ return new FeatureStatus('service', $summary->first());
+ }
+
+ protected function prepareSearchFilter(Query $query, string $search, Filter\Any $filter, array $additionalColumns)
+ {
+ if ($this->params->shift('_hostFilterOnly', false)) {
+ foreach (['host.name_ci', 'host.display_name', 'host.address', 'host.address6'] as $column) {
+ $filter->add(Filter::like($column, "*$search*"));
+ }
+ } else {
+ parent::prepareSearchFilter($query, $search, $filter, $additionalColumns);
+ }
+ }
+
+ public function createProblemToggle(): ProblemToggle
+ {
+ $filter = $this->params->shift('problems');
+
+ $problemToggle = new ProblemToggle($filter);
+ $problemToggle->setIdProtector([$this->getRequest(), 'protectId']);
+
+ $problemToggle->on(ProblemToggle::ON_SUCCESS, function (ProblemToggle $form) {
+ if (! $form->getElement('problems')->isChecked()) {
+ $this->redirectNow(Url::fromRequest()->remove('problems'));
+ } else {
+ $this->redirectNow(Url::fromRequest()->setParams($this->params->add('problems')));
+ }
+ })->handleRequest(ServerRequest::fromGlobals());
+
+ return $problemToggle;
+ }
+}
diff --git a/application/controllers/TacticalController.php b/application/controllers/TacticalController.php
new file mode 100644
index 0000000..b8d3757
--- /dev/null
+++ b/application/controllers/TacticalController.php
@@ -0,0 +1,94 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\HostSummaryDonut;
+use Icinga\Module\Icingadb\Widget\ServiceSummaryDonut;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class TacticalController extends Controller
+{
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Tactical Overview'));
+
+ $db = $this->getDb();
+
+ $hoststateSummary = HoststateSummary::on($db);
+ $servicestateSummary = ServicestateSummary::on($db);
+
+ $this->handleSearchRequest($servicestateSummary, [
+ 'host.name_ci',
+ 'host.display_name',
+ 'host.address',
+ 'host.address6'
+ ]);
+
+ $searchBar = $this->createSearchBar($servicestateSummary);
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($hoststateSummary, $filter);
+ $this->filter($servicestateSummary, $filter);
+
+ yield $this->export($hoststateSummary, $servicestateSummary);
+
+ $this->addControl($searchBar);
+
+ $this->addContent(
+ (new HostSummaryDonut($hoststateSummary->first()))
+ ->setBaseFilter($filter)
+ );
+
+ $this->addContent(
+ (new ServiceSummaryDonut($servicestateSummary->first()))
+ ->setBaseFilter($filter)
+ );
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(ServicestateSummary::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(ServicestateSummary::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/UserController.php b/application/controllers/UserController.php
new file mode 100644
index 0000000..9321965
--- /dev/null
+++ b/application/controllers/UserController.php
@@ -0,0 +1,48 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\UserDetail;
+use Icinga\Module\Icingadb\Widget\ItemTable\UserTableRow;
+use ipl\Stdlib\Filter;
+
+class UserController extends Controller
+{
+ /** @var User The user object */
+ protected $user;
+
+ public function init()
+ {
+ $this->assertRouteAccess('users');
+
+ $this->addTitleTab(t('User'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = User::on($this->getDb());
+ $query->filter(Filter::equal('user.name', $name));
+
+ $this->applyRestrictions($query);
+
+ $user = $query->first();
+ if ($user === null) {
+ throw new NotFoundError(t('User not found'));
+ }
+
+ $this->user = $user;
+ $this->setTitle($user->display_name);
+ }
+
+ public function indexAction()
+ {
+ $this->addControl(new UserTableRow($this->user));
+ $this->addContent(new UserDetail($this->user));
+
+ $this->setAutorefreshInterval(10);
+ }
+}
diff --git a/application/controllers/UsergroupController.php b/application/controllers/UsergroupController.php
new file mode 100644
index 0000000..8c3fed8
--- /dev/null
+++ b/application/controllers/UsergroupController.php
@@ -0,0 +1,48 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\Detail\UsergroupDetail;
+use Icinga\Module\Icingadb\Widget\ItemTable\UsergroupTableRow;
+use ipl\Stdlib\Filter;
+
+class UsergroupController extends Controller
+{
+ /** @var Usergroup The usergroup object */
+ protected $usergroup;
+
+ public function init()
+ {
+ $this->assertRouteAccess('usergroups');
+
+ $this->addTitleTab(t('User Group'));
+
+ $name = $this->params->getRequired('name');
+
+ $query = Usergroup::on($this->getDb());
+ $query->filter(Filter::equal('usergroup.name', $name));
+
+ $this->applyRestrictions($query);
+
+ $usergroup = $query->first();
+ if ($usergroup === null) {
+ throw new NotFoundError(t('User group not found'));
+ }
+
+ $this->usergroup = $usergroup;
+ $this->setTitle($usergroup->display_name);
+ }
+
+ public function indexAction()
+ {
+ $this->addControl(new UsergroupTableRow($this->usergroup));
+ $this->addContent(new UsergroupDetail($this->usergroup));
+
+ $this->setAutorefreshInterval(10);
+ }
+}
diff --git a/application/controllers/UsergroupsController.php b/application/controllers/UsergroupsController.php
new file mode 100644
index 0000000..99a73a9
--- /dev/null
+++ b/application/controllers/UsergroupsController.php
@@ -0,0 +1,95 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemTable\UsergroupTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class UsergroupsController extends Controller
+{
+ public function init()
+ {
+ parent::init();
+
+ $this->assertRouteAccess();
+ }
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('User Groups'));
+
+ $db = $this->getDb();
+
+ $usergroups = Usergroup::on($db);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($usergroups);
+ $sortControl = $this->createSortControl(
+ $usergroups,
+ [
+ 'usergroup.display_name' => t('Name')
+ ]
+ );
+ $searchBar = $this->createSearchBar($usergroups, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($usergroups, $filter);
+
+ yield $this->export($usergroups);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $this->addContent(new UsergroupTable($usergroups));
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(Usergroup::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(Usergroup::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/controllers/UsersController.php b/application/controllers/UsersController.php
new file mode 100644
index 0000000..83ee96d
--- /dev/null
+++ b/application/controllers/UsersController.php
@@ -0,0 +1,97 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemTable\UserTable;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+
+class UsersController extends Controller
+{
+ public function init()
+ {
+ parent::init();
+
+ $this->assertRouteAccess();
+ }
+
+ public function indexAction()
+ {
+ $this->addTitleTab(t('Users'));
+
+ $db = $this->getDb();
+
+ $users = User::on($db);
+
+ $limitControl = $this->createLimitControl();
+ $paginationControl = $this->createPaginationControl($users);
+ $sortControl = $this->createSortControl(
+ $users,
+ [
+ 'user.display_name' => t('Name'),
+ 'user.email' => t('Email'),
+ 'user.pager' => t('Pager Address / Number')
+ ]
+ );
+ $searchBar = $this->createSearchBar($users, [
+ $limitControl->getLimitParam(),
+ $sortControl->getSortParam()
+ ]);
+
+ if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) {
+ if ($searchBar->hasBeenSubmitted()) {
+ $filter = $this->getFilter();
+ } else {
+ $this->addControl($searchBar);
+ $this->sendMultipartUpdate();
+ return;
+ }
+ } else {
+ $filter = $searchBar->getFilter();
+ }
+
+ $this->filter($users, $filter);
+
+ yield $this->export($users);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $this->addContent(new UserTable($users));
+
+ if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) {
+ $this->sendMultipartUpdate();
+ }
+
+ $this->setAutorefreshInterval(10);
+ }
+
+ public function completeAction()
+ {
+ $suggestions = new ObjectSuggestions();
+ $suggestions->setModel(User::class);
+ $suggestions->forRequest(ServerRequest::fromGlobals());
+ $this->getDocument()->add($suggestions);
+ }
+
+ public function searchEditorAction()
+ {
+ $editor = $this->createSearchEditor(User::on($this->getDb()), [
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ SortControl::DEFAULT_SORT_PARAM,
+ ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM
+ ]);
+
+ $this->getDocument()->add($editor);
+ $this->setTitle(t('Adjust Filter'));
+ }
+}
diff --git a/application/forms/ApiTransportForm.php b/application/forms/ApiTransportForm.php
new file mode 100644
index 0000000..27c147b
--- /dev/null
+++ b/application/forms/ApiTransportForm.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransportException;
+use Icinga\Web\Session;
+use ipl\Web\Common\CsrfCounterMeasure;
+use ipl\Web\Compat\CompatForm;
+
+class ApiTransportForm extends CompatForm
+{
+ use CsrfCounterMeasure;
+
+ protected function assemble()
+ {
+ // TODO: Use a validator to check if a name is not already in use
+ $this->addElement('text', 'name', [
+ 'required' => true,
+ 'label' => t('Transport Name')
+ ]);
+
+ $this->addElement('hidden', 'transport', [
+ 'value' => 'api'
+ ]);
+
+ $this->addElement('text', 'host', [
+ 'required' => true,
+ 'id' => 'api_transport_host',
+ 'label' => t('Host'),
+ 'description' => t('Hostname or address of the Icinga master')
+ ]);
+
+ // TODO: Don't rely only on browser validation
+ $this->addElement('number', 'port', [
+ 'required' => true,
+ 'label' => t('Port'),
+ 'value' => 5665,
+ 'min' => 1,
+ 'max' => 65536
+ ]);
+
+ $this->addElement('text', 'username', [
+ 'required' => true,
+ 'label' => t('API Username'),
+ 'description' => t('User to authenticate with using HTTP Basic Auth')
+ ]);
+
+ $this->addElement('password', 'password', [
+ 'required' => true,
+ 'autocomplete' => 'new-password',
+ 'label' => t('API Password')
+ ]);
+
+ $this->addElement('submit', 'btn_submit', [
+ 'label' => t('Save')
+ ]);
+
+ $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId()));
+ }
+
+ public function validate()
+ {
+ parent::validate();
+ if (! $this->isValid) {
+ return $this;
+ }
+
+ if ($this->getPopulatedValue('force_creation') === 'y') {
+ return $this;
+ }
+
+ try {
+ CommandTransport::createTransport(new ConfigObject($this->getValues()))->probe();
+ } catch (CommandTransportException $e) {
+ $this->addMessage(
+ sprintf(t('Failed to successfully validate the configuration: %s'), $e->getMessage())
+ );
+
+ $forceCheckbox = $this->createElement(
+ 'checkbox',
+ 'force_creation',
+ [
+ 'ignore' => true,
+ 'label' => t('Force Changes'),
+ 'description' => t('Check this box to enforce changes without connectivity validation')
+ ]
+ );
+
+ $this->registerElement($forceCheckbox);
+ $this->decorate($forceCheckbox);
+ $this->prepend($forceCheckbox);
+
+ $this->isValid = false;
+ }
+
+ return $this;
+ }
+}
diff --git a/application/forms/Command/CommandForm.php b/application/forms/Command/CommandForm.php
new file mode 100644
index 0000000..a535c6d
--- /dev/null
+++ b/application/forms/Command/CommandForm.php
@@ -0,0 +1,179 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command;
+
+use ArrayIterator;
+use Exception;
+use Generator;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Web\Notification;
+use Icinga\Web\Session;
+use ipl\Html\Form;
+use ipl\Orm\Model;
+use ipl\Web\Common\CsrfCounterMeasure;
+use Traversable;
+
+abstract class CommandForm extends Form
+{
+ use Auth;
+ use CsrfCounterMeasure;
+
+ protected $defaultAttributes = ['class' => 'icinga-form icinga-controls'];
+
+ /** @var mixed */
+ protected $objects;
+
+ /** @var bool */
+ protected $isApiTarget = false;
+
+ /**
+ * Whether an error occurred while sending the command
+ *
+ * Prevents the success message from being rendered simultaneously
+ *
+ * @var bool
+ */
+ protected $errorOccurred = false;
+
+ /**
+ * Set the objects to issue the command for
+ *
+ * @param mixed $objects A traversable that is also countable
+ *
+ * @return $this
+ */
+ public function setObjects($objects): self
+ {
+ $this->objects = $objects;
+
+ return $this;
+ }
+
+ /**
+ * Get the objects to issue the command for
+ *
+ * @return mixed
+ */
+ public function getObjects()
+ {
+ return $this->objects;
+ }
+
+ /**
+ * Set whether this form is an API target
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setIsApiTarget(bool $state = true): self
+ {
+ $this->isApiTarget = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether this form is an API target
+ *
+ * @return bool
+ */
+ public function isApiTarget(): bool
+ {
+ return $this->isApiTarget;
+ }
+
+ /**
+ * Create and add form elements representing the command's options
+ *
+ * @return void
+ */
+ abstract protected function assembleElements();
+
+ /**
+ * Create and add a submit button to the form
+ *
+ * @return void
+ */
+ abstract protected function assembleSubmitButton();
+
+ /**
+ * Get the commands to issue for the given objects
+ *
+ * @param Traversable<Model> $objects
+ *
+ * @return Traversable<IcingaCommand>
+ */
+ abstract protected function getCommands(Traversable $objects): Traversable;
+
+ protected function assemble()
+ {
+ $this->assembleElements();
+
+ if (! $this->isApiTarget()) {
+ $this->assembleSubmitButton();
+ $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId()));
+ }
+ }
+
+ protected function onSuccess()
+ {
+ $errors = [];
+ $objects = $this->getObjects();
+
+ foreach ($this->getCommands(is_array($objects) ? new ArrayIterator($objects) : $objects) as $command) {
+ try {
+ $this->sendCommand($command);
+ } catch (Exception $e) {
+ Logger::error($e->getMessage());
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if (! empty($errors)) {
+ if (count($errors) > 1) {
+ Notification::warning(
+ t('Some commands were not transmitted. Please check the log. The first error follows.')
+ );
+ }
+
+ $this->errorOccurred = true;
+
+ Notification::error($errors[0]);
+ }
+ }
+
+ /**
+ * Transmit the given command
+ *
+ * @param IcingaCommand $command
+ *
+ * @return void
+ */
+ protected function sendCommand(IcingaCommand $command)
+ {
+ (new CommandTransport())->send($command);
+ }
+
+ /**
+ * Yield the $objects the currently logged in user has the permission $permission for
+ *
+ * @param string $permission
+ * @param Traversable $objects
+ *
+ * @return Generator
+ */
+ protected function filterGrantedOn(string $permission, Traversable $objects): Generator
+ {
+ foreach ($objects as $object) {
+ if ($this->isGrantedOn($permission, $object)) {
+ yield $object;
+ }
+ }
+ }
+}
diff --git a/application/forms/Command/Instance/ToggleInstanceFeaturesForm.php b/application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
new file mode 100644
index 0000000..cf14db8
--- /dev/null
+++ b/application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
@@ -0,0 +1,154 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Instance;
+
+use Icinga\Module\Icingadb\Command\Instance\ToggleInstanceFeatureCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use Traversable;
+
+class ToggleInstanceFeaturesForm extends CommandForm
+{
+ protected $features;
+
+ protected $featureStatus;
+
+ /**
+ * ToggleFeature(s) being used to submit this form
+ *
+ * @var ToggleInstanceFeatureCommand[]
+ */
+ protected $submittedFeatures = [];
+
+ public function __construct(array $featureStatus)
+ {
+ $this->featureStatus = $featureStatus;
+ $this->features = [
+ ToggleInstanceFeatureCommand::FEATURE_ACTIVE_HOST_CHECKS =>
+ t('Active Host Checks'),
+ ToggleInstanceFeatureCommand::FEATURE_ACTIVE_SERVICE_CHECKS =>
+ t('Active Service Checks'),
+ ToggleInstanceFeatureCommand::FEATURE_EVENT_HANDLERS =>
+ t('Event Handlers'),
+ ToggleInstanceFeatureCommand::FEATURE_FLAP_DETECTION =>
+ t('Flap Detection'),
+ ToggleInstanceFeatureCommand::FEATURE_NOTIFICATIONS =>
+ t('Notifications'),
+ ToggleInstanceFeatureCommand::FEATURE_PERFORMANCE_DATA =>
+ t('Performance Data')
+ ];
+
+ $this->getAttributes()->add('class', 'instance-features');
+
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ foreach ($this->submittedFeatures as $feature) {
+ $enabled = $feature->getEnabled();
+ switch ($feature->getFeature()) {
+ case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_HOST_CHECKS:
+ if ($enabled) {
+ $message = t('Enabled active host checks successfully');
+ } else {
+ $message = t('Disabled active host checks successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_SERVICE_CHECKS:
+ if ($enabled) {
+ $message = t('Enabled active service checks successfully');
+ } else {
+ $message = t('Disabled active service checks successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_EVENT_HANDLERS:
+ if ($enabled) {
+ $message = t('Enabled event handlers successfully');
+ } else {
+ $message = t('Disabled event handlers checks successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_FLAP_DETECTION:
+ if ($enabled) {
+ $message = t('Enabled flap detection successfully');
+ } else {
+ $message = t('Disabled flap detection successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_NOTIFICATIONS:
+ if ($enabled) {
+ $message = t('Enabled notifications successfully');
+ } else {
+ $message = t('Disabled notifications successfully');
+ }
+
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_PERFORMANCE_DATA:
+ if ($enabled) {
+ $message = t('Enabled performance data successfully');
+ } else {
+ $message = t('Disabled performance data successfully');
+ }
+
+ break;
+ default:
+ $message = t('Invalid feature option');
+ break;
+ }
+
+ Notification::success($message);
+ }
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $disabled = ! $this->getAuth()->hasPermission('icingadb/command/feature/instance');
+ $decorator = new IcingaFormDecorator();
+
+ foreach ($this->features as $feature => $label) {
+ $this->addElement(
+ 'checkbox',
+ $feature,
+ [
+ 'class' => 'autosubmit',
+ 'label' => $label,
+ 'disabled' => $disabled,
+ 'value' => (bool) $this->featureStatus[$feature]
+ ]
+ );
+ $decorator->decorate($this->getElement($feature));
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ foreach ($this->features as $feature => $spec) {
+ $featureState = $this->getElement($feature)->isChecked();
+
+ if ((int) $featureState === (int) $this->featureStatus[$feature]) {
+ continue;
+ }
+
+ $command = new ToggleInstanceFeatureCommand();
+ $command->setFeature($feature);
+ $command->setEnabled($featureState);
+
+ $this->submittedFeatures[] = $command;
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/AcknowledgeProblemForm.php b/application/forms/Command/Object/AcknowledgeProblemForm.php
new file mode 100644
index 0000000..81b93e2
--- /dev/null
+++ b/application/forms/Command/Object/AcknowledgeProblemForm.php
@@ -0,0 +1,210 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\AcknowledgeProblemCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class AcknowledgeProblemForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(tp(
+ 'Acknowledged problem successfully',
+ 'Acknowledged problem on %d hosts successfully',
+ $countObjects
+ ), $countObjects);
+ } else {
+ $message = sprintf(tp(
+ 'Acknowledged problem successfully',
+ 'Acknowledged problem on %d services successfully',
+ $countObjects
+ ), $countObjects);
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement('li', null, Text::create(t(
+ 'This command is used to acknowledge host or service problems. When a problem is acknowledged,'
+ . ' future notifications about problems are temporarily disabled until the host or service'
+ . ' recovers.'
+ )))
+ )
+ ));
+
+ $config = Config::module('icingadb');
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'textarea',
+ 'comment',
+ [
+ 'required' => true,
+ 'label' => t('Comment'),
+ 'description' => t(
+ 'If you work with other administrators, you may find it useful to share information about'
+ . ' the host or service that is having problems. Make sure you enter a brief description of'
+ . ' what you are doing.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('comment'));
+
+ $this->addElement(
+ 'checkbox',
+ 'persistent',
+ [
+ 'label' => t('Persistent Comment'),
+ 'value' => (bool) $config->get('settings', 'acknowledge_persistent', false),
+ 'description' => t(
+ 'If you want the comment to remain even when the acknowledgement is removed, check this'
+ . ' option.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('persistent'));
+
+ $this->addElement(
+ 'checkbox',
+ 'notify',
+ [
+ 'label' => t('Send Notification'),
+ 'value' => (bool) $config->get('settings', 'acknowledge_notify', true),
+ 'description' => t(
+ 'If you want an acknowledgement notification to be sent out to the appropriate contacts,'
+ . ' check this option.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('notify'));
+
+ $this->addElement(
+ 'checkbox',
+ 'sticky',
+ [
+ 'label' => t('Sticky Acknowledgement'),
+ 'value' => (bool) $config->get('settings', 'acknowledge_sticky', false),
+ 'description' => t(
+ 'If you want the acknowledgement to remain until the host or service recovers even if the host'
+ . ' or service changes state, check this option.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('sticky'));
+
+ $this->addElement(
+ 'checkbox',
+ 'expire',
+ [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'value' => (bool) $config->get('settings', 'acknowledge_expire', false),
+ 'label' => t('Use Expire Time'),
+ 'description' => t('If the acknowledgement should expire, check this option.')
+ ]
+ );
+ $decorator->decorate($this->getElement('expire'));
+
+ if ($this->getElement('expire')->isChecked()) {
+ $expireTime = new DateTime();
+ $expireTime->add(new DateInterval($config->get('settings', 'acknowledge_expire_time', 'PT1H')));
+
+ $this->addElement(
+ 'localDateTime',
+ 'expire_time',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'value' => $expireTime,
+ 'label' => t('Expire Time'),
+ 'description' => t(
+ 'Choose the date and time when Icinga should delete the acknowledgement.'
+ ),
+ 'validators' => [
+ 'DateTime' => ['break_chain_on_failure' => true],
+ 'Callback' => function ($value, $validator) {
+ /** @var CallbackValidator $validator */
+ if ($value <= (new DateTime())) {
+ $validator->addMessage(t('The expire time must not be in the past'));
+ return false;
+ }
+
+ return true;
+ }
+ ]
+ ]
+ );
+ $decorator->decorate($this->getElement('expire_time'));
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Acknowledge problem', 'Acknowledge problems', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/acknowledge-problem', $objects);
+
+ if ($granted->valid()) {
+ $command = new AcknowledgeProblemCommand();
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+ $command->setNotify($this->getElement('notify')->isChecked());
+ $command->setSticky($this->getElement('sticky')->isChecked());
+ $command->setPersistent($this->getElement('persistent')->isChecked());
+
+ if (($expireTime = $this->getValue('expire_time')) !== null) {
+ /** @var DateTime $expireTime */
+ $command->setExpireTime($expireTime->getTimestamp());
+ }
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/AddCommentForm.php b/application/forms/Command/Object/AddCommentForm.php
new file mode 100644
index 0000000..9cd0754
--- /dev/null
+++ b/application/forms/Command/Object/AddCommentForm.php
@@ -0,0 +1,162 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\AddCommentCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class AddCommentForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(
+ tp('Added comment successfully', 'Added comment to %d hosts successfully', $countObjects),
+ $countObjects
+ );
+ } else {
+ $message = sprintf(
+ tp('Added comment successfully', 'Added comment to %d services successfully', $countObjects),
+ $countObjects
+ );
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t('This command is used to add host or service comments.'))
+ )
+ )
+ ));
+
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'textarea',
+ 'comment',
+ [
+ 'required' => true,
+ 'label' => t('Comment'),
+ 'description' => t(
+ 'If you work with other administrators, you may find it useful to share information about'
+ . ' the host or service that is having problems. Make sure you enter a brief description of'
+ . ' what you are doing.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('comment'));
+
+ $config = Config::module('icingadb');
+
+ $this->addElement(
+ 'checkbox',
+ 'expire',
+ [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'value' => (bool) $config->get('settings', 'comment_expire', false),
+ 'label' => t('Use Expire Time'),
+ 'description' => t('If the comment should expire, check this option.')
+ ]
+ );
+ $decorator->decorate($this->getElement('expire'));
+
+ if ($this->getElement('expire')->isChecked()) {
+ $expireTime = new DateTime();
+ $expireTime->add(new DateInterval($config->get('settings', 'comment_expire_time', 'PT1H')));
+
+ $this->addElement(
+ 'localDateTime',
+ 'expire_time',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'value' => $expireTime,
+ 'label' => t('Expire Time'),
+ 'description' => t('Choose the date and time when Icinga should delete the comment.'),
+ 'validators' => [
+ 'DateTime' => ['break_chain_on_failure' => true],
+ 'Callback' => function ($value, $validator) {
+ /** @var CallbackValidator $validator */
+ if ($value <= (new DateTime())) {
+ $validator->addMessage(t('The expire time must not be in the past'));
+ return false;
+ }
+
+ return true;
+ }
+ ]
+ ]
+ );
+ $decorator->decorate($this->getElement('expire_time'));
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Add comment', 'Add comments', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/comment/add', $objects);
+
+ if ($granted->valid()) {
+ $command = new AddCommentCommand();
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ if (($expireTime = $this->getValue('expire_time'))) {
+ /** @var DateTime $expireTime */
+ $command->setExpireTime($expireTime->getTimestamp());
+ }
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/CheckNowForm.php b/application/forms/Command/Object/CheckNowForm.php
new file mode 100644
index 0000000..b7a506c
--- /dev/null
+++ b/application/forms/Command/Object/CheckNowForm.php
@@ -0,0 +1,72 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\ScheduleCheckCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+class CheckNowForm extends CommandForm
+{
+ protected $defaultAttributes = ['class' => 'inline'];
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if (! $this->errorOccurred) {
+ Notification::success(tp('Scheduling check..', 'Scheduling checks..', count($this->getObjects())));
+ }
+ });
+ }
+
+ protected function assembleElements()
+ {
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submitButton',
+ 'btn_submit',
+ [
+ 'class' => ['link-button', 'spinner'],
+ 'label' => [
+ new Icon('sync-alt'),
+ t('Check Now')
+ ],
+ 'title' => t('Schedule the next active check to run immediately')
+ ]
+ );
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($objects as $object) {
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $object)
+ || (
+ $object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $object)
+ )
+ ) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new ScheduleCheckCommand();
+ $command->setObjects($granted);
+ $command->setCheckTime(time());
+ $command->setForced();
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/DeleteCommentForm.php b/application/forms/Command/Object/DeleteCommentForm.php
new file mode 100644
index 0000000..25275ba
--- /dev/null
+++ b/application/forms/Command/Object/DeleteCommentForm.php
@@ -0,0 +1,75 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\DeleteCommentCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Web\Common\RedirectOption;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+class DeleteCommentForm extends CommandForm
+{
+ use RedirectOption;
+
+ protected $defaultAttributes = ['class' => 'inline'];
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+
+ Notification::success(sprintf(
+ tp('Removed comment successfully', 'Removed comment from %d objects successfully', $countObjects),
+ $countObjects
+ ));
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addElement($this->createRedirectOption());
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submitButton',
+ 'btn_submit',
+ [
+ 'class' => ['cancel-button', 'spinner'],
+ 'label' => [
+ new Icon('trash'),
+ tp('Remove Comment', 'Remove Comments', count($this->getObjects()))
+ ]
+ ]
+ );
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($objects as $object) {
+ if ($this->isGrantedOn('icingadb/command/comment/delete', $object->{$object->object_type})) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new DeleteCommentCommand();
+ $command->setObjects($granted);
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/DeleteDowntimeForm.php b/application/forms/Command/Object/DeleteDowntimeForm.php
new file mode 100644
index 0000000..5f695b9
--- /dev/null
+++ b/application/forms/Command/Object/DeleteDowntimeForm.php
@@ -0,0 +1,90 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\DeleteDowntimeCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Web\Common\RedirectOption;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+class DeleteDowntimeForm extends CommandForm
+{
+ use RedirectOption;
+
+ protected $defaultAttributes = ['class' => 'inline'];
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+
+ Notification::success(sprintf(
+ tp('Removed downtime successfully', 'Removed downtime from %d objects successfully', $countObjects),
+ $countObjects
+ ));
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addElement($this->createRedirectOption());
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $isDisabled = true;
+ foreach ($this->getObjects() as $downtime) {
+ if ($downtime->scheduled_by === null) {
+ $isDisabled = false;
+ break;
+ }
+ }
+
+ $this->addElement(
+ 'submitButton',
+ 'btn_submit',
+ [
+ 'class' => ['cancel-button', 'spinner'],
+ 'disabled' => $isDisabled ?: null,
+ 'title' => $isDisabled
+ ? t('Downtime cannot be removed at runtime because it is based on a configured scheduled downtime.')
+ : null,
+ 'label' => [
+ new Icon('trash'),
+ tp('Delete downtime', 'Delete downtimes', count($this->getObjects()))
+ ]
+ ]
+ );
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($objects as $object) {
+ if (
+ $this->isGrantedOn('icingadb/command/downtime/delete', $object->{$object->object_type})
+ && $object->scheduled_by === null
+ ) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new DeleteDowntimeCommand();
+ $command->setObjects($granted);
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ProcessCheckResultForm.php b/application/forms/Command/Object/ProcessCheckResultForm.php
new file mode 100644
index 0000000..5764bf8
--- /dev/null
+++ b/application/forms/Command/Object/ProcessCheckResultForm.php
@@ -0,0 +1,156 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\ProcessCheckResultCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class ProcessCheckResultForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(tp(
+ 'Submitted passive check result successfully',
+ 'Submitted passive check result for %d hosts successfully',
+ $countObjects
+ ), $countObjects);
+ } else {
+ $message = sprintf(tp(
+ 'Submitted passive check result successfully',
+ 'Submitted passive check result for %d services successfully',
+ $countObjects
+ ), $countObjects);
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t('This command is used to submit passive host or service check results.'))
+ )
+ )
+ ));
+
+ $decorator = new IcingaFormDecorator();
+
+ /** @var Model $object */
+ $object = iterable_value_first($this->getObjects());
+
+ $this->addElement(
+ 'select',
+ 'status',
+ [
+ 'required' => true,
+ 'label' => t('Status'),
+ 'description' => t('The state this check result should report'),
+ 'options' => $object instanceof Host ? [
+ ProcessCheckResultCommand::HOST_UP => t('UP', 'icinga.state'),
+ ProcessCheckResultCommand::HOST_DOWN => t('DOWN', 'icinga.state')
+ ] : [
+ ProcessCheckResultCommand::SERVICE_OK => t('OK', 'icinga.state'),
+ ProcessCheckResultCommand::SERVICE_WARNING => t('WARNING', 'icinga.state'),
+ ProcessCheckResultCommand::SERVICE_CRITICAL => t('CRITICAL', 'icinga.state'),
+ ProcessCheckResultCommand::SERVICE_UNKNOWN => t('UNKNOWN', 'icinga.state')
+ ]
+ ]
+ );
+ $decorator->decorate($this->getElement('status'));
+
+ $this->addElement(
+ 'text',
+ 'output',
+ [
+ 'required' => true,
+ 'label' => t('Output'),
+ 'description' => t('The plugin output of this check result')
+ ]
+ );
+ $decorator->decorate($this->getElement('output'));
+
+ $this->addElement(
+ 'text',
+ 'perfdata',
+ [
+ 'allowEmpty' => true,
+ 'label' => t('Performance Data'),
+ 'description' => t(
+ 'The performance data of this check result. Leave empty'
+ . ' if this check result has no performance data'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('perfdata'));
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp(
+ 'Submit Passive Check Result',
+ 'Submit Passive Check Results',
+ count($this->getObjects())
+ )
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($this->filterGrantedOn('icingadb/command/process-check-result', $objects) as $object) {
+ if ($object->passive_checks_enabled) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new ProcessCheckResultCommand();
+ $command->setObjects($granted);
+ $command->setStatus($this->getValue('status'));
+ $command->setOutput($this->getValue('output'));
+ $command->setPerformanceData($this->getValue('perfdata'));
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/RemoveAcknowledgementForm.php b/application/forms/Command/Object/RemoveAcknowledgementForm.php
new file mode 100644
index 0000000..8697985
--- /dev/null
+++ b/application/forms/Command/Object/RemoveAcknowledgementForm.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Module\Icingadb\Command\Object\RemoveAcknowledgementCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class RemoveAcknowledgementForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(tp(
+ 'Removed acknowledgment successfully',
+ 'Removed acknowledgment from %d hosts successfully',
+ $countObjects
+ ), $countObjects);
+ } else {
+ $message = sprintf(tp(
+ 'Removed acknowledgment successfully',
+ 'Removed acknowledgment from %d services successfully',
+ $countObjects
+ ), $countObjects);
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected $defaultAttributes = ['class' => 'inline'];
+
+ protected function assembleElements()
+ {
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submitButton',
+ 'btn_submit',
+ [
+ 'class' => ['link-button', 'spinner'],
+ 'label' => [
+ new Icon('trash'),
+ tp('Remove acknowledgement', 'Remove acknowledgements', count($this->getObjects()))
+ ]
+ ]
+ );
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/remove-acknowledgement', $objects);
+
+ if ($granted->valid()) {
+ $command = new RemoveAcknowledgementCommand();
+ $command->setObjects($granted);
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ScheduleCheckForm.php b/application/forms/Command/Object/ScheduleCheckForm.php
new file mode 100644
index 0000000..9b32ea1
--- /dev/null
+++ b/application/forms/Command/Object/ScheduleCheckForm.php
@@ -0,0 +1,137 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Generator;
+use Icinga\Module\Icingadb\Command\Object\ScheduleCheckCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class ScheduleCheckForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(
+ tp('Scheduled check successfully', 'Scheduled check for %d hosts successfully', $countObjects),
+ $countObjects
+ );
+ } else {
+ $message = sprintf(
+ tp('Scheduled check successfully', 'Scheduled check for %d services successfully', $countObjects),
+ $countObjects
+ );
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t(
+ 'This command is used to schedule the next check of hosts or services. Icinga'
+ . ' will re-queue the hosts or services to be checked at the time you specify.'
+ ))
+ )
+ )
+ ));
+
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'localDateTime',
+ 'check_time',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'label' => t('Check Time'),
+ 'description' => t('Set the date and time when the check should be scheduled.'),
+ 'value' => (new DateTime())->add(new DateInterval('PT1H'))
+ ]
+ );
+ $decorator->decorate($this->getElement('check_time'));
+
+ $this->addElement(
+ 'checkbox',
+ 'force_check',
+ [
+ 'label' => t('Force Check'),
+ 'description' => t(
+ 'If you select this option, Icinga will force a check regardless of both what time the'
+ . ' scheduled check occurs and whether or not checks are enabled.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('force_check'));
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Schedule check', 'Schedule checks', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = (function () use ($objects): Generator {
+ foreach ($objects as $object) {
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $object)
+ || (
+ $object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $object)
+ )
+ ) {
+ yield $object;
+ }
+ }
+ })();
+
+ if ($granted->valid()) {
+ $command = new ScheduleCheckCommand();
+ $command->setObjects($granted);
+ $command->setForced($this->getElement('force_check')->isChecked());
+ $command->setCheckTime($this->getValue('check_time')->getTimestamp());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ScheduleHostDowntimeForm.php b/application/forms/Command/Object/ScheduleHostDowntimeForm.php
new file mode 100644
index 0000000..bc21114
--- /dev/null
+++ b/application/forms/Command/Object/ScheduleHostDowntimeForm.php
@@ -0,0 +1,119 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\PropagateHostDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\ScheduleHostDowntimeCommand;
+use Icinga\Web\Notification;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use Traversable;
+
+class ScheduleHostDowntimeForm extends ScheduleServiceDowntimeForm
+{
+ /** @var bool */
+ protected $hostDowntimeAllServices;
+
+ public function __construct()
+ {
+ $this->start = new DateTime();
+ $config = Config::module('icingadb');
+ $this->commentText = $config->get('settings', 'hostdowntime_comment_text');
+
+ $this->hostDowntimeAllServices = (bool) $config->get('settings', 'hostdowntime_all_services', false);
+
+ $fixedEnd = clone $this->start;
+ $fixed = $config->get('settings', 'hostdowntime_end_fixed', 'PT1H');
+ $this->fixedEnd = $fixedEnd->add(new DateInterval($fixed));
+
+ $flexibleEnd = clone $this->start;
+ $flexible = $config->get('settings', 'hostdowntime_end_flexible', 'PT2H');
+ $this->flexibleEnd = $flexibleEnd->add(new DateInterval($flexible));
+
+ $flexibleDuration = $config->get('settings', 'hostdowntime_flexible_duration', 'PT2H');
+ $this->flexibleDuration = new DateInterval($flexibleDuration);
+
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+
+ Notification::success(sprintf(
+ tp('Scheduled downtime successfully', 'Scheduled downtime for %d hosts successfully', $countObjects),
+ $countObjects
+ ));
+ });
+ }
+
+ protected function assembleElements()
+ {
+ parent::assembleElements();
+
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'checkbox',
+ 'all_services',
+ [
+ 'label' => t('All Services'),
+ 'description' => t(
+ 'Sets downtime for all services for the matched host objects. If child options are set,'
+ . ' all child hosts and their services will schedule a downtime too.'
+ ),
+ 'value' => $this->hostDowntimeAllServices
+ ]
+ );
+ $decorator->decorate($this->getElement('all_services'));
+
+ $this->addElement(
+ 'select',
+ 'child_options',
+ array(
+ 'description' => t('Schedule child downtimes.'),
+ 'label' => t('Child Options'),
+ 'options' => [
+ 0 => t('Do nothing with child hosts'),
+ 1 => t('Schedule triggered downtime for all child hosts'),
+ 2 => t('Schedule non-triggered downtime for all child hosts')
+ ]
+ )
+ );
+ $decorator->decorate($this->getElement('child_options'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/downtime/schedule', $objects);
+
+ if ($granted->valid()) {
+ if (($childOptions = (int) $this->getValue('child_options'))) {
+ $command = new PropagateHostDowntimeCommand();
+ $command->setTriggered($childOptions === 1);
+ } else {
+ $command = new ScheduleHostDowntimeCommand();
+ }
+
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+ $command->setStart($this->getValue('start')->getTimestamp());
+ $command->setEnd($this->getValue('end')->getTimestamp());
+ $command->setForAllServices($this->getElement('all_services')->isChecked());
+
+ if ($this->getElement('flexible')->isChecked()) {
+ $command->setFixed(false);
+ $command->setDuration(
+ $this->getValue('hours') * 3600 + $this->getValue('minutes') * 60
+ );
+ }
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ScheduleServiceDowntimeForm.php b/application/forms/Command/Object/ScheduleServiceDowntimeForm.php
new file mode 100644
index 0000000..184a4e8
--- /dev/null
+++ b/application/forms/Command/Object/ScheduleServiceDowntimeForm.php
@@ -0,0 +1,267 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\ScheduleServiceDowntimeCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+class ScheduleServiceDowntimeForm extends CommandForm
+{
+ /** @var DateTime downtime start */
+ protected $start;
+
+ /** @var DateTime fixed downtime end */
+ protected $fixedEnd;
+
+ /**@var DateTime flexible downtime end */
+ protected $flexibleEnd;
+
+ /** @var DateInterval flexible downtime duration */
+ protected $flexibleDuration;
+
+ /** @var mixed Comment Text */
+ protected $commentText;
+
+ /**
+ * Initialize this form
+ */
+ public function __construct()
+ {
+ $this->start = new DateTime();
+
+ $config = Config::module('icingadb');
+
+ $this->commentText = $config->get('settings', 'hostdowntime_comment_text');
+ $fixedEnd = clone $this->start;
+ $fixed = $config->get('settings', 'servicedowntime_end_fixed', 'PT1H');
+ $this->fixedEnd = $fixedEnd->add(new DateInterval($fixed));
+
+ $flexibleEnd = clone $this->start;
+ $flexible = $config->get('settings', 'servicedowntime_end_flexible', 'PT2H');
+ $this->flexibleEnd = $flexibleEnd->add(new DateInterval($flexible));
+
+ $flexibleDuration = $config->get('settings', 'servicedowntime_flexible_duration', 'PT2H');
+ $this->flexibleDuration = new DateInterval($flexibleDuration);
+
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+
+ Notification::success(sprintf(
+ tp('Scheduled downtime successfully', 'Scheduled downtime for %d services successfully', $countObjects),
+ $countObjects
+ ));
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $isFlexible = $this->getPopulatedValue('flexible') === 'y';
+
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t(
+ 'This command is used to schedule host and service downtimes. During the downtime specified'
+ . ' by the start and end time, Icinga will not send notifications out about the hosts and'
+ . ' services. When the scheduled downtime expires, Icinga will send out notifications for'
+ . ' the hosts and services as it normally would.'
+ ))
+ )
+ )
+ ));
+
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'textarea',
+ 'comment',
+ [
+ 'required' => true,
+ 'label' => t('Comment'),
+ 'description' => t(
+ 'If you work with other administrators, you may find it useful to share information about'
+ . ' the host or service that is having problems. Make sure you enter a brief description of'
+ . ' what you are doing.'
+ ),
+ 'value' => $this->commentText
+ ]
+ );
+ $decorator->decorate($this->getElement('comment'));
+
+ $this->addElement(
+ 'localDateTime',
+ 'start',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'value' => $this->start,
+ 'label' => t('Start Time'),
+ 'description' => t('Set the start date and time for the downtime.')
+ ]
+ );
+ $decorator->decorate($this->getElement('start'));
+
+ $this->addElement(
+ 'localDateTime',
+ 'end',
+ [
+ 'data-use-datetime-picker' => true,
+ 'required' => true,
+ 'label' => t('End Time'),
+ 'description' => t('Set the end date and time for the downtime.'),
+ 'value' => $isFlexible ? $this->flexibleEnd : $this->fixedEnd,
+ 'validators' => [
+ 'DateTime' => ['break_chain_on_failure' => true],
+ 'Callback' => function ($value, $validator) {
+ /** @var CallbackValidator $validator */
+
+ if ($value <= $this->getValue('start')) {
+ $validator->addMessage(t('The end time must be greater than the start time'));
+ return false;
+ }
+
+ if ($value <= (new DateTime())) {
+ $validator->addMessage(t('A downtime must not be in the past'));
+ return false;
+ }
+
+ return true;
+ }
+ ]
+ ]
+ );
+ $decorator->decorate($this->getElement('end'));
+
+ $this->addElement(
+ 'checkbox',
+ 'flexible',
+ [
+ 'class' => 'autosubmit',
+ 'label' => t('Flexible'),
+ 'description' => t(
+ 'To make this a flexible downtime, check this option. A flexible downtime starts when the host'
+ . ' or service enters a problem state sometime between the start and end times you specified.'
+ . ' It then lasts as long as the duration time you enter.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('flexible'));
+
+ if ($isFlexible) {
+ $hoursInput = $this->createElement(
+ 'number',
+ 'hours',
+ [
+ 'required' => true,
+ 'label' => t('Duration'),
+ 'value' => $this->flexibleDuration->h,
+ 'min' => 0,
+ 'validators' => [
+ 'Callback' => function ($value, $validator) {
+ /** @var CallbackValidator $validator */
+
+ if ($this->getValue('minutes') == 0 && $value == 0) {
+ $validator->addMessage(t('The duration must not be zero'));
+ return false;
+ }
+
+ return true;
+ }
+ ]
+ ]
+ );
+ $this->registerElement($hoursInput);
+ $decorator->decorate($hoursInput);
+
+ $minutesInput = $this->createElement(
+ 'number',
+ 'minutes',
+ [
+ 'required' => true,
+ 'value' => $this->flexibleDuration->m,
+ 'min' => 0
+ ]
+ );
+ $this->registerElement($minutesInput);
+ $minutesInput->addWrapper(
+ new HtmlElement('label', null, new HtmlElement('span', null, Text::create(t('Minutes'))))
+ );
+
+ $hoursInput->getWrapper()->on(
+ IcingaFormDecorator::ON_ASSEMBLED,
+ function ($hoursInputWrapper) use ($minutesInput, $hoursInput) {
+ $hoursInputWrapper
+ ->insertAfter($minutesInput, $hoursInput)
+ ->getAttributes()->add('class', 'downtime-duration');
+ }
+ );
+
+ $hoursInput->prependWrapper(
+ new HtmlElement('label', null, new HtmlElement('span', null, Text::create(t('Hours'))))
+ );
+
+ $this->add($hoursInput);
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Schedule downtime', 'Schedule downtimes', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/downtime/schedule', $objects);
+
+ if ($granted->valid()) {
+ $command = new ScheduleServiceDowntimeCommand();
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+ $command->setStart($this->getValue('start')->getTimestamp());
+ $command->setEnd($this->getValue('end')->getTimestamp());
+
+ if ($this->getElement('flexible')->isChecked()) {
+ $command->setFixed(false);
+ $command->setDuration(
+ $this->getValue('hours') * 3600 + $this->getValue('minutes') * 60
+ );
+ }
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/SendCustomNotificationForm.php b/application/forms/Command/Object/SendCustomNotificationForm.php
new file mode 100644
index 0000000..dfb1e96
--- /dev/null
+++ b/application/forms/Command/Object/SendCustomNotificationForm.php
@@ -0,0 +1,125 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Application\Config;
+use Icinga\Module\Icingadb\Command\Object\SendCustomNotificationCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+use Traversable;
+
+use function ipl\Stdlib\iterable_value_first;
+
+class SendCustomNotificationForm extends CommandForm
+{
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (iterable_value_first($this->getObjects()) instanceof Host) {
+ $message = sprintf(tp(
+ 'Sent custom notification successfully',
+ 'Sent custom notification for %d hosts successfully',
+ $countObjects
+ ), $countObjects);
+ } else {
+ $message = sprintf(tp(
+ 'Sent custom notification successfully',
+ 'Sent custom notification for %d services successfully',
+ $countObjects
+ ), $countObjects);
+ }
+
+ Notification::success($message);
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'form-description']),
+ new Icon('info-circle', ['class' => 'form-description-icon']),
+ new HtmlElement(
+ 'ul',
+ null,
+ new HtmlElement(
+ 'li',
+ null,
+ Text::create(t('This command is used to send custom notifications about hosts or services.'))
+ )
+ )
+ ));
+
+ $config = Config::module('icingadb');
+ $decorator = new IcingaFormDecorator();
+
+ $this->addElement(
+ 'textarea',
+ 'comment',
+ [
+ 'required' => true,
+ 'label' => t('Comment'),
+ 'description' => t(
+ 'Enter a brief description on why you\'re sending this notification. It will be sent with it.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('comment'));
+
+ $this->addElement(
+ 'checkbox',
+ 'forced',
+ [
+ 'label' => t('Forced'),
+ 'value' => (bool) $config->get('settings', 'custom_notification_forced', false),
+ 'description' => t(
+ 'If you check this option, the notification is sent regardless'
+ . ' of downtimes or whether notifications are enabled or not.'
+ )
+ ]
+ );
+ $decorator->decorate($this->getElement('forced'));
+ }
+
+ protected function assembleSubmitButton()
+ {
+ $this->addElement(
+ 'submit',
+ 'btn_submit',
+ [
+ 'required' => true,
+ 'label' => tp('Send custom notification', 'Send custom notifications', count($this->getObjects()))
+ ]
+ );
+
+ (new IcingaFormDecorator())->decorate($this->getElement('btn_submit'));
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ $granted = $this->filterGrantedOn('icingadb/command/send-custom-notification', $objects);
+
+ if ($granted->valid()) {
+ $command = new SendCustomNotificationCommand();
+ $command->setObjects($granted);
+ $command->setComment($this->getValue('comment'));
+ $command->setForced($this->getElement('forced')->isChecked());
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ yield $command;
+ }
+ }
+}
diff --git a/application/forms/Command/Object/ToggleObjectFeaturesForm.php b/application/forms/Command/Object/ToggleObjectFeaturesForm.php
new file mode 100644
index 0000000..50767da
--- /dev/null
+++ b/application/forms/Command/Object/ToggleObjectFeaturesForm.php
@@ -0,0 +1,186 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Module\Icingadb\Command\Object\ToggleObjectFeatureCommand;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Html\FormElement\CheckboxElement;
+use ipl\Orm\Model;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use Traversable;
+
+class ToggleObjectFeaturesForm extends CommandForm
+{
+ const LEAVE_UNCHANGED = 'noop';
+
+ protected $features;
+
+ protected $featureStatus;
+
+ /**
+ * ToggleFeature(s) being used to submit this form
+ *
+ * @var ToggleObjectFeatureCommand[]
+ */
+ protected $submittedFeatures = [];
+
+ public function __construct($featureStatus)
+ {
+ $this->featureStatus = $featureStatus;
+ $this->features = [
+ ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS => [
+ 'label' => t('Active Checks'),
+ 'permission' => 'icingadb/command/feature/object/active-checks'
+ ],
+ ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS => [
+ 'label' => t('Passive Checks'),
+ 'permission' => 'icingadb/command/feature/object/passive-checks'
+ ],
+ ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS => [
+ 'label' => t('Notifications'),
+ 'permission' => 'icingadb/command/feature/object/notifications'
+ ],
+ ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER => [
+ 'label' => t('Event Handler'),
+ 'permission' => 'icingadb/command/feature/object/event-handler'
+ ],
+ ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION => [
+ 'label' => t('Flap Detection'),
+ 'permission' => 'icingadb/command/feature/object/flap-detection'
+ ]
+ ];
+
+ $this->getAttributes()->add('class', 'object-features');
+
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ foreach ($this->submittedFeatures as $feature) {
+ $enabled = $feature->getEnabled();
+ switch ($feature->getFeature()) {
+ case ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS:
+ if ($enabled) {
+ $message = t('Enabled active checks successfully');
+ } else {
+ $message = t('Disabled active checks successfully');
+ }
+
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS:
+ if ($enabled) {
+ $message = t('Enabled passive checks successfully');
+ } else {
+ $message = t('Disabled passive checks successfully');
+ }
+
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER:
+ if ($enabled) {
+ $message = t('Enabled event handler successfully');
+ } else {
+ $message = t('Disabled event handler checks successfully');
+ }
+
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION:
+ if ($enabled) {
+ $message = t('Enabled flap detection successfully');
+ } else {
+ $message = t('Disabled flap detection successfully');
+ }
+
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS:
+ if ($enabled) {
+ $message = t('Enabled notifications successfully');
+ } else {
+ $message = t('Disabled notifications successfully');
+ }
+
+ break;
+ default:
+ $message = t('Invalid feature option');
+ break;
+ }
+
+ Notification::success($message);
+ }
+ });
+ }
+
+ protected function assembleElements()
+ {
+ $decorator = new IcingaFormDecorator();
+ foreach ($this->features as $feature => $spec) {
+ $options = [
+ 'class' => 'autosubmit',
+ 'disabled' => $this->featureStatus instanceof Model
+ ? ! $this->isGrantedOn($spec['permission'], $this->featureStatus)
+ : false,
+ 'label' => $spec['label']
+ ];
+ if ($this->featureStatus[$feature] === 2) {
+ $this->addElement(
+ 'select',
+ $feature,
+ $options + [
+ 'description' => t('Multiple Values'),
+ 'options' => [
+ self::LEAVE_UNCHANGED => t('Leave Unchanged'),
+ t('Disable All'),
+ t('Enable All')
+ ],
+ 'value' => self::LEAVE_UNCHANGED
+ ]
+ );
+ $decorator->decorate($this->getElement($feature));
+
+ $this->getElement($feature)
+ ->getWrapper()
+ ->getAttributes()
+ ->add('class', 'indeterminate');
+ } else {
+ $options['value'] = (bool) $this->featureStatus[$feature];
+ $this->addElement('checkbox', $feature, $options);
+ $decorator->decorate($this->getElement($feature));
+ }
+ }
+ }
+
+ protected function assembleSubmitButton()
+ {
+ }
+
+ protected function getCommands(Traversable $objects): Traversable
+ {
+ foreach ($this->features as $feature => $spec) {
+ if ($this->getElement($feature) instanceof CheckboxElement) {
+ $state = $this->getElement($feature)->isChecked();
+ } else {
+ $state = $this->getElement($feature)->getValue();
+ }
+
+ if ($state === self::LEAVE_UNCHANGED || (int) $state === (int) $this->featureStatus[$feature]) {
+ continue;
+ }
+
+ $granted = $this->filterGrantedOn($spec['permission'], $objects);
+
+ if ($granted->valid()) {
+ $command = new ToggleObjectFeatureCommand();
+ $command->setObjects($granted);
+ $command->setFeature($feature);
+ $command->setEnabled((int) $state);
+
+ $this->submittedFeatures[] = $command;
+
+ yield $command;
+ }
+ }
+ }
+}
diff --git a/application/forms/DatabaseConfigForm.php b/application/forms/DatabaseConfigForm.php
new file mode 100644
index 0000000..7a6c1bd
--- /dev/null
+++ b/application/forms/DatabaseConfigForm.php
@@ -0,0 +1,33 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms;
+
+use Icinga\Data\ResourceFactory;
+use Icinga\Forms\ConfigForm;
+
+class DatabaseConfigForm extends ConfigForm
+{
+ public function init()
+ {
+ $this->setSubmitLabel(t('Save Changes'));
+ }
+
+ public function createElements(array $formData)
+ {
+ $dbResources = ResourceFactory::getResourceConfigs('db')->keys();
+
+ $this->addElement('select', 'icingadb_resource', [
+ 'description' => t('Database resource'),
+ 'label' => t('Database'),
+ 'multiOptions' => array_merge(
+ ['' => sprintf(' - %s - ', t('Please choose'))],
+ array_combine($dbResources, $dbResources)
+ ),
+ 'disable' => [''],
+ 'required' => true,
+ 'value' => ''
+ ]);
+ }
+}
diff --git a/application/forms/Navigation/ActionForm.php b/application/forms/Navigation/ActionForm.php
new file mode 100644
index 0000000..08cba3f
--- /dev/null
+++ b/application/forms/Navigation/ActionForm.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Navigation;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Forms\Navigation\NavigationItemForm;
+use Icinga\Module\Icingadb\Common\Auth;
+
+class ActionForm extends NavigationItemForm
+{
+ use Auth;
+
+ /**
+ * The name of the restriction to which the filter should be applied
+ *
+ * @var string
+ */
+ protected $restriction;
+
+ public function createElements(array $formData)
+ {
+ parent::createElements($formData);
+
+ $this->addElement(
+ 'text',
+ 'filter',
+ array(
+ 'allowEmpty' => true,
+ 'label' => $this->translate('Filter'),
+ 'description' => $this->translate(
+ 'Display this action only for objects matching this filter. Leave it blank'
+ . ' if you want this action being displayed regardless of the object'
+ )
+ )
+ );
+ }
+
+ public function isValid($formData): bool
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (($filterString = $this->getValue('filter')) !== null) {
+ try {
+ $this->parseRestriction($filterString, $this->restriction);
+ } catch (ConfigurationError $err) {
+ $this->getElement('filter')->addError($err->getMessage());
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/application/forms/Navigation/IcingadbHostActionForm.php b/application/forms/Navigation/IcingadbHostActionForm.php
new file mode 100644
index 0000000..adee11d
--- /dev/null
+++ b/application/forms/Navigation/IcingadbHostActionForm.php
@@ -0,0 +1,10 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Navigation;
+
+class IcingadbHostActionForm extends ActionForm
+{
+ protected $restriction = 'icingadb/filter/hosts';
+}
diff --git a/application/forms/Navigation/IcingadbServiceActionForm.php b/application/forms/Navigation/IcingadbServiceActionForm.php
new file mode 100644
index 0000000..29d33c8
--- /dev/null
+++ b/application/forms/Navigation/IcingadbServiceActionForm.php
@@ -0,0 +1,10 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Navigation;
+
+class IcingadbServiceActionForm extends ActionForm
+{
+ protected $restriction = 'icingadb/filter/services';
+}
diff --git a/application/forms/RedisConfigForm.php b/application/forms/RedisConfigForm.php
new file mode 100644
index 0000000..bd3db9c
--- /dev/null
+++ b/application/forms/RedisConfigForm.php
@@ -0,0 +1,606 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms;
+
+use Closure;
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Exception\NotWritableError;
+use Icinga\File\Storage\LocalFileStorage;
+use Icinga\File\Storage\TemporaryLocalFileStorage;
+use Icinga\Forms\ConfigForm;
+use Icinga\Module\Icingadb\Common\IcingaRedis;
+use Icinga\Web\Form;
+use ipl\Validator\PrivateKeyValidator;
+use ipl\Validator\X509CertValidator;
+use Zend_Validate_Callback;
+
+class RedisConfigForm extends ConfigForm
+{
+ public function init()
+ {
+ $this->setSubmitLabel(t('Save Changes'));
+ $this->setValidatePartial(true);
+ }
+
+ public function createElements(array $formData)
+ {
+ $this->addElement('checkbox', 'redis_tls', [
+ 'label' => t('Use TLS'),
+ 'description' => t('Encrypt connections to Redis via TLS'),
+ 'autosubmit' => true
+ ]);
+
+ $this->addElement('hidden', 'redis_ca');
+ $this->addElement('hidden', 'redis_cert');
+ $this->addElement('hidden', 'redis_key');
+ $this->addElement('hidden', 'clear_redis_ca', ['ignore' => true]);
+ $this->addElement('hidden', 'clear_redis_cert', ['ignore' => true]);
+ $this->addElement('hidden', 'clear_redis_key', ['ignore' => true]);
+
+ $useTls = isset($formData['redis_tls']) && $formData['redis_tls'];
+ if ($useTls) {
+ $this->addElement('textarea', 'redis_ca_pem', [
+ 'label' => t('Redis CA Certificate'),
+ 'description' => sprintf(
+ t('Verify the peer using this PEM-encoded CA certificate ("%s...")'),
+ '-----BEGIN CERTIFICATE-----'
+ ),
+ 'required' => true,
+ 'ignore' => true,
+ 'validators' => [$this->wrapIplValidator(X509CertValidator::class, 'redis_ca_pem')]
+ ]);
+
+ $this->addElement('textarea', 'redis_cert_pem', [
+ 'label' => t('Client Certificate'),
+ 'description' => sprintf(
+ t('Authenticate using this PEM-encoded client certificate ("%s...")'),
+ '-----BEGIN CERTIFICATE-----'
+ ),
+ 'ignore' => true,
+ 'allowEmpty' => false,
+ 'validators' => [
+ $this->wrapIplValidator(X509CertValidator::class, 'redis_cert_pem', function ($value) {
+ if (! $value && $this->getElement('redis_key_pem')->getValue()) {
+ $this->getElement('redis_cert_pem')->addError(t(
+ 'Either both a client certificate and its private key or none of them must be specified'
+ ));
+ }
+
+ return true;
+ })
+ ]
+ ]);
+
+ $this->addElement('textarea', 'redis_key_pem', [
+ 'label' => t('Client Key'),
+ 'description' => sprintf(
+ t('Authenticate using this PEM-encoded private key ("%s...")'),
+ '-----BEGIN PRIVATE KEY-----'
+ ),
+ 'ignore' => true,
+ 'allowEmpty' => false,
+ 'validators' => [
+ $this->wrapIplValidator(PrivateKeyValidator::class, 'redis_key_pem', function ($value) {
+ if (! $value && $this->getElement('redis_cert_pem')->getValue()) {
+ $this->getElement('redis_key_pem')->addError(t(
+ 'Either both a client certificate and its private key or none of them must be specified'
+ ));
+ }
+
+ return true;
+ })
+ ]
+ ]);
+ }
+
+ $this->addDisplayGroup(
+ ['redis_tls', 'redis_insecure', 'redis_ca_pem', 'redis_cert_pem', 'redis_key_pem'],
+ 'redis',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div']],
+ [
+ 'Description',
+ ['tag' => 'span', 'class' => 'description', 'placement' => 'prepend']
+ ],
+ 'Fieldset'
+ ],
+ 'description' => t(
+ 'Secure connections. If you are running a high availability zone'
+ . ' with two masters, the following applies to both of them.'
+ ),
+ 'legend' => t('General')
+ ]
+ );
+
+ if (isset($formData['skip_validation']) && $formData['skip_validation']) {
+ // In case another error occured and the checkbox was displayed before
+ static::addSkipValidationCheckbox($this);
+ }
+
+ if ($useTls && isset($formData['redis_insecure']) && $formData['redis_insecure']) {
+ // In case another error occured and the checkbox was displayed before
+ static::addInsecureCheckboxIfTls($this);
+ }
+
+ $this->addElement('text', 'redis1_host', [
+ 'description' => t('Redis Host'),
+ 'label' => t('Redis Host'),
+ 'required' => true
+ ]);
+
+ $this->addElement('number', 'redis1_port', [
+ 'description' => t('Redis Port'),
+ 'label' => t('Redis Port'),
+ 'placeholder' => 6380
+ ]);
+
+ $this->addElement('password', 'redis1_password', [
+ 'description' => t('Redis Password'),
+ 'label' => t('Redis Password'),
+ 'renderPassword' => true,
+ 'autocomplete' => 'new-password'
+ ]);
+
+ $this->addDisplayGroup(
+ ['redis1_host', 'redis1_port', 'redis1_password'],
+ 'redis1',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div']],
+ [
+ 'Description',
+ ['tag' => 'span', 'class' => 'description', 'placement' => 'prepend']
+ ],
+ 'Fieldset'
+ ],
+ 'description' => t(
+ 'Redis connection details of your Icinga host. If you are running a high'
+ . ' availability zone with two masters, this is your configuration master.'
+ ),
+ 'legend' => t('Primary Icinga Master')
+ ]
+ );
+
+ $this->addElement('text', 'redis2_host', [
+ 'description' => t('Redis Host'),
+ 'label' => t('Redis Host'),
+ ]);
+
+ $this->addElement('number', 'redis2_port', [
+ 'description' => t('Redis Port'),
+ 'label' => t('Redis Port'),
+ 'placeholder' => 6380
+ ]);
+
+ $this->addElement('password', 'redis2_password', [
+ 'description' => t('Redis Password'),
+ 'label' => t('Redis Password'),
+ 'renderPassword' => true,
+ 'autocomplete' => 'new-password'
+ ]);
+
+ $this->addDisplayGroup(
+ ['redis2_host', 'redis2_port', 'redis2_password'],
+ 'redis2',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div']],
+ [
+ 'Description',
+ ['tag' => 'span', 'class' => 'description', 'placement' => 'prepend']
+ ],
+ 'Fieldset'
+ ],
+ 'description' => t(
+ 'If you are running a high availability zone with two masters,'
+ . ' please provide the Redis connection details of the secondary master.'
+ ),
+ 'legend' => t('Secondary Icinga Master')
+ ]
+ );
+ }
+
+ public static function addSkipValidationCheckbox(Form $form)
+ {
+ $form->addElement(
+ 'checkbox',
+ 'skip_validation',
+ [
+ 'order' => 0,
+ 'ignore' => true,
+ 'label' => t('Skip Validation'),
+ 'description' => t(
+ 'Check this box to enforce changes without validating that Redis is available.'
+ )
+ ]
+ );
+ }
+
+ public static function addInsecureCheckboxIfTls(Form $form)
+ {
+ if ($form->getElement('redis_insecure') !== null) {
+ return;
+ }
+
+ $form->addElement(
+ 'checkbox',
+ 'redis_insecure',
+ [
+ 'order' => 1,
+ 'label' => t('Insecure'),
+ 'description' => t('Don\'t verify the peer')
+ ]
+ );
+
+ $displayGroup = $form->getDisplayGroup('redis');
+ $elements = $displayGroup->getElements();
+ $elements['redis_insecure'] = $form->getElement('redis_insecure');
+ $displayGroup->setElements($elements);
+ }
+
+ public function isValid($formData)
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (($el = $this->getElement('skip_validation')) === null || ! $el->isChecked()) {
+ if (! static::checkRedis($this)) {
+ if ($el === null) {
+ static::addSkipValidationCheckbox($this);
+
+ if ($this->getElement('redis_tls')->isChecked()) {
+ static::addInsecureCheckboxIfTls($this);
+ }
+ }
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function isValidPartial(array $formData)
+ {
+ if (! parent::isValidPartial($formData)) {
+ return false;
+ }
+
+ $useTls = $this->getElement('redis_tls')->isChecked();
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $textareaName = 'redis_' . $name . '_pem';
+ $clearName = 'clear_redis_' . $name;
+
+ if ($useTls) {
+ $this->getElement($clearName)->setValue(null);
+
+ $pemPath = $this->getValue('redis_' . $name);
+ if ($pemPath && ! isset($formData[$textareaName]) && ! $formData[$clearName]) {
+ $this->getElement($textareaName)->setValue(@file_get_contents($pemPath));
+ }
+ }
+
+ if (isset($formData[$textareaName]) && ! $formData[$textareaName]) {
+ $this->getElement($clearName)->setValue(true);
+ }
+ }
+
+ if ($this->getElement('backend_validation')->isChecked()) {
+ if (! static::checkRedis($this)) {
+ if ($this->getElement('redis_tls')->isChecked()) {
+ static::addInsecureCheckboxIfTls($this);
+ }
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function onRequest()
+ {
+ $errors = [];
+
+ $redisConfig = $this->config->getSection('redis');
+ if ($redisConfig->get('tls', false)) {
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $path = $redisConfig->get($name);
+ if (file_exists($path)) {
+ try {
+ $redisConfig[$name . '_pem'] = file_get_contents($path);
+ } catch (Exception $e) {
+ $errors['redis_' . $name . '_pem'] = sprintf(
+ t('Failed to read file "%s": %s'),
+ $path,
+ $e->getMessage()
+ );
+ }
+ }
+ }
+ }
+
+ $connectionConfig = Config::fromIni(
+ join(DIRECTORY_SEPARATOR, [dirname($this->config->getConfigFile()), 'redis.ini'])
+ );
+ $this->config->setSection('redis1', [
+ 'host' => $connectionConfig->get('redis1', 'host'),
+ 'port' => $connectionConfig->get('redis1', 'port'),
+ 'password' => $connectionConfig->get('redis1', 'password')
+ ]);
+ $this->config->setSection('redis2', [
+ 'host' => $connectionConfig->get('redis2', 'host'),
+ 'port' => $connectionConfig->get('redis2', 'port'),
+ 'password' => $connectionConfig->get('redis2', 'password')
+ ]);
+
+ parent::onRequest();
+
+ foreach ($errors as $elementName => $message) {
+ $this->getElement($elementName)->addError($message);
+ }
+ }
+
+ public function onSuccess()
+ {
+ $storage = new LocalFileStorage(Icinga::app()->getStorageDir(
+ join(DIRECTORY_SEPARATOR, ['modules', 'icingadb', 'redis'])
+ ));
+
+ $useTls = $this->getElement('redis_tls')->isChecked();
+ $pem = null;
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $textarea = $this->getElement('redis_' . $name . '_pem');
+ if ($useTls && $textarea !== null && ($pem = $textarea->getValue())) {
+ $pemFile = md5($pem) . '-' . $name . '.pem';
+ if (! $storage->has($pemFile)) {
+ try {
+ $storage->create($pemFile, $pem);
+ } catch (NotWritableError $e) {
+ $textarea->addError($e->getMessage());
+ return false;
+ }
+ }
+
+ $this->getElement('redis_' . $name)->setValue($storage->resolvePath($pemFile));
+ }
+
+ if ((! $useTls && $this->getElement('clear_redis_' . $name)->getValue()) || ($useTls && ! $pem)) {
+ $pemPath = $this->getValue('redis_' . $name);
+ if ($pemPath && $storage->has(($pemFile = basename($pemPath)))) {
+ try {
+ $storage->delete($pemFile);
+ $this->getElement('redis_' . $name)->setValue(null);
+ } catch (NotWritableError $e) {
+ $this->addError($e->getMessage());
+ return false;
+ }
+ }
+ }
+ }
+
+ $connectionConfig = Config::fromIni(
+ join(DIRECTORY_SEPARATOR, [dirname($this->config->getConfigFile()), 'redis.ini'])
+ );
+
+ $redis1Host = $this->getValue('redis1_host');
+ $redis1Port = $this->getValue('redis1_port');
+ $redis1Password = $this->getValue('redis1_password');
+ $redis1Section = $connectionConfig->getSection('redis1');
+ $redis1Section['host'] = $redis1Host;
+ $this->getElement('redis1_host')->setValue(null);
+ $connectionConfig->setSection('redis1', $redis1Section);
+ if (! empty($redis1Port)) {
+ $redis1Section['port'] = $redis1Port;
+ $this->getElement('redis1_port')->setValue(null);
+ } else {
+ $redis1Section['port'] = null;
+ }
+
+ if (! empty($redis1Password)) {
+ $redis1Section['password'] = $redis1Password;
+ $this->getElement('redis1_password')->setValue(null);
+ } else {
+ $redis1Section['password'] = null;
+ }
+
+ if (! array_filter($redis1Section->toArray())) {
+ $connectionConfig->removeSection('redis1');
+ }
+
+ $redis2Host = $this->getValue('redis2_host');
+ $redis2Port = $this->getValue('redis2_port');
+ $redis2Password = $this->getValue('redis2_password');
+ $redis2Section = $connectionConfig->getSection('redis2');
+ if (! empty($redis2Host)) {
+ $redis2Section['host'] = $redis2Host;
+ $this->getElement('redis2_host')->setValue(null);
+ $connectionConfig->setSection('redis2', $redis2Section);
+ } else {
+ $redis2Section['host'] = null;
+ }
+
+ if (! empty($redis2Port)) {
+ $redis2Section['port'] = $redis2Port;
+ $this->getElement('redis2_port')->setValue(null);
+ $connectionConfig->setSection('redis2', $redis2Section);
+ } else {
+ $redis2Section['port'] = null;
+ }
+
+ if (! empty($redis2Password)) {
+ $redis2Section['password'] = $redis2Password;
+ $this->getElement('redis2_password')->setValue(null);
+ } else {
+ $redis2Section['password'] = null;
+ }
+
+ if (! array_filter($redis2Section->toArray())) {
+ $connectionConfig->removeSection('redis2');
+ }
+
+ $connectionConfig->saveIni();
+
+ return parent::onSuccess();
+ }
+
+ public function addSubmitButton()
+ {
+ parent::addSubmitButton()
+ ->getElement('btn_submit')
+ ->setDecorators(['ViewHelper']);
+
+ $this->addElement(
+ 'submit',
+ 'backend_validation',
+ [
+ 'ignore' => true,
+ 'label' => t('Validate Configuration'),
+ 'data-progress-label' => t('Validation In Progress'),
+ 'decorators' => ['ViewHelper']
+ ]
+ );
+ $this->addDisplayGroup(
+ ['btn_submit', 'backend_validation'],
+ 'submit_validation',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div', 'class' => 'control-group form-controls']]
+ ]
+ ]
+ );
+
+ return $this;
+ }
+
+ public static function checkRedis(Form $form): bool
+ {
+ $sections = [];
+
+ $storage = new TemporaryLocalFileStorage();
+ foreach (ConfigForm::transformEmptyValuesToNull($form->getValues()) as $sectionAndPropertyName => $value) {
+ if ($value !== null) {
+ list($section, $property) = explode('_', $sectionAndPropertyName, 2);
+ if (in_array($property, ['ca', 'cert', 'key'])) {
+ $storage->create("$property.pem", $value);
+ $value = $storage->resolvePath("$property.pem");
+ }
+
+ $sections[$section][$property] = $value;
+ }
+ }
+
+ $ignoredTextAreas = [
+ 'ca' => 'redis_ca_pem',
+ 'cert' => 'redis_cert_pem',
+ 'key' => 'redis_key_pem'
+ ];
+ foreach ($ignoredTextAreas as $name => $textareaName) {
+ if (($textarea = $form->getElement($textareaName)) !== null) {
+ if (($pem = $textarea->getValue())) {
+ if ($storage->has("$name.pem")) {
+ $storage->update("$name.pem", $pem);
+ } else {
+ $storage->create("$name.pem", $pem);
+ $sections['redis'][$name] = $storage->resolvePath("$name.pem");
+ }
+ } elseif ($storage->has("$name.pem")) {
+ $storage->delete("$name.pem");
+ unset($sections['redis'][$name]);
+ }
+ }
+ }
+
+ $moduleConfig = new Config();
+ $moduleConfig->setSection('redis', $sections['redis']);
+ $redisConfig = new Config();
+ $redisConfig->setSection('redis1', $sections['redis1'] ?? []);
+ $redisConfig->setSection('redis2', $sections['redis2'] ?? []);
+
+ try {
+ $redis1 = IcingaRedis::getPrimaryRedis($moduleConfig, $redisConfig);
+ } catch (Exception $e) {
+ $form->warning(sprintf(
+ t('Failed to connect to primary Redis: %s'),
+ $e->getMessage()
+ ));
+ return false;
+ }
+
+ if (IcingaRedis::getLastIcingaHeartbeat($redis1) === null) {
+ $form->warning(t('Primary connection established but failed to verify Icinga is connected as well.'));
+ return false;
+ }
+
+ try {
+ $redis2 = IcingaRedis::getSecondaryRedis($moduleConfig, $redisConfig);
+ } catch (Exception $e) {
+ $form->warning(sprintf(t('Failed to connect to secondary Redis: %s'), $e->getMessage()));
+ return false;
+ }
+
+ if ($redis2 !== null && IcingaRedis::getLastIcingaHeartbeat($redis2) === null) {
+ $form->warning(t('Secondary connection established but failed to verify Icinga is connected as well.'));
+ return false;
+ }
+
+ $form->info(t('The configuration has been successfully validated.'));
+ return true;
+ }
+
+ /**
+ * Wraps the given IPL validator class into a callback validator
+ * for usage as the only validator of the element given by name.
+ *
+ * @param string $cls IPL validator class FQN
+ * @param string $element Form element name
+ * @param Closure $additionalValidator
+ *
+ * @return array Callback validator
+ */
+ private function wrapIplValidator(string $cls, string $element, Closure $additionalValidator = null): array
+ {
+ return [
+ 'Callback',
+ false,
+ [
+ 'callback' => function ($v) use ($cls, $element, $additionalValidator) {
+ if ($additionalValidator !== null) {
+ if (! $additionalValidator($v)) {
+ return false;
+ }
+ }
+
+ if (! $v) {
+ return true;
+ }
+
+ $validator = new $cls();
+ $valid = $validator->isValid($v);
+
+ if (! $valid) {
+ /** @var Zend_Validate_Callback $callbackValidator */
+ $callbackValidator = $this->getElement($element)->getValidator('Callback');
+
+ $callbackValidator->setMessage(
+ $validator->getMessages()[0],
+ Zend_Validate_Callback::INVALID_VALUE
+ );
+ }
+
+ return $valid;
+ }
+ ]
+ ];
+ }
+}
diff --git a/application/forms/SetAsBackendForm.php b/application/forms/SetAsBackendForm.php
new file mode 100644
index 0000000..2840633
--- /dev/null
+++ b/application/forms/SetAsBackendForm.php
@@ -0,0 +1,34 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms;
+
+use Icinga\Module\Icingadb\Hook\IcingadbSupportHook;
+use Icinga\Web\Session;
+use ipl\Web\Compat\CompatForm;
+
+class SetAsBackendForm extends CompatForm
+{
+ protected $defaultAttributes = [
+ 'id' => 'setAsBackendForm',
+ 'class' => 'icinga-controls'
+ ];
+
+ protected function assemble()
+ {
+ $this->addElement('checkbox', 'backend', [
+ 'class' => 'autosubmit',
+ 'label' => t('Use Icinga DB As Backend'),
+ 'value' => IcingadbSupportHook::isIcingaDbSetAsPreferredBackend()
+ ]);
+ }
+
+ public function onSuccess()
+ {
+ Session::getSession()->getNamespace('icingadb')->set(
+ IcingadbSupportHook::PREFERENCE_NAME,
+ $this->getElement('backend')->isChecked()
+ );
+ }
+}
diff --git a/application/views/scripts/joystickPagination-icingadb.phtml b/application/views/scripts/joystickPagination-icingadb.phtml
new file mode 100644
index 0000000..122d72a
--- /dev/null
+++ b/application/views/scripts/joystickPagination-icingadb.phtml
@@ -0,0 +1,162 @@
+<?php
+
+use Icinga\Web\Url;
+
+$showText = $this->translate('%s: Show %s %u to %u out of %u', 'pagination.joystick');
+$flipUrl = clone $baseUrl;
+if ($flipUrl->getParam('flipped')) {
+ $flipUrl->remove('flipped');
+} else {
+ $flipUrl->setParam('flipped');
+}
+if ($flipUrl->hasParam('page')) {
+ $flipUrl->setParam('page', implode(',', array_reverse(explode(',', $flipUrl->getParam('page')))));
+}
+if ($flipUrl->hasParam('limit')) {
+ $flipUrl->setParam('limit', implode(',', array_reverse(explode(',', $flipUrl->getParam('limit')))));
+}
+
+$yAxisTotalItem = $yAxisPaginator->count();
+$yAxisItemCountPerPage = $yAxisPaginator->getLimit() ?? $yAxisTotalItem;
+$totalYAxisPages = ceil($yAxisTotalItem / $yAxisItemCountPerPage);
+$currentYAxisPage = round($yAxisPaginator->getOffset() / $yAxisItemCountPerPage) + 1;
+$prevYAxisPage = $currentYAxisPage > 1 ? $currentYAxisPage - 1 : null;
+$nextYAxisPage = $currentYAxisPage < $totalYAxisPages ? $currentYAxisPage + 1 : null;
+
+$xAxisTotalItem = $xAxisPaginator->count();
+$xAxisItemCountPerPage = $xAxisPaginator->getLimit() ?? $xAxisTotalItem;
+$totalXAxisPages = ceil($xAxisTotalItem / $xAxisItemCountPerPage);
+$currentXAxisPage = round($xAxisPaginator->getOffset() / $xAxisItemCountPerPage) + 1;
+$prevXAxisPage = $currentXAxisPage > 1 ? $currentXAxisPage - 1 : null;
+$nextXAxisPage = $currentXAxisPage < $totalXAxisPages ? $currentXAxisPage + 1 : null;
+?>
+
+<table class="joystick-pagination">
+ <tbody>
+ <tr>
+ <td>&nbsp;</td>
+ <td>
+ <?php if ($prevYAxisPage): ?>
+ <?= $this->qlink(
+ '',
+ $baseUrl,
+ array(
+ 'page' => $currentXAxisPage . ',' . $prevYAxisPage
+ ),
+ array(
+ 'icon' => 'up-open',
+ 'data-base-target' => '_self',
+ 'title' => sprintf(
+ $showText,
+ $this->translate('Y-Axis', 'pagination.joystick'),
+ $this->translate('hosts', 'pagination.joystick'),
+ ($prevYAxisPage - 1) * $xAxisItemCountPerPage + 1,
+ $prevYAxisPage * $xAxisItemCountPerPage,
+ $yAxisTotalItem
+ )
+ )
+ ); ?>
+ <?php else: ?>
+ <?= $this->icon('up-open'); ?>
+ <?php endif ?>
+ </td>
+ <td>&nbsp;</td>
+ </tr>
+ <tr>
+ <td>
+ <?php if ($prevXAxisPage): ?>
+ <?= $this->qlink(
+ '',
+ $baseUrl,
+ array(
+ 'page' => $prevXAxisPage . ',' . $currentYAxisPage
+ ),
+ array(
+ 'icon' => 'left-open',
+ 'data-base-target' => '_self',
+ 'title' => sprintf(
+ $showText,
+ $this->translate('X-Axis', 'pagination.joystick'),
+ $this->translate('services', 'pagination.joystick'),
+ ($prevXAxisPage - 1) * $xAxisItemCountPerPage + 1,
+ $prevXAxisPage * $xAxisItemCountPerPage,
+ $xAxisTotalItem
+ )
+ )
+ ); ?>
+ <?php else: ?>
+ <?= $this->icon('left-open'); ?>
+ <?php endif ?>
+ </td>
+ <?php if ($this->flippable): ?>
+ <td><?= $this->qlink(
+ '',
+ $flipUrl,
+ null,
+ array(
+ 'icon' => 'arrows-cw',
+ 'data-base-target' => '_self',
+ 'title' => $this->translate('Flip grid axes')
+ )
+ ) ?></td>
+ <?php else: ?>
+ <td>&nbsp;</td>
+ <?php endif ?>
+ <td>
+ <?php if ($nextXAxisPage): ?>
+ <?= $this->qlink(
+ '',
+ $baseUrl,
+ array(
+ 'page' => $nextXAxisPage . ',' . $currentYAxisPage
+ ),
+ array(
+ 'icon' => 'right-open',
+ 'data-base-target' => '_self',
+ 'title' => sprintf(
+ $showText,
+ $this->translate('X-Axis', 'pagination.joystick'),
+ $this->translate('services', 'pagination.joystick'),
+ $currentXAxisPage * $xAxisItemCountPerPage + 1,
+ $nextXAxisPage === $totalXAxisPages ? $xAxisItemCountPerPage : $nextXAxisPage * $xAxisItemCountPerPage,
+ $xAxisTotalItem
+ )
+ ),
+ false
+ ); ?>
+ <?php else: ?>
+ <?= $this->icon('right-open'); ?>
+ <?php endif ?>
+ </td>
+ </tr>
+ <tr>
+ <td>&nbsp;</td>
+ <td>
+ <?php if ($nextYAxisPage): ?>
+ <?= $this->qlink(
+ '',
+ $baseUrl,
+ array(
+ 'page' => $currentXAxisPage . ',' . $nextYAxisPage
+ ),
+ array(
+ 'icon' => 'down-open',
+ 'data-base-target' => '_self',
+ 'title' => sprintf(
+ $showText,
+ $this->translate('Y-Axis', 'pagination.joystick'),
+ $this->translate('hosts', 'pagination.joystick'),
+ $currentYAxisPage * $yAxisItemCountPerPage + 1,
+ $nextYAxisPage === $totalYAxisPages ? $yAxisItemCountPerPage : $nextYAxisPage * $yAxisItemCountPerPage,
+ $yAxisTotalItem
+ )
+ )
+ ); ?>
+ <?php else: ?>
+ <?= $this->icon('down-open'); ?>
+ <?php endif ?>
+ </td>
+ <td>&nbsp;</td>
+ </tr>
+ </tbody>
+</table>
diff --git a/application/views/scripts/services/grid-flipped.phtml b/application/views/scripts/services/grid-flipped.phtml
new file mode 100644
index 0000000..0642eb2
--- /dev/null
+++ b/application/views/scripts/services/grid-flipped.phtml
@@ -0,0 +1,149 @@
+<?php
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Web\Url;
+
+if (! $this->compact): ?>
+ <?= $this->controls ?>
+<?php endif ?>
+<div class="content" data-base-target="_next" id="<?= $this->protectId('content') ?>">
+ <?php if (empty($pivotData)): ?>
+ <div class="item-list">
+ <div class="empty-state-bar"><?= $this->translate('No services found matching the filter.') ?></div>
+ </div>
+</div>
+<?php return; endif;
+$serviceFilter = Filter::matchAny();
+foreach ($pivotData as $serviceDescription => $_) {
+ $serviceFilter->orFilter(Filter::where('service.name', $serviceDescription));
+}
+?>
+<table class="service-grid-table">
+ <thead>
+ <tr>
+ <th><?= $this->partial(
+ 'joystickPagination-icingadb.phtml',
+ 'default',
+ array(
+ 'flippable' => true,
+ 'baseUrl' => $baseUrl,
+ 'xAxisPaginator' => $horizontalPaginator,
+ 'yAxisPaginator' => $verticalPaginator
+ )
+ ) ?></th>
+ <?php foreach ($pivotHeader['cols'] as $hostName => $hostAlias): ?>
+ <th class="rotate-45"><div><span><?= $this->qlink(
+ $this->ellipsis($hostAlias, 24),
+ Url::fromPath('icingadb/services')->addFilter(
+ Filter::matchAll($serviceFilter, Filter::where('host.name', $hostName))
+ ),
+ null,
+ array('title' => sprintf($this->translate('List all reported services on host %s'), $hostAlias)),
+ false
+ ) ?></span></div></th>
+ <?php endforeach ?>
+ </tr>
+ </thead>
+ <tbody>
+
+ <?php $i = 0 ?>
+ <?php foreach ($pivotHeader['rows'] as $serviceDescription => $serviceDisplayName): ?>
+ <tr>
+ <th><?php
+ $hostFilter = Filter::matchAny();
+ foreach ($pivotData[$serviceDescription] as $hostName => $_) {
+ $hostFilter->orFilter(Filter::where('host.name', $hostName));
+ }
+ echo $this->qlink(
+ $serviceDisplayName,
+ Url::fromPath('icingadb/services')->addFilter(
+ Filter::matchAll($hostFilter, Filter::where('service.name', $serviceDescription))
+ ),
+ null,
+ array('title' => sprintf(
+ $this->translate('List all services with the name "%s" on all reported hosts'),
+ $serviceDisplayName
+ ))
+ );
+ ?></th>
+ <?php foreach (array_keys($pivotHeader['cols']) as $hostName): ?>
+ <td><?php
+ $service = $pivotData[$serviceDescription][$hostName];
+ if ($service === null): ?>
+ <span aria-hidden="true">&middot;</span>
+ <?php continue; endif ?>
+ <?php $ariaDescribedById = $this->protectId($service->host_name . '_' . $service->name . '_desc') ?>
+ <span class="sr-only" id="<?= $ariaDescribedById ?>">
+ <?= $this->escape($service->state->output) ?>
+ </span>
+ <?= $this->qlink(
+ '',
+ 'icingadb/services',
+ array(
+ 'host.name' => $hostName,
+ 'name' => $serviceDescription
+ ),
+ array(
+ 'aria-describedby' => $ariaDescribedById,
+ 'aria-label' => sprintf(
+ $this->translate('Show detailed information for service %s on host %s'),
+ $service->display_name,
+ $service->host_display_name
+ ),
+ 'class' => 'service-grid-link state-' . $service->state->getStateText() . ($service->state->is_handled ? ' handled' : ''),
+ 'title' => $service->state->output
+ )
+ ) ?>
+ </td>
+ <?php endforeach ?>
+ <?php
+ $horizontalTotalItems = $this->horizontalPaginator->count();
+ $horizontalItemsPerPage = $this->horizontalPaginator->getLimit() ?? $horizontalTotalItems;
+ $horizontalTotalPages = ceil($horizontalTotalItems / $horizontalItemsPerPage);
+
+ $verticalTotalItems = $this->verticalPaginator->count();
+ $verticalItemsPerPage = $this->verticalPaginator->getLimit() ?? $verticalTotalItems;
+ $verticalTotalPages = ceil($verticalTotalItems / $verticalItemsPerPage);
+
+ if (! $this->compact && $horizontalTotalPages > 1): ?>
+ <td>
+ <?php $expandLink = $this->qlink(
+ $this->translate('Load more'),
+ $baseUrl,
+ array(
+ 'limit' => ($horizontalItemsPerPage + 20)
+ . ','
+ . $verticalItemsPerPage
+ ),
+ array(
+ 'class' => 'action-link',
+ 'data-base-target' => '_self'
+ )
+ ) ?>
+ <?= ++$i === (int) ceil(count($pivotHeader['rows']) / 2) ? $expandLink : '' ?>
+ </td>
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+ <?php if (! $this->compact && $verticalTotalPages > 1): ?>
+ <tr>
+ <td colspan="<?= count($pivotHeader['cols']) + 1?>" class="service-grid-table-more">
+ <?php echo $this->qlink(
+ $this->translate('Load more'),
+ $baseUrl,
+ array(
+ 'limit' => $horizontalItemsPerPage
+ . ','
+ . ($verticalItemsPerPage + 20)
+ ),
+ array(
+ 'class' => 'action-link',
+ 'data-base-target' => '_self'
+ )
+ ) ?>
+ </td>
+ </tr>
+ <?php endif ?>
+ </tbody>
+</table>
+</div>
diff --git a/application/views/scripts/services/grid.phtml b/application/views/scripts/services/grid.phtml
new file mode 100644
index 0000000..f00ce8e
--- /dev/null
+++ b/application/views/scripts/services/grid.phtml
@@ -0,0 +1,150 @@
+<?php
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Web\Url;
+
+if (! $this->compact): ?>
+ <?= $this->controls ?>
+<?php endif ?>
+<div class="content" data-base-target="_next" id="<?= $this->protectId('content') ?>">
+<?php if (empty($pivotData)): ?>
+ <div class="item-list">
+ <div class="empty-state-bar"><?= $this->translate('No services found matching the filter.') ?></div>
+ </div>
+</div>
+<?php return; endif;
+$hostFilter = Filter::matchAny();
+foreach ($pivotData as $hostName => $_) {
+ $hostFilter->orFilter(Filter::where('host.name', $hostName));
+}
+?>
+ <table class="service-grid-table">
+ <thead>
+ <tr>
+ <th><?= $this->partial(
+ 'joystickPagination-icingadb.phtml',
+ 'default',
+ array(
+ 'flippable' => true,
+ 'baseUrl' => $baseUrl,
+ 'xAxisPaginator' => $horizontalPaginator,
+ 'yAxisPaginator' => $verticalPaginator
+ )
+ ); ?></th>
+ <?php foreach ($pivotHeader['cols'] as $serviceDescription => $serviceDisplayName):?>
+ <th class="rotate-45"><div><span><?= $this->qlink(
+ $this->ellipsis($serviceDisplayName, 24),
+ Url::fromPath('icingadb/services')->addFilter(
+ Filter::matchAll($hostFilter, Filter::where('service.name', $serviceDescription))
+ ),
+ null,
+ array('title' => sprintf(
+ $this->translate('List all services with the name "%s" on all reported hosts'),
+ $serviceDisplayName
+ )),
+ false
+ ) ?></span></div></th>
+ <?php endforeach ?>
+ </tr>
+ </thead>
+ <tbody>
+
+ <?php $i = 0 ?>
+ <?php foreach ($pivotHeader['rows'] as $hostName => $hostDisplayName): ?>
+ <tr>
+ <th><?php
+ $serviceFilter = Filter::matchAny();
+ foreach ($pivotData[$hostName] as $serviceName => $_) {
+ $serviceFilter->orFilter(Filter::where('service.name', $serviceName));
+ }
+ echo $this->qlink(
+ $hostDisplayName,
+ Url::fromPath('icingadb/services')->addFilter(
+ Filter::matchAll($serviceFilter, Filter::where('host.name', $hostName))
+ ),
+ null,
+ array('title' => sprintf($this->translate('List all reported services on host %s'), $hostDisplayName))
+ );
+ ?></th>
+ <?php foreach (array_keys($pivotHeader['cols']) as $serviceDescription): ?>
+ <td>
+ <?php
+ $service = $pivotData[$hostName][$serviceDescription];
+ if ($service === null): ?>
+ <span aria-hidden="true">&middot;</span>
+ <?php continue; endif ?>
+ <?php
+ $ariaDescribedById = $this->protectId($service->host_name . '_' . $service->name . '_desc') ?>
+ <span class="sr-only" id="<?= $ariaDescribedById ?>">
+ <?= $this->escape($service->state->output) ?>
+ </span>
+ <?= $this->qlink(
+ '',
+ 'icingadb/service',
+ array(
+ 'host.name' => $hostName,
+ 'name' => $serviceDescription
+ ),
+ array(
+ 'aria-describedby' => $ariaDescribedById,
+ 'aria-label' => sprintf(
+ $this->translate('Show detailed information for service %s on host %s'),
+ $service->display_name,
+ $service->host_display_name
+ ),
+ 'class' => 'service-grid-link state-' . $service->state->getStateText() . ($service->state->is_handled ? ' handled' : ''),
+ 'title' => $service->state->output
+ )
+ ) ?>
+ </td>
+ <?php endforeach ?>
+ <?php
+ $horizontalTotalItems = $this->horizontalPaginator->count();
+ $horizontalItemsPerPage = $this->horizontalPaginator->getLimit() ?? $horizontalTotalItems;
+ $horizontalTotalPages = ceil($horizontalTotalItems / $horizontalItemsPerPage);
+
+ $verticalTotalItems = $this->verticalPaginator->count();
+ $verticalItemsPerPage = $this->verticalPaginator->getLimit() ?? $verticalTotalItems;
+ $verticalTotalPages = ceil($verticalTotalItems / $verticalItemsPerPage);
+
+ if (! $this->compact && $horizontalTotalPages > 1): ?>
+ <td>
+ <?php $expandLink = $this->qlink(
+ $this->translate('Load more'),
+ $baseUrl,
+ array(
+ 'limit' => (
+ $horizontalItemsPerPage + 20) . ','
+ . $verticalItemsPerPage
+ ),
+ array(
+ 'class' => 'action-link',
+ 'data-base-target' => '_self'
+ )
+ ) ?>
+ <?= ++$i === (int) (count($pivotHeader['rows']) / 2) ? $expandLink : '' ?>
+ </td>
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+ <?php if (! $this->compact && $verticalTotalPages > 1): ?>
+ <tr>
+ <td colspan="<?= count($pivotHeader['cols']) + 1?>" class="service-grid-table-more">
+ <?php echo $this->qlink(
+ $this->translate('Load more'),
+ $baseUrl,
+ array(
+ 'limit' => $horizontalItemsPerPage . ',' .
+ ($verticalItemsPerPage + 20)
+ ),
+ array(
+ 'class' => 'action-link',
+ 'data-base-target' => '_self'
+ )
+ ) ?>
+ </td>
+ </tr>
+ <?php endif ?>
+ </tbody>
+ </table>
+</div>
diff --git a/configuration.php b/configuration.php
new file mode 100644
index 0000000..d00737c
--- /dev/null
+++ b/configuration.php
@@ -0,0 +1,570 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb {
+
+ use Icinga\Application\Icinga;
+ use Icinga\Authentication\Auth;
+ use Icinga\Module\Icingadb\Web\Navigation\Renderer\HostProblemsBadge;
+ use Icinga\Module\Icingadb\Web\Navigation\Renderer\ServiceProblemsBadge;
+ use Icinga\Util\StringHelper;
+ use RecursiveDirectoryIterator;
+ use RecursiveIteratorIterator;
+
+ /** @var \Icinga\Application\Modules\Module $this */
+
+ $auth = Auth::getInstance();
+ $authenticated = Icinga::app()->isWeb() && $auth->isAuthenticated();
+
+ $this->provideSetupWizard('Icinga\Module\Icingadb\Setup\IcingaDbWizard');
+
+ $this->providePermission(
+ 'icingadb/command/*',
+ $this->translate('Allow all commands')
+ );
+ $this->providePermission(
+ 'icingadb/command/schedule-check',
+ $this->translate('Allow to schedule host and service checks')
+ );
+ $this->providePermission(
+ 'icingadb/command/schedule-check/active-only',
+ $this->translate('Allow to schedule host and service checks (Only on objects with active checks enabled)')
+ );
+ $this->providePermission(
+ 'icingadb/command/acknowledge-problem',
+ $this->translate('Allow to acknowledge host and service problems')
+ );
+ $this->providePermission(
+ 'icingadb/command/remove-acknowledgement',
+ $this->translate('Allow to remove problem acknowledgements')
+ );
+ $this->providePermission(
+ 'icingadb/command/comment/*',
+ $this->translate('Allow to add and delete host and service comments')
+ );
+ $this->providePermission(
+ 'icingadb/command/comment/add',
+ $this->translate('Allow to add host and service comments')
+ );
+ $this->providePermission(
+ 'icingadb/command/comment/delete',
+ $this->translate('Allow to delete host and service comments')
+ );
+ $this->providePermission(
+ 'icingadb/command/downtime/*',
+ $this->translate('Allow to schedule and delete host and service downtimes')
+ );
+ $this->providePermission(
+ 'icingadb/command/downtime/schedule',
+ $this->translate('Allow to schedule host and service downtimes')
+ );
+ $this->providePermission(
+ 'icingadb/command/downtime/delete',
+ $this->translate('Allow to delete host and service downtimes')
+ );
+ $this->providePermission(
+ 'icingadb/command/process-check-result',
+ $this->translate('Allow to process host and service check results')
+ );
+ $this->providePermission(
+ 'icingadb/command/feature/instance',
+ $this->translate('Allow to toggle instance-wide features')
+ );
+ $this->providePermission(
+ 'icingadb/command/feature/object/*',
+ $this->translate('Allow to toggle all features on host and service objects')
+ );
+ $this->providePermission(
+ 'icingadb/command/feature/object/active-checks',
+ $this->translate('Allow to toggle active checks on host and service objects')
+ );
+ $this->providePermission(
+ 'icingadb/command/feature/object/passive-checks',
+ $this->translate('Allow to toggle passive checks on host and service objects')
+ );
+ $this->providePermission(
+ 'icingadb/command/feature/object/notifications',
+ $this->translate('Allow to toggle notifications on host and service objects')
+ );
+ $this->providePermission(
+ 'icingadb/command/feature/object/event-handler',
+ $this->translate('Allow to toggle event handlers on host and service objects')
+ );
+ $this->providePermission(
+ 'icingadb/command/feature/object/flap-detection',
+ $this->translate('Allow to toggle flap detection on host and service objects')
+ );
+ $this->providePermission(
+ 'icingadb/command/send-custom-notification',
+ $this->translate('Allow to send custom notifications for hosts and services')
+ );
+
+ $this->providePermission(
+ 'icingadb/object/show-source',
+ $this->translate('Allow to view an object\'s source data. (May contain sensitive data!)')
+ );
+
+ $this->provideRestriction(
+ 'icingadb/filter/objects',
+ $this->translate('Restrict access to the Icinga objects that match the filter')
+ );
+
+ $this->provideRestriction(
+ 'icingadb/filter/hosts',
+ $this->translate('Restrict access to the Icinga hosts and services that match the filter')
+ );
+
+ $this->provideRestriction(
+ 'icingadb/filter/services',
+ $this->translate('Restrict access to the Icinga services that match the filter')
+ );
+
+ $this->provideRestriction(
+ 'icingadb/denylist/routes',
+ $this->translate('Prevent access to routes that are part of the list')
+ );
+
+ $this->provideRestriction(
+ 'icingadb/denylist/variables',
+ $this->translate('Hide custom variables of Icinga objects that are part of the list')
+ );
+
+ $this->provideRestriction(
+ 'icingadb/protect/variables',
+ $this->translate('Obfuscate custom variable values of Icinga objects that are part of the list')
+ );
+
+ if (! $this::exists('monitoring') || ($authenticated && ! $auth->getUser()->can('module/monitoring'))) {
+ /*
+ * Available navigation items
+ */
+ $this->provideNavigationItem('icingadb-host-action', $this->translate('Host Action'));
+ $this->provideNavigationItem('icingadb-service-action', $this->translate('Service Action'));
+
+ /**
+ * Search urls
+ */
+ $this->provideSearchUrl(
+ $this->translate('Tactical Overview'),
+ 'icingadb/tactical',
+ 100
+ );
+ $this->provideSearchUrl(
+ $this->translate('Hosts'),
+ 'icingadb/hosts?sort=host.state.severity&limit=10',
+ 99
+ );
+ $this->provideSearchUrl(
+ $this->translate('Services on Hosts'),
+ 'icingadb/services?sort=service.state.severity&limit=10&_hostFilterOnly',
+ 98
+ );
+ $this->provideSearchUrl(
+ $this->translate('Services'),
+ 'icingadb/services?sort=service.state.severity&limit=10',
+ 97
+ );
+ $this->provideSearchUrl(
+ $this->translate('Hostgroups'),
+ 'icingadb/hostgroups?limit=10',
+ 96
+ );
+ $this->provideSearchUrl(
+ $this->translate('Servicegroups'),
+ 'icingadb/servicegroups?limit=10',
+ 95
+ );
+
+ /**
+ * Current Incidents
+ */
+ $dashboard = $this->dashboard(N_('Current Incidents'), ['priority' => 50]);
+ $dashboard->add(
+ N_('Service Problems'),
+ 'icingadb/services?service.state.is_problem=y'
+ . '&view=minimal&limit=32&sort=service.state.severity desc',
+ 100
+ );
+ $dashboard->add(
+ N_('Recently Recovered Services'),
+ 'icingadb/services?service.state.soft_state=0'
+ . '&view=minimal&limit=32&sort=service.state.last_state_change desc',
+ 110
+ );
+ $dashboard->add(
+ N_('Host Problems'),
+ 'icingadb/hosts?host.state.is_problem=y'
+ . '&view=minimal&limit=32&sort=host.state.severity desc',
+ 120
+ );
+
+ /**
+ * Overdue
+ */
+ $dashboard = $this->dashboard(N_('Overdue'), ['priority' => 70]);
+ $dashboard->add(
+ N_('Late Host Check Results'),
+ 'icingadb/hosts?host.state.is_overdue=y'
+ . '&view=minimal&limit=15&sort=host.state.severity desc',
+ 100
+ );
+ $dashboard->add(
+ N_('Late Service Check Results'),
+ 'icingadb/services?service.state.is_overdue=y'
+ . '&view=minimal&limit=15&sort=service.state.severity desc',
+ 110
+ );
+ $dashboard->add(
+ N_('Acknowledgements Active For At Least Three Days'),
+ 'icingadb/comments?comment.entry_type=ack&comment.entry_time<-3 days'
+ . '&view=minimal&limit=15&sort=comment.entry_time',
+ 120
+ );
+ $dashboard->add(
+ N_('Downtimes Active For At Least Three Days'),
+ 'icingadb/downtimes?downtime.is_in_effect=y&downtime.scheduled_start_time<-3 days'
+ . '&view=minimal&limit=15&sort=downtime.start_time',
+ 130
+ );
+
+ /**
+ * Muted
+ */
+ $dashboard = $this->dashboard(N_('Muted'), ['priority' => 80]);
+ $dashboard->add(
+ N_('Disabled Service Notifications'),
+ 'icingadb/services?service.notifications_enabled=n'
+ . '&view=minimal&limit=15&sort=service.state.severity desc',
+ 100
+ );
+ $dashboard->add(
+ N_('Disabled Host Notifications'),
+ 'icingadb/hosts?host.notifications_enabled=n'
+ . '&view=minimal&limit=15&sort=host.state.severity desc',
+ 110
+ );
+ $dashboard->add(
+ N_('Disabled Service Checks'),
+ 'icingadb/services?service.active_checks_enabled=n'
+ . '&view=minimal&limit=15&sort=service.state.last_state_change',
+ 120
+ );
+ $dashboard->add(
+ N_('Disabled Host Checks'),
+ 'icingadb/hosts?host.active_checks_enabled=n'
+ . '&view=minimal&limit=15&sort=host.state.last_state_change',
+ 130
+ );
+ $dashboard->add(
+ N_('Acknowledged Problem Services'),
+ 'icingadb/services?service.state.is_acknowledged!=n&service.state.is_problem=y'
+ . '&view=minimal&limit=15&sort=service.state.severity desc',
+ 140
+ );
+ $dashboard->add(
+ N_('Acknowledged Problem Hosts'),
+ 'icingadb/hosts?host.state.is_acknowledged!=n&host.state.is_problem=y'
+ . '&view=minimal&limit=15&sort=host.state.severity desc',
+ 150
+ );
+
+ /**
+ * @var \Icinga\Application\Modules\Module $this
+ *
+ * Problems section in case monitoring is disabled
+ */
+ $problemSection = $this->menuSection(N_('Problems'), [
+ 'renderer' => array(
+ 'TotalProblemsBadge',
+ 'state' => 'critical'
+ ),
+ 'icon' => 'attention-circled',
+ 'priority' => 20
+ ]);
+ $problemSection->add(N_('Host Problems'), [
+ 'renderer' => (new HostProblemsBadge())->disableLink(),
+ 'icon' => 'server',
+ 'description' => $this->translate('List current host problems'),
+ 'url' => 'icingadb/hosts?host.state.is_problem=y'
+ . '&sort=host.state.severity desc',
+ 'priority' => 50
+ ]);
+ $problemSection->add(N_('Service Problems'), [
+ 'renderer' => (new ServiceProblemsBadge())->disableLink(),
+ 'icon' => 'cog',
+ 'description' => $this->translate('List current service problems'),
+ 'url' => 'icingadb/services?service.state.is_problem=y'
+ . '&sort=service.state.severity desc',
+ 'priority' => 60
+ ]);
+ $problemSection->add(N_('Service Grid'), [
+ 'icon' => 'cogs',
+ 'description' => $this->translate('Display service problems as grid'),
+ 'url' => 'icingadb/services/grid?problems',
+ 'priority' => 70
+ ]);
+
+ $problemSection->add(N_('Current Downtimes'), [
+ 'description' => $this->translate('List current downtimes'),
+ 'url' => 'icingadb/downtimes?downtime.is_in_effect=y',
+ 'priority' => 80,
+ 'icon' => 'plug'
+ ]);
+
+ /**
+ * @var \Icinga\Application\Modules\Module $this
+ *
+ * Overview section in case monitoring is disabled
+ */
+ $overviewSection = $this->menuSection('Overview', [
+ 'icon' => 'binoculars',
+ 'priority' => 30
+ ]);
+
+ $overviewSection->add(N_('Tactical Overview'), [
+ 'url' => 'icingadb/tactical',
+ 'description' => $this->translate('Open tactical overview'),
+ 'priority' => 40,
+ 'icon' => 'chart-pie'
+ ]);
+ $overviewSection->add(N_('Hosts'), [
+ 'priority' => 50,
+ 'description' => $this->translate('List hosts'),
+ 'url' => 'icingadb/hosts',
+ 'icon' => 'server'
+ ]);
+ $overviewSection->add(N_('Services'), [
+ 'priority' => 60,
+ 'description' => $this->translate('List services'),
+ 'url' => 'icingadb/services',
+ 'icon' => 'cog'
+ ]);
+ $routeDenylist = [];
+ if ($authenticated && ! $auth->getUser()->isUnrestricted()) {
+ // The empty array is for PHP pre 7.4, older versions require at least a single param for array_merge
+ $routeDenylist = array_flip(array_merge([], ...array_map(function ($restriction) {
+ return StringHelper::trimSplit($restriction);
+ }, $auth->getRestrictions('icingadb/denylist/routes'))));
+ }
+
+ if (! array_key_exists('hostgroups', $routeDenylist)) {
+ $overviewSection->add(N_('Host Groups'), [
+ 'description' => $this->translate('List host groups'),
+ 'url' => 'icingadb/hostgroups',
+ 'priority' => 70,
+ 'icon' => 'network-wired'
+ ]);
+ }
+
+ if (! array_key_exists('servicegroups', $routeDenylist)) {
+ $overviewSection->add(N_('Service Groups'), [
+ 'description' => $this->translate('List service groups'),
+ 'url' => 'icingadb/servicegroups',
+ 'priority' => 80,
+ 'icon' => 'cogs'
+ ]);
+ }
+
+ if (! array_key_exists('usergroups', $routeDenylist)) {
+ $overviewSection->add(N_('User Groups'), [
+ 'description' => $this->translate('List user groups'),
+ 'url' => 'icingadb/usergroups',
+ 'priority' => 90,
+ 'icon' => 'users'
+ ]);
+ }
+
+ if (! array_key_exists('users', $routeDenylist)) {
+ $overviewSection->add(N_('Users'), [
+ 'description' => $this->translate('List users'),
+ 'url' => 'icingadb/users',
+ 'priority' => 100,
+ 'icon' => 'user-friends'
+ ]);
+ }
+
+
+
+ $overviewSection->add(N_('Comments'), [
+ 'url' => 'icingadb/comments',
+ 'description' => $this->translate('List comments'),
+ 'priority' => 110,
+ 'icon' => 'comments'
+ ]);
+ $overviewSection->add(N_('Downtimes'), [
+ 'url' => 'icingadb/downtimes',
+ 'description' => $this->translate('List downtimes'),
+ 'priority' => 120,
+ 'icon' => 'plug'
+ ]);
+
+ /**
+ * @var \Icinga\Application\Modules\Module $this
+ *
+ * History section in case monitoring is disabled
+ */
+
+ $section = $this->menuSection(N_('History'), array(
+ 'icon' => 'history',
+ 'priority' => 90
+ ));
+ $section->add(N_('Notifications'), array(
+ 'icon' => 'bell',
+ 'description' => $this->translate('List notifications'),
+ 'priority' => 20,
+ 'url' => 'icingadb/notifications',
+ ));
+ $section->add(N_('Event Overview'), array(
+ 'icon' => 'history',
+ 'description' => $this->translate('Open event overview'),
+ 'priority' => 30,
+ 'url' => 'icingadb/history'
+ ));
+ } else {
+ /*
+ * Available navigation items
+ */
+ $this->provideNavigationItem(
+ 'icingadb-host-action',
+ $this->translate('Host Action') . ' (Icinga DB)'
+ );
+ $this->provideNavigationItem(
+ 'icingadb-service-action',
+ $this->translate('Service Action') . ' (Icinga DB)'
+ );
+
+ /** @var \Icinga\Application\Modules\Module $this */
+ $section = $this->menuSection('Icinga DB', [
+ 'icon' => 'database',
+ 'priority' => 30
+ ]);
+
+ $section->add(N_('Tactical Overview'), [
+ 'url' => 'icingadb/tactical',
+ 'priority' => 10,
+ 'description' => $this->translate('Open tactical overview'),
+ 'icon' => 'chart-pie'
+ ]);
+
+ $section->add(N_('Hosts'), [
+ 'priority' => 20,
+ 'description' => $this->translate('List hosts'),
+ 'renderer' => 'HostProblemsBadge',
+ 'url' => 'icingadb/hosts',
+ 'icon' => 'server'
+ ]);
+ $section->add(N_('Services'), [
+ 'priority' => 30,
+ 'description' => $this->translate('List services'),
+ 'renderer' => 'ServiceProblemsBadge',
+ 'url' => 'icingadb/services',
+ 'icon' => 'cog'
+ ]);
+ $section->add(N_('Service Grid'), [
+ 'icon' => 'cog',
+ 'description' => $this->translate('Display service problems as grid'),
+ 'url' => 'icingadb/services/grid?problems',
+ 'priority' => 40
+ ]);
+
+ $routeDenylist = [];
+ if ($authenticated && ! $auth->getUser()->isUnrestricted()) {
+ // The empty array is for PHP pre 7.4, older versions require at least a single param for array_merge
+ $routeDenylist = array_flip(array_merge([], ...array_map(function ($restriction) {
+ return StringHelper::trimSplit($restriction);
+ }, $auth->getRestrictions('icingadb/denylist/routes'))));
+ }
+
+ if (! array_key_exists('hostgroups', $routeDenylist)) {
+ $section->add(N_('Host Groups'), [
+ 'url' => 'icingadb/hostgroups',
+ 'priority' => 50,
+ 'description' => $this->translate('List host groups'),
+ 'icon' => 'network-wired'
+ ]);
+ }
+
+ if (! array_key_exists('servicegroups', $routeDenylist)) {
+ $section->add(N_('Service Groups'), [
+ 'url' => 'icingadb/servicegroups',
+ 'priority' => 60,
+ 'description' => $this->translate('List service groups'),
+ 'icon' => 'cogs'
+ ]);
+ }
+
+ if (! array_key_exists('usergroups', $routeDenylist)) {
+ $section->add(N_('User Groups'), [
+ 'url' => 'icingadb/usergroups',
+ 'priority' => 70,
+ 'description' => $this->translate('List user groups'),
+ 'icon' => 'users'
+ ]);
+ }
+
+ if (! array_key_exists('users', $routeDenylist)) {
+ $section->add(N_('Users'), [
+ 'url' => 'icingadb/users',
+ 'priority' => 80,
+ 'description' => $this->translate('List users'),
+ 'icon' => 'user-friends'
+ ]);
+ }
+
+ $section->add(N_('Comments'), [
+ 'url' => 'icingadb/comments',
+ 'priority' => 90,
+ 'description' => $this->translate('List comments'),
+ 'icon' => 'comments'
+ ]);
+ $section->add(N_('Downtimes'), [
+ 'url' => 'icingadb/downtimes',
+ 'priority' => 100,
+ 'description' => $this->translate('List downtimes'),
+ 'icon' => 'plug'
+ ]);
+ $section->add(N_('Notifications'), [
+ 'url' => 'icingadb/notifications',
+ 'priority' => 110,
+ 'description' => $this->translate('List notifications'),
+ 'icon' => 'bell'
+ ]);
+ $section->add(N_('History'), [
+ 'url' => 'icingadb/history',
+ 'priority' => 120,
+ 'description' => $this->translate('List history'),
+ 'icon' => 'history'
+ ]);
+ }
+
+ $this->provideConfigTab('database', [
+ 'label' => t('Database'),
+ 'title' => t('Configure the database backend'),
+ 'url' => 'config/database'
+ ]);
+ $this->provideConfigTab('redis', [
+ 'label' => t('Redis'),
+ 'title' => t('Configure the Redis connections'),
+ 'url' => 'config/redis'
+ ]);
+ $this->provideConfigTab('command-transports', [
+ 'label' => t('Command Transports'),
+ 'title' => t('Configure command transports'),
+ 'url' => 'command-transport'
+ ]);
+
+ $cssDirectory = $this->getCssDir();
+ $cssFiles = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
+ $cssDirectory,
+ RecursiveDirectoryIterator::CURRENT_AS_PATHNAME | RecursiveDirectoryIterator::SKIP_DOTS
+ ));
+ foreach ($cssFiles as $path) {
+ $this->provideCssFile(ltrim(substr($path, strlen($cssDirectory)), DIRECTORY_SEPARATOR));
+ }
+
+ $this->provideJsFile('action-list.js');
+ $this->provideJsFile('loadmore.js');
+ $this->provideJsFile('migrate.js');
+ $this->provideJsFile('progress-bar.js');
+}
diff --git a/doc/01-About.md b/doc/01-About.md
new file mode 100644
index 0000000..a13333e
--- /dev/null
+++ b/doc/01-About.md
@@ -0,0 +1,64 @@
+# Icinga DB Web
+
+Icinga DB is a set of components for publishing, synchronizing and
+visualizing monitoring data in the Icinga ecosystem, consisting of:
+
+* Icinga DB Web which connects to both a Redis server and a database to view and work with monitoring data
+* Icinga 2 with its [Icinga DB feature](https://icinga.com/docs/icinga-2/latest/14-features/#icinga-db) enabled,
+ responsible for publishing monitoring configuration, check results,
+ states changes and history items to the Redis server
+* And the [Icinga DB daemon](https://icinga.com/docs/icinga-db/latest/01-About/),
+ which synchronizes monitoring data between the Redis server and the database
+
+![Icinga DB Architecture](res/icingadb-architecture.png)
+
+## Features
+
+Icinga DB Web offers a modern and streamlined design to provide a clear and
+concise overview of your monitoring environment, also with dark and light mode support.
+
+![Icinga DB Dashboard](res/icingadb-dashboard.png)
+
+### Various List Layouts
+
+The view switcher allows to control the level of detail displayed in host and service list views:
+
+![View Switcher Preview](res/view-switcher-preview.png)
+
+### Search with Autocomplete
+
+The search bar in list views can be used for everything from simple searches to creating complex filters.
+It allows full keyboard control and also supports contextual auto-completion.
+In addition, there is an editor for easier filter creation.
+
+![Searchbar Completion Preview](res/searchbar-completion-preview.png)
+
+### Clean Detail Views
+
+Host and service detail views are structured to make best use of available space.
+Related information is grouped and important information is at the top for instant access without having to scroll down.
+
+![Service Detail Preview](res/service-detail-preview.png)
+
+### Modal Dialogs
+
+Any interaction that requires user input, such as acknowledging problems, scheduling downtimes, etc.,
+shows a modal dialog over the current view to preserve context and focus on interaction.
+
+![Modal Dialog Preview](res/modal-dialog-preview.png)
+
+### Bulk Operations
+
+Bulk interactions such as scheduling downtimes for multiple objects, acknowledging multiple problems, etc.
+are easily accomplished with the `Continue With` control that operates on filtered lists.
+
+![Continue With Preview](res/continue-with-preview.png)
+
+## Installation
+
+To install Icinga DB Web see [Installation](02-Installation.md).
+
+## License
+
+Icinga DB Web and the Icinga DB Web documentation are licensed under the terms of the
+GNU General Public License Version 2.
diff --git a/doc/02-Installation.md b/doc/02-Installation.md
new file mode 100644
index 0000000..1e68342
--- /dev/null
+++ b/doc/02-Installation.md
@@ -0,0 +1,28 @@
+<!-- {% if index %} -->
+# Installing Icinga DB Web
+
+The recommended way to install Icinga DB Web is to use prebuilt packages for
+all supported platforms from our official release repository.
+Please follow the steps listed for your target operating system,
+which guide you through setting up the repository and installing Icinga DB Web.
+
+To upgrade an existing Icinga DB Web installation to a newer version,
+see the [Upgrading](05-Upgrading.md) documentation for the necessary steps.
+
+![Icinga DB Web](res/icingadb-web.png)
+
+Before installing Icinga DB Web, make sure you have installed the
+[Icinga DB daemon](https://icinga.com/docs/icinga-db/latest/doc/02-Installation/).
+
+<!-- {% else %} -->
+<!-- {% if not icingaDocs %} -->
+
+## Installing the Package
+
+If the [repository](https://packages.icinga.com) is not configured yet, please add it first.
+Then use your distribution's package manager to install the `icingadb-web` package
+or install [from source](02-Installation.md.d/From-Source.md).
+<!-- {% endif %} -->
+
+This concludes the installation. Now proceed with the [configuration](03-Configuration.md).
+<!-- {% endif %} --><!-- {# end else if index #} -->
diff --git a/doc/02-Installation.md.d/From-Source.md b/doc/02-Installation.md.d/From-Source.md
new file mode 100644
index 0000000..b430d16
--- /dev/null
+++ b/doc/02-Installation.md.d/From-Source.md
@@ -0,0 +1,17 @@
+# Installing Icinga DB Web from Source
+
+Please see the Icinga Web documentation on
+[how to install modules](https://icinga.com/docs/icinga-web-2/latest/doc/08-Modules/#installation) from source.
+Make sure you use `icingadb` as the module name. The following requirements must also be met.
+
+### Requirements
+
+* PHP (≥7.2)
+* MySQL or PostgreSQL PDO PHP libraries
+* The following PHP modules must be installed: `cURL`, `dom`, `json`, `libxml`
+* [Icinga DB](https://github.com/Icinga/icingadb)
+* [Icinga Web 2](https://github.com/Icinga/icingaweb2) (≥2.9)
+* [Icinga PHP Library (ipl)](https://github.com/Icinga/icinga-php-library) (≥0.13)
+* [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (≥0.12)
+
+<!-- {% include "02-Installation.md" %} -->
diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md
new file mode 100644
index 0000000..ce24277
--- /dev/null
+++ b/doc/03-Configuration.md
@@ -0,0 +1,67 @@
+# Configuration
+
+If Icinga Web has been installed but not yet set up, please visit Icinga Web and follow the web-based setup wizard.
+For Icinga Web setups already running, log in to Icinga Web with a privileged user and follow the steps below to
+configure Icinga DB Web:
+
+If you have previously used the monitoring module, there is an option to [migrate](10-Migration.md) some settings.
+
+## Database Configuration
+
+Connection configuration for the database to which Icinga DB synchronizes monitoring data.
+
+1. Create a new resource for the Icinga DB database via the `Configuration → Application → Resources` menu.
+
+2. Configure the resource you just created as the database connection for the Icinga DB Web module using the
+ `Configuration → Modules → icingadb → Database` menu.
+
+## Redis Configuration
+
+Connection configuration for the Redis server where Icinga 2 writes check results.
+This data is used to display the latest state information in Icinga DB Web.
+
+1. Configure the connection to the Redis server through the `Configuration → Modules → icingadb → Redis` menu.
+
+!!! info
+
+ If you are running a high-availability Icinga 2 setup,
+ also configure the secondary master's Redis connection details.
+ Icinga DB Web then uses this connection if the primary one is not available.
+
+## Command Transport Configuration
+
+In order to acknowledge problems, force checks, schedule downtimes, etc.,
+Icinga DB Web needs access to the Icinga 2 API.
+For this you need an `ApiUser` object with at least the following permissions on the Icinga 2 side:
+
+* `actions/*`
+* `objects/query/*`
+* `objects/modify/*`
+* `status/query`
+
+!!! tip
+
+ For single-node setups it is recommended to manage API credentials in the `/etc/icinga2/conf.d/api-users.conf` file.
+ If you are running a high-availability Icinga 2 setup, please manage the credentials in the master zone.
+
+1. Please add the following Icinga 2 configuration and change the password accordingly:
+ ```
+ object ApiUser "icingadb-web" {
+ password = "CHANGEME"
+ permissions = [ "actions/*", "objects/modify/*", "objects/query/*", "status/query" ]
+ }
+ ```
+2. Restart Icinga 2 for these changes to take effect.
+3. Then configure a command transport for Icinga DB Web
+ using the credentials you just created via the `Configuration → Modules → icingadb → Command Transports` menu.
+
+!!! info
+
+ If you are running a high-availability Icinga 2 setup,
+ also configure the secondary master's API command transport.
+ Icinga DB Web then uses this transport if the primary one is not available.
+
+## Security
+
+To grant users permissions to run commands and restrict them to specific views,
+see the [Security](04-Security.md) documentation for the necessary steps.
diff --git a/doc/04-Security.md b/doc/04-Security.md
new file mode 100644
index 0000000..d3e7545
--- /dev/null
+++ b/doc/04-Security.md
@@ -0,0 +1,142 @@
+# Security
+
+Icinga DB Web allows users to send commands to Icinga. Users may be restricted to a specific set of commands,
+by use of **permissions**.
+
+The same applies to routes, objects and variables. To these, users can be restricted by use of **restrictions**.
+
+## Permissions
+
+> **Restricted Command Permissions:**
+>
+> If a role [limits users](#filters) to a specific set of results, the command
+> permissions or refusals of the very same role only apply to these results.
+
+| Name | Allow... |
+|------------------------------------------------|----------------------------------------------------------------------------------|
+| icingadb/command/* | all commands |
+| icingadb/command/schedule-check | to schedule host and service checks |
+| icingadb/command/schedule-check/active-only | to schedule host and service checks (Only on objects with active checks enabled) |
+| icingadb/command/acknowledge-problem | to acknowledge host and service problems |
+| icingadb/command/remove-acknowledgement | to remove problem acknowledgements |
+| icingadb/command/comment/* | to add and delete host and service comments |
+| icingadb/command/comment/add | to add host and service comments |
+| icingadb/command/comment/delete | to delete host and service comments |
+| icingadb/command/downtime/* | to schedule and delete host and service downtimes |
+| icingadb/command/downtime/schedule | to schedule host and service downtimes |
+| icingadb/command/downtime/delete | to delete host and service downtimes |
+| icingadb/command/process-check-result | to process host and service check results |
+| icingadb/command/feature/instance | to toggle instance-wide features |
+| icingadb/command/feature/object/* | to toggle all features on host and service objects |
+| icingadb/command/feature/object/active-checks | to toggle active checks on host and service objects |
+| icingadb/command/feature/object/passive-checks | to toggle passive checks on host and service objects |
+| icingadb/command/feature/object/notifications | to toggle notifications on host and service objects |
+| icingadb/command/feature/object/event-handler | to toggle event handlers on host and service objects |
+| icingadb/command/feature/object/flap-detection | to toggle flap detection on host and service objects |
+| icingadb/command/send-custom-notification | to send custom notifications for hosts and services |
+| icingadb/object/show-source | to view an object's source data. (May contain sensitive data!) |
+
+## Restrictions
+
+### Filters
+
+Filters limit users to a specific set of results.
+
+> **Note:**
+>
+> Filters from multiple roles will widen available access.
+
+| Name | Description |
+|--------------------------|------------------------------------------------------------------------|
+| icingadb/filter/objects | Restrict access to the Icinga objects that match the filter |
+| icingadb/filter/hosts | Restrict access to the Icinga hosts and services that match the filter |
+| icingadb/filter/services | Restrict access to the Icinga services that match the filter |
+
+`icingadb/filter/objects` will only allow users to access matching objects. This applies to all objects,
+not just hosts or services. It should be one or more [filter expressions](#filter-expressions).
+
+`icingadb/filter/hosts` will only allow users to access matching hosts and services. Other objects remain
+unrestricted. It should be one or more [filter expressions](#filter-expressions).
+
+`icingadb/filter/services` will only allow users to access matching services. Other objects remain unrestricted.
+It should be one or more [filter expressions](#filter-expressions).
+
+### Denylists
+
+Denylists prevent users from accessing information and in some cases will block them entirely from it.
+
+> **Note:**
+>
+> Denylists from multiple roles will further limit access.
+
+Name | Description
+-----------------------------|------------------------------------------------------------------
+icingadb/denylist/routes | Prevent access to routes that are part of the list
+icingadb/denylist/variables | Hide custom variables of Icinga objects that are part of the list
+
+`icingadb/denylist/routes` will block users from accessing defined routes and from related information elsewhere.
+For example, if `hostgroups` are part of the list a user won't have access to the hostgroup overview nor to a host's
+groups shown in its detail area. This should be a comma separated list. Possible values are: hostgroups, servicegroups,
+users, usergroups
+
+`icingadb/denylist/variables` will block users from accessing certain custom variables. A user affected by this won't
+see that those variables even exist. This should be a comma separated list of [variable paths](#variable-paths). It is
+possible to use [match patterns](#match-patterns).
+
+### Protections
+
+Protections prevent users from accessing actual data. They will know that there is something, but not what exactly.
+
+> **Note:**
+>
+> Denylists from multiple roles will further limit access.
+
+Name | Description
+---------------------------|-----------------------------------------------------------------------------
+icingadb/protect/variables | Obfuscate custom variable values of Icinga objects that are part of the list
+
+`icingadb/protect/variables` will replace certain custom variable values with `***`. A user affected by this will still
+be able to see the variable names though. This should be a comma separated list of [variable paths](#variable-paths).
+It is possible to use [match patterns](#match-patterns).
+
+### Formats
+
+#### Filter Expressions
+
+These consist of one or more [filter expressions](https://icinga.com/docs/icinga-web-2/latest/doc/06-Security/#filter-expressions).
+
+Allowed columns are:
+
+* host.name
+* service.name
+* hostgroup.name
+* servicegroup.name
+* host.user.name
+* service.user.name
+* host.usergroup.name
+* service.usergroup.name
+* host.vars.[\<variable path\>](#variable-paths)
+* service.vars.[\<variable path\>](#variable-paths)
+
+#### Match Patterns
+
+Match patterns allow a very simple form of wildcard matching. Use `*` in any place to match zero or any characters.
+
+#### Variable Paths
+
+Icinga DB Web allows to express any custom variable depth in variable paths. So they may be not just names.
+
+Consider the following variables:
+
+```
+vars.os = "Linux"
+vars.team = {
+ name = "Support",
+ on-site = ["mo", "tue", "wed", "thu", "fr"]
+}
+```
+
+To reference `vars.os`, use `os`. To reference `vars.team.name`, use `team.name`. To reference `vars.team.on-site`,
+use `team.on-site[0]` for the first list position and `team.on-site[4]` for the last one.
+
+Combined with a [match pattern](#match-patterns) it is also possible to perform a *contains* check: `team.on-site[*]`
diff --git a/doc/05-Upgrading.md b/doc/05-Upgrading.md
new file mode 100644
index 0000000..007a4c1
--- /dev/null
+++ b/doc/05-Upgrading.md
@@ -0,0 +1,37 @@
+# Upgrading Icinga DB Web
+
+Specific version upgrades are described below. Please note that version upgrades are incremental.
+If you are upgrading across multiple versions, make sure to follow the steps for each of them.
+
+## Upgrading to Icinga DB Web v1.1
+
+**Breaking Changes**
+
+We've extended our filter syntax to include new signs for *like* and *unlike* comparisons. These are `~` and `!~`,
+respectively. The *equal* (`=`) and *unequal* (`!=`) operators won't perform any wildcard matching anymore due to
+this. If you have dashboards, navigation items or bookmarks that attempt to perform wildcard matching with *equal*/
+*unequal* comparisons, the migration widget in the top right will toggle and suggest you an automatically transformed
+alternative.
+
+Please note that due to our release process, this change already affects installations of Icinga DB Web v1.0.x.
+
+The module's migration command already performs the transformations necessary once you migrate navigation items,
+dashboards and roles. (As described in the [Migration](10-Migration.md) chapter.) If you already migrated such
+manually in the past, and you don't want to perform the entire migration again, you can use the following command
+to only transform filters of such:
+
+`icingacli icingadb migrate filter [--no-backup]`
+
+By default, this creates backups of menu items, dashboards and roles. Pass `--no-backup` to disable this.
+
+## Upgrading to Icinga DB Web v1.0
+
+**Requirements**
+
+* You need at least Icinga DB version 1.0.0 to run Icinga DB Web v1.0.0.
+
+**Configuration Changes**
+
+* The restrictions `icingadb/blacklist/routes` and `icingadb/blacklist/variables` have been renamed to
+ `icingadb/denylist/routes` and `icingadb/denylist/variables` respectively. If you use this restriction,
+ make sure to adjust `/etc/icingaweb2/roles.ini` accordingly.
diff --git a/doc/09-Automation.md b/doc/09-Automation.md
new file mode 100644
index 0000000..f514188
--- /dev/null
+++ b/doc/09-Automation.md
@@ -0,0 +1,233 @@
+# Automation
+
+It is possible to issue command actions without a browser. To do so, a form needs to be submitted by a tool such as
+cUrl. This is also used in the example below.
+
+## Request Format
+
+The request is required to be an Icinga Web API request. For this it is necessary to transmit the `Accept` HTTP header
+and set it to `application/json`. In addition to this, the request must be authenticated using the `Basic` schema.
+
+All endpoints support filters. To issue commands only for specific items, define a filter in the request's query string.
+If this filter is omitted, all items are affected.
+
+The options need to be transmitted in the request body as `multipart/form-data`.
+
+## Response Format
+
+If the request succeeds, the HTTP response code is `200` and the response body contains a JSON object such as this:
+
+```json
+{
+ "status": "success",
+ "data": [
+ {
+ "type": "success",
+ "message": "Added comment successfully"
+ }
+ ]
+}
+```
+
+If there's something wrong with the options, the HTTP response code is `422` and the response body contains a JSON
+object such as this:
+
+```json
+{
+ "status": "fail",
+ "data": {
+ "comment": [],
+ "expire": [],
+ "expire_time": ["The expire time must not be in the past"]
+ }
+}
+```
+
+## Example
+
+```shell
+USER="icingaadmin"
+PASSWORD="icinga"
+BASEURL="http://localhost/icingaweb2"
+FILTER="host.name=docker-master"
+curl -H "Accept: application/json" -u $USER:$PASSWORD "$BASEURL/icingadb/hosts/add-comment?$FILTER" \
+ -F "comment=kaput" -F "expire_time=2023-10-05T20:00:00" -F "expire=y"
+```
+
+## Option Types
+
+### Text
+
+A simple text message. May contain newlines.
+
+### Number
+
+An integer value.
+
+### BoolEnum
+
+A string with the value of `y` for `true` and `n` for `false`.
+
+### DateTime
+
+A date time string in the following format: `Y-m-d\TH:i:s`
+
+The timezone this is interpreted in depends on the user who's transmitting the request.
+To change this, log in as this user and change its preference setting.
+
+### State
+
+| Value | Description | Applicable To |
+|-------|----------------|----------------|
+| 0 | UP / OK | Host / Service |
+| 1 | DOWN / WARNING | Host / Service |
+| 2 | CRITICAL | Service |
+| 3 | UNKNOWN | Service |
+
+### PerfData
+
+Please have a look at the [Monitoring Plugins Development Guidelines](https://www.monitoring-plugins.org/doc/guidelines.html#AEN201).
+
+### ChildOption
+
+| Value | Description |
+|-------|-----------------------------------------------------|
+| 0 | Do nothing with child hosts |
+| 1 | Schedule triggered downtime for all child hosts |
+| 2 | Schedule non-triggered downtime for all child hosts |
+
+## Endpoints
+
+### Acknowledge Problem
+
+#### Routes
+
+* icingadb/hosts/acknowledge
+* icingadb/services/acknowledge
+
+#### Options
+
+| Option | Required | Type | Depends On |
+|-------------|----------|----------|------------|
+| comment | y | Text | - |
+| persistent | n | BoolEnum | - |
+| notify | n | BoolEnum | - |
+| sticky | n | BoolEnum | - |
+| expire | n | BoolEnum | - |
+| expire_time | y | DateTime | expire |
+
+### Add Comment
+
+#### Routes
+
+* icingadb/hosts/add-comment
+* icingadb/services/add-comment
+
+#### Options
+
+| Option | Required | Type | Depends On |
+|-------------|----------|----------|------------|
+| comment | y | Text | - |
+| expire | n | BoolEnum | - |
+| expire_time | y | DateTime | expire |
+
+### Check Now
+
+#### Routes
+
+* icingadb/hosts/check-now
+* icingadb/services/check-now
+
+#### Options
+
+None.
+
+### Process Check Result
+
+#### Routes
+
+* icingadb/hosts/process-checkresult
+* icingadb/services/process-checkresult
+
+#### Options
+
+| Option | Required | Type |
+|----------|----------|----------|
+| status | y | State |
+| output | y | Text |
+| perfdata | n | PerfData |
+
+### Remove Acknowledgement
+
+#### Routes
+
+* icingadb/hosts/remove-acknowledgement
+* icingadb/services/remove-acknowledgement
+
+#### Options
+
+None.
+
+### Schedule Check
+
+#### Routes
+
+* icingadb/hosts/schedule-check
+* icingadb/services/schedule-check
+
+#### Options
+
+| Option | Required | Type |
+|-------------|----------|----------|
+| check_time | y | DateTime |
+| force_check | n | BoolEnum |
+
+### Schedule Host Downtime
+
+#### Routes
+
+* icingadb/hosts/schedule-downtime
+
+#### Options
+
+| Option | Required | Type | Depends On |
+|---------------|----------|-------------|------------|
+| comment | y | Text | - |
+| start | y | DateTime | - |
+| end | y | DateTime | - |
+| flexible | n | BoolEnum | - |
+| hours | y | Number | flexible |
+| minutes | y | Number | flexible |
+| all_services | n | BoolEnum | - |
+| child_options | n | ChildOption | - |
+
+### Schedule Service Downtime
+
+#### Routes
+
+* icingadb/services/schedule-check
+
+#### Options
+
+| Option | Required | Type | Depends On |
+|----------|----------|-------------|------------|
+| comment | y | Text | - |
+| start | y | DateTime | - |
+| end | y | DateTime | - |
+| flexible | n | BoolEnum | - |
+| hours | y | Number | flexible |
+| minutes | y | Number | flexible |
+
+### Send Custom Notification
+
+#### Routes
+
+* icingadb/hosts/send-custom-notification
+* icingadb/services/send-custom-notification
+
+#### Options
+
+| Option | Required | Type |
+|---------|----------|----------|
+| comment | y | Text |
+| forced | n | BoolEnum |
diff --git a/doc/10-Migration.md b/doc/10-Migration.md
new file mode 100644
index 0000000..a8ed80f
--- /dev/null
+++ b/doc/10-Migration.md
@@ -0,0 +1,160 @@
+# Migration
+
+If you previously used the monitoring module, (built into Icinga Web 2) you may want to
+migrate your existing configuration, custom dashboards and navigation items as well as
+permissions or restrictions.
+
+If that is the case, this chapter has you covered.
+
+## Configuration
+
+### Command Transports
+
+Icinga DB Web still uses the same configuration format for command transports. This means that the file
+`/etc/icingaweb2/modules/monitoring/commandtransports.ini` can simply be copied over to
+`/etc/icingaweb2/modules/icingadb/commandtransports.ini`.
+
+But note that Icinga DB Web doesn't support the commandfile (local and remote) anymore. Remove all sections
+that do **not** define `transport=api`.
+
+### Protected Customvars
+
+The rules previously configured at `Configuration -> Modules -> monitoring -> Security` have moved into the
+roles configuration as a new restriction. This is called `icingadb/protect/variables` and accepts the same
+rules. Just copy them over.
+
+## Navigation
+
+The monitoring module provides two custom navigation item types: `host-action` and `service-action`
+Icinga DB Web does the same, though uses different type names to achieve that: `icingadb-host-action`
+and `icingadb-service-action`
+
+With Icinga DB Web 1.1, its migrate command allows you to migrate these navigation items automatically:
+
+`icingacli icingadb migrate navigation --user=<name> [--no-backup] [--override]`
+
+By default, this only migrates navigation items of specific users and keeps the old ones. The `--user`
+switch expects a username, with optional wildcards (`*`) to match multiple users. `--user=*` matches
+all users. Pass `--no-backup` to fully remove the old monitoring navigation items.
+
+A similar version of this command has already been available since Icinga Web 2.9.4. Due to this, the new
+command allows you to perform the migration from scratch again with the `--override` switch. (Provided you
+still have the old navigation items.) Otherwise, already migrated items are ignored. That's also a difference
+to the previous command, which duplicated items instead.
+
+## Dashboards
+
+The dashboard item configuration does not change since it is related to Icinga Web. However, items that
+reference views of the monitoring module should be changed in order to permanently reference views of
+Icinga DB Web.
+
+With Icinga DB Web 1.1, its migrate command allows you to migrate such dashboard items automatically:
+
+`icingacli icingadb migrate dashboard --user=<name> [--no-backup]`
+
+By default, this only migrates dashboards of specific users and creates backups. The `--user` switch
+expects a username, with optional wildcards (`*`) to match multiple users. `--user=*` matches all users.
+Pass `--no-backup` to disable backup creation. Please note, if you do so, that this makes resetting
+changes more difficult.
+
+### Automation
+
+For those who integrate Icinga Web into e.g. custom dashboards, there is also a way to automate the
+migration of urls. An API endpoint in Icinga DB Web allows for this:
+
+`/icingaweb2/icingadb/migrate/monitoring-url`
+
+If you `POST` a JSON list there, you'll get a JSON list back with the transformed urls in it.
+The returned list is ordered the same and any unrecognized url is left unchanged:
+
+**Input:**
+```json
+[
+ "/icingaweb2/monitoring/list/services?hostgroup_name=prod-hosts|(_host_env=prod&_host_stage!=testing)",
+ "/icingaweb2/businessprocess/process/show?config=production"
+]
+```
+
+**Output**:
+```json
+[
+ "/icingaweb2/icingadb/services?hostgroup.name=prod-hosts|(host.vars.env=prod&host.vars.stage!=testing)",
+ "/icingaweb2/businessprocess/process/show?config=production"
+]
+```
+
+**cURL example:**
+`curl -s -HContent-Type:application/json -HAccept:application/json -u icingaadmin:icinga http://localhost/icingaweb2/icingadb/migrate/monitoring-url -d '["/icingaweb2/monitoring/list/services?hostgroup_name=prod-hosts|(_host_env=prod&_host_stage!=testing)","/icingaweb2/businessprocess/process/show?config=production"]'`
+
+
+## Views and Exports
+
+### Url Parameter `addColumns`
+
+The host and service list of the monitoring module allows to show/export additional information per object by using the
+URL parameter `addColumns`. Icinga DB Web has a very similar but much enhanced parameter: `columns`
+
+If you pass this to the host and service list of Icinga DB Web, you'll get an entirely different view mode in which you
+have full control over the information displayed. The parameter accepts a comma separated list of columns. This list
+also defines the order in which the columns are shown.
+
+As of now, there is no dedicated control in the UI to conveniently choose those columns. You can use all columns
+however, which are valid in the search bar as well. The migration widget, that's shown if you have access to
+monitoring and Icinga DB Web, also assists you by providing an example set of columns conveying the same information
+shown in the monitoring module lists.
+
+## Access Control
+
+### `monitoring/filter/objects`
+
+This is now `icingadb/filter/objects` but still accepts the same filter syntax. Only the columns have changed
+or support for them has been dropped. Check the table below for details:
+
+| Old Column Name | New Column Name |
+|----------------------|------------------------|
+| instance\_name | - |
+| host\_name | host.name |
+| hostgroup\_name | hostgroup.name |
+| service\_description | service.name |
+| servicegroup\_name | servicegroup.name |
+| \_host\_customvar | host.vars.customvar |
+| \_service\_customvar | service.vars.customvar |
+
+### `monitoring/blacklist/properties`
+
+This is now `icingadb/denylist/variables`. However, it does not accept the same rules as
+`monitoring/blacklist/properties`. It still accepts a comma separated list of GLOB like filters,
+but with some features removed:
+
+* No distinction between host and service variables (`host.vars.` and `service.vars.` prefixes are no longer keywords)
+* No `**` to cross multiple level boundaries at once (`a.**.d` does not differ from `a.*.d`)
+* Dots are not significant (`foo.*.oof` and `foo*oof` will both match `foo.bar.oof`)
+
+Check the [security chapter](04-Security.md#variable-paths) for more details.
+
+### Permissions
+
+The command permissions have not changed. It is only the module identifier that has changed of course:
+`monitoring/command/*` is now `icingadb/command/*`
+
+The `no-monitoring/contacts` permission (or *fake refusal*) is now a restriction: `icingadb/denylist/routes`.
+Add `users,usergroups` to it to achieve the same effect.
+
+### Perform The Migration
+
+To apply the necessary changes automatically, Icinga DB Web 1.1 provides this command:
+
+`icingacli icingadb migrate role [--role=<name>] [--group=<name>] [--override] [--no-backup]`
+
+By default, this only migrates roles with matching names or matching groups, doesn't change roles that were
+already manually migrated and creates backups. Either `--role` or `--group` must be passed, but not both.
+Both accept wildcards and just `*` matches all roles. Pass `--override` to forcefully update roles that appear
+to be already migrated. Please note that this will reset changes made to Icinga DB Web's rules, which were not
+equally applied to their monitoring module counterparts. Pass `--no-backup` to disable backup creation. Please
+note, if you do so, that this makes resetting changes more difficult.
+
+With respect to permissions, the command will only migrate the command permissions. If a role grants full or
+general access to the monitoring module, this is not automatically migrated. You have to adjust this manually.
+It gives you the chance to review the performed changes, before letting them loose on your users. Please also
+take in mind, that Icinga DB Web handles permissions and restrictions differently. Our blog provides details
+on that: https://icinga.com/blog/2021/04/07/web-access-control-redefined/#icingadb-permission-linkage
diff --git a/doc/11-Concepts.md b/doc/11-Concepts.md
new file mode 100644
index 0000000..35f7350
--- /dev/null
+++ b/doc/11-Concepts.md
@@ -0,0 +1,78 @@
+# Views
+
+## List Items
+Each list that is shown in the web interface consists of list items that follow this basic schema:
+![DefaultAnatomy](res/ListItemAnatomy.jpg "A list item stripped down to the skeleton")
+
+### Visual
+Each element has a so-called visual. It's purpose is to highlight certain elements in long lists and give an intuitive overview about the underlying objects' condition.
+In the case of host or service lists, the state is displayed. This makes it obvious if and where there are problems that need to be taken care of, immediately from the overview.
+
+### Title
+The title briefly describes the state of the list element as an addition to the visual. For example, it contains the information that a host is currently DOWN state. While the visual gives an intuitive impression, the title explains what exactly happened.
+
+### Meta
+The meta area provides additional details, usually displays of time. In the host and service lists, you can see how long the object has been in its current state.
+
+### Caption
+The caption area contains detailed information about the list item. In the case of hosts and services this would be the check output. For comments and downtimes the comment texts of the user are displayed here. To make the default list view compact and consistent, long texts are truncated to a maximum of two lines.
+
+## Different detail degrees
+
+The level of detail of the host and service lists is selectable - the above image shows the default list item, but there are two more iterations:
+
+In the compact mode the entire list item is displayed in one single line.
+Given, that there is enough space, the caption is placed in the same line as the rest and trimmed to fit.
+![CompactAnatomy](res/ListItemAnatomyCompact.jpg "A more compact list item that fits in one line")
+
+The detailed mode displays the entire caption, which makes it possible to react to problems directly from the list view.
+![DetailedAnatomy](res/ListItemAnatomyDetailed.jpg "A more detailed list item that spans four lines")
+
+## Overdue Items
+
+In host and service lists overdue checks are highlighted. This makes it immediately apparent which objects may no longer be up-to-date. The red badge contains information about how long the check has been overdue.
+![OverdueListAnatomy](res/ListAnatomyOverdue.jpg "A list with a highlighted item")
+
+## Downtimes
+Downtime lists have an extra information to be displayed: the progress.
+How far along the downtime is, is immeadiately visible via the progress bar at the top of each list item.
+In the visual it is displayed how much time is left until the downtime ends.
+![DowntimeListItem](res/ListItemDowntimeAnatomy.jpg "A downtime list item with the added progress bar in the top")
+
+
+# Modal
+The modal element introduces short interaction dialogues. For actions in the detail area, a modal dialog will appear. This preserves the left list column and thus the content better.
+![ModalAnatomy](res/ModalAnatomy.jpg "A modal presenting a form")
+
+### Utility
+The utility or close button is one of three ways to close the modal without confirming your changes. It's also possible to just click outside it's borders or press the escape key to return to the previous page.
+
+### Title
+The title specifies the target, e.g. an action like: Acknowledge Problem, Add Comment or Schedule Downtime.
+
+### Hint
+The hint explains more about how to interact with the modal and what is asked of you. It goes into detail about the consequences from an action to your environment
+
+### Form
+The form is the main focus of the modal. It takes the call to action out of the normal page flow and makes it easy to focus on the task at hand.
+
+### Submit
+With the submit button you apply the selections you made in the form and return to where you left off.
+
+# Details
+
+## Check statistics
+Information about check execution is available in a handy graph that makes it possible to get all of the information at a glance.
+![CheckStatistics](res/CheckStatisticsAnatomy.jpg "A panel with the statistics about check execution")
+
+### Descriptions and Values
+Instead of having a table with the different check information there is a header that lays out the information in an intuitive way, giving weight to more important bits of data and their values.
+
+### Graph
+The graphical element makes it easier to see, whether the check is on time or late. It's easy to immediately see the configured check interval and have a better grasp on how up-to-date the current state is.
+
+### Indicator
+The indicator visualises where the current time fits in for orientation.
+
+### Specifics
+The specifics put the exact times of the execution points in relation to each other. There are both the full timestamps and the passed time immediately visible.
diff --git a/doc/res/CheckStatisticsAnatomy.jpg b/doc/res/CheckStatisticsAnatomy.jpg
new file mode 100644
index 0000000..de3dc43
--- /dev/null
+++ b/doc/res/CheckStatisticsAnatomy.jpg
Binary files differ
diff --git a/doc/res/ListAnatomyOverdue.jpg b/doc/res/ListAnatomyOverdue.jpg
new file mode 100644
index 0000000..35120fd
--- /dev/null
+++ b/doc/res/ListAnatomyOverdue.jpg
Binary files differ
diff --git a/doc/res/ListItemAnatomy.jpg b/doc/res/ListItemAnatomy.jpg
new file mode 100644
index 0000000..b24c92b
--- /dev/null
+++ b/doc/res/ListItemAnatomy.jpg
Binary files differ
diff --git a/doc/res/ListItemAnatomyCompact.jpg b/doc/res/ListItemAnatomyCompact.jpg
new file mode 100644
index 0000000..faa474c
--- /dev/null
+++ b/doc/res/ListItemAnatomyCompact.jpg
Binary files differ
diff --git a/doc/res/ListItemAnatomyDetailed.jpg b/doc/res/ListItemAnatomyDetailed.jpg
new file mode 100644
index 0000000..1772f80
--- /dev/null
+++ b/doc/res/ListItemAnatomyDetailed.jpg
Binary files differ
diff --git a/doc/res/ListItemDowntimeAnatomy.jpg b/doc/res/ListItemDowntimeAnatomy.jpg
new file mode 100644
index 0000000..ebe40a1
--- /dev/null
+++ b/doc/res/ListItemDowntimeAnatomy.jpg
Binary files differ
diff --git a/doc/res/ModalAnatomy.jpg b/doc/res/ModalAnatomy.jpg
new file mode 100644
index 0000000..c59e0fe
--- /dev/null
+++ b/doc/res/ModalAnatomy.jpg
Binary files differ
diff --git a/doc/res/continue-with-preview.png b/doc/res/continue-with-preview.png
new file mode 100644
index 0000000..30fada7
--- /dev/null
+++ b/doc/res/continue-with-preview.png
Binary files differ
diff --git a/doc/res/icingadb-architecture.png b/doc/res/icingadb-architecture.png
new file mode 100644
index 0000000..3d55ff7
--- /dev/null
+++ b/doc/res/icingadb-architecture.png
Binary files differ
diff --git a/doc/res/icingadb-dashboard.png b/doc/res/icingadb-dashboard.png
new file mode 100644
index 0000000..521eb39
--- /dev/null
+++ b/doc/res/icingadb-dashboard.png
Binary files differ
diff --git a/doc/res/icingadb-web.png b/doc/res/icingadb-web.png
new file mode 100644
index 0000000..05a3e31
--- /dev/null
+++ b/doc/res/icingadb-web.png
Binary files differ
diff --git a/doc/res/modal-dialog-preview.png b/doc/res/modal-dialog-preview.png
new file mode 100644
index 0000000..1cc1901
--- /dev/null
+++ b/doc/res/modal-dialog-preview.png
Binary files differ
diff --git a/doc/res/searchbar-completion-preview.png b/doc/res/searchbar-completion-preview.png
new file mode 100644
index 0000000..a847d63
--- /dev/null
+++ b/doc/res/searchbar-completion-preview.png
Binary files differ
diff --git a/doc/res/service-detail-preview.png b/doc/res/service-detail-preview.png
new file mode 100644
index 0000000..6d74d70
--- /dev/null
+++ b/doc/res/service-detail-preview.png
Binary files differ
diff --git a/doc/res/view-switcher-preview.png b/doc/res/view-switcher-preview.png
new file mode 100644
index 0000000..cc65d95
--- /dev/null
+++ b/doc/res/view-switcher-preview.png
Binary files differ
diff --git a/library/Icingadb/Authentication/ObjectAuthorization.php b/library/Icingadb/Authentication/ObjectAuthorization.php
new file mode 100644
index 0000000..988e8f0
--- /dev/null
+++ b/library/Icingadb/Authentication/ObjectAuthorization.php
@@ -0,0 +1,261 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Authentication;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use InvalidArgumentException;
+use ipl\Orm\Compat\FilterProcessor;
+use ipl\Orm\Model;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+use ipl\Stdlib\Filter;
+
+class ObjectAuthorization
+{
+ use Auth;
+ use Database;
+
+ /** @var array */
+ protected static $knownGrants = [];
+
+ /**
+ * Caches already applied filters to an object
+ *
+ * @var array
+ */
+ protected static $matchedFilters = [];
+
+ /**
+ * Check whether the permission is granted on the object
+ *
+ * @param string $permission
+ * @param Model $for The object
+ *
+ * @return bool
+ */
+ public static function grantsOn(string $permission, Model $for): bool
+ {
+ $self = new static();
+
+ $tableName = $for->getTableName();
+ $uniqueId = $for->{$for->getKeyName()};
+ if (! isset($uniqueId)) {
+ return false;
+ }
+
+ if (! isset(self::$knownGrants[$tableName][$uniqueId])) {
+ $self->loadGrants(
+ get_class($for),
+ Filter::equal($for->getKeyName(), $uniqueId),
+ $uniqueId,
+ false
+ );
+ }
+
+ return $self->checkGrants($permission, self::$knownGrants[$tableName][$uniqueId]);
+ }
+
+ /**
+ * Check whether the permission is granted on objects matching the type and filter
+ *
+ * The check will be performed on every object matching the filter. Though the result
+ * only allows to determine whether the permission is granted on **any** or *none*
+ * of the objects in question. Any subsequent call to {@see ObjectAuthorization::grantsOn}
+ * will make use of the underlying results the check has determined in order to avoid
+ * unnecessary queries.
+ *
+ * @param string $permission
+ * @param string $type
+ * @param Filter\Rule $filter
+ * @param bool $cache Pass `false` to not perform the check on every object
+ *
+ * @return bool
+ */
+ public static function grantsOnType(string $permission, string $type, Filter\Rule $filter, bool $cache = true): bool
+ {
+ switch ($type) {
+ case 'host':
+ $for = Host::class;
+ break;
+ case 'service':
+ $for = Service::class;
+ break;
+ default:
+ throw new InvalidArgumentException(sprintf('Unknown type "%s"', $type));
+ }
+
+ $self = new static();
+
+ $uniqueId = spl_object_hash($filter);
+ if (! isset(self::$knownGrants[$type][$uniqueId])) {
+ $self->loadGrants($for, $filter, $uniqueId, $cache);
+ }
+
+ return $self->checkGrants($permission, self::$knownGrants[$type][$uniqueId]);
+ }
+
+ /**
+ * Check whether the given filter matches on the given object
+ *
+ * @param string $queryString
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public static function matchesOn(string $queryString, Model $object): bool
+ {
+ $self = new static();
+
+ $uniqueId = $object->{$object->getKeyName()};
+ if (! isset(self::$matchedFilters[$queryString][$uniqueId])) {
+ $restriction = 'icingadb/filter/services';
+ if ($object instanceof Host) {
+ $restriction = 'icingadb/filter/hosts';
+ }
+
+ $filter = $self->parseRestriction($queryString, $restriction);
+
+ $query = $object::on($self->getDb());
+ $query
+ ->filter($filter)
+ ->filter(Filter::equal($object->getKeyName(), $uniqueId))
+ ->columns([new Expression('1')]);
+
+ $result = $query->execute()->hasResult();
+ self::$matchedFilters[$queryString][$uniqueId] = $result;
+
+ return $result;
+ }
+
+ return self::$matchedFilters[$queryString][$uniqueId];
+ }
+
+ /**
+ * Load all the user's roles that grant access to at least one object matching the filter
+ *
+ * @param string $model The class path to the object model
+ * @param Filter\Rule $filter
+ * @param string $cacheKey
+ * @param bool $cache Pass `false` to not populate the cache with the matching objects
+ *
+ * @return void
+ */
+ protected function loadGrants(string $model, Filter\Rule $filter, string $cacheKey, bool $cache = true)
+ {
+ /** @var Model $model */
+ $query = $model::on($this->getDb());
+ $tableName = $query->getModel()->getTableName();
+
+ $inspectedRoles = [];
+ $roleExpressions = [];
+ $rolesWithoutRestrictions = [];
+
+ foreach ($this->getAuth()->getUser()->getRoles() as $role) {
+ $roleFilter = Filter::all();
+ if (($restriction = $role->getRestrictions('icingadb/filter/objects'))) {
+ $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/objects'));
+ }
+
+ if ($tableName === 'host' || $tableName === 'service') {
+ if (($restriction = $role->getRestrictions('icingadb/filter/hosts'))) {
+ $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/hosts'));
+ }
+ }
+
+ if ($tableName === 'service' && ($restriction = $role->getRestrictions('icingadb/filter/services'))) {
+ $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/services'));
+ }
+
+ if ($roleFilter->isEmpty()) {
+ $rolesWithoutRestrictions[] = $role->getName();
+ continue;
+ }
+
+ $roleName = str_replace('.', '_', $role->getName());
+ $inspectedRoles[$roleName] = $role->getName();
+
+ $roleName = $this->getDb()->quoteIdentifier($roleName);
+
+ if ($cache) {
+ FilterProcessor::apply($roleFilter, $query);
+ $where = $query->getSelectBase()->getWhere();
+ $query->getSelectBase()->resetWhere();
+
+ $values = [];
+ $rendered = $this->getDb()->getQueryBuilder()->buildCondition($where, $values);
+ $roleExpressions[$roleName] = new Expression($rendered, null, ...$values);
+ } else {
+ $subQuery = clone $query;
+ $roleExpressions[$roleName] = $subQuery
+ ->columns([new Expression('1')])
+ ->filter($roleFilter)
+ ->filter($filter)
+ ->limit(1)
+ ->assembleSelect()
+ ->resetOrderBy();
+ }
+ }
+
+ $rolesWithRestrictions = [];
+ if (! empty($roleExpressions)) {
+ if ($cache) {
+ $query->columns('id')->withColumns($roleExpressions);
+ $query->filter($filter);
+ } else {
+ $query = [$this->getDb()->fetchOne((new Select())->columns($roleExpressions))];
+ }
+
+ foreach ($query as $row) {
+ $roles = $rolesWithoutRestrictions;
+ foreach ($inspectedRoles as $alias => $roleName) {
+ if ($row->$alias) {
+ $rolesWithRestrictions[$roleName] = true;
+ $roles[] = $roleName;
+ }
+ }
+
+ if ($cache) {
+ self::$knownGrants[$tableName][$row->id] = $roles;
+ }
+ }
+ }
+
+ self::$knownGrants[$tableName][$cacheKey] = array_merge(
+ $rolesWithoutRestrictions,
+ array_keys($rolesWithRestrictions)
+ );
+ }
+
+ /**
+ * Check if any of the given roles grants the permission
+ *
+ * @param string $permission
+ * @param array $roles
+ *
+ * @return bool
+ */
+ protected function checkGrants(string $permission, array $roles): bool
+ {
+ if (empty($roles)) {
+ return false;
+ }
+
+ $granted = false;
+ foreach ($this->getAuth()->getUser()->getRoles() as $role) {
+ if ($role->denies($permission)) {
+ return false;
+ } elseif ($granted || ! $role->grants($permission)) {
+ continue;
+ }
+
+ $granted = in_array($role->getName(), $roles, true);
+ }
+
+ return $granted;
+ }
+}
diff --git a/library/Icingadb/Command/IcingaApiCommand.php b/library/Icingadb/Command/IcingaApiCommand.php
new file mode 100644
index 0000000..f3f0c33
--- /dev/null
+++ b/library/Icingadb/Command/IcingaApiCommand.php
@@ -0,0 +1,128 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command;
+
+class IcingaApiCommand
+{
+ /**
+ * Command data
+ *
+ * @var array
+ */
+ protected $data;
+
+ /**
+ * Name of the endpoint
+ *
+ * @var string
+ */
+ protected $endpoint;
+
+ /**
+ * HTTP method to use
+ *
+ * @var string
+ */
+ protected $method = 'POST';
+
+ /**
+ * Create a new Icinga 2 API command
+ *
+ * @param string $endpoint
+ * @param array $data
+ *
+ * @return static
+ */
+ public static function create(string $endpoint, array $data): self
+ {
+ return (new static())
+ ->setEndpoint($endpoint)
+ ->setData($data);
+ }
+
+ /**
+ * Get the command data
+ *
+ * @return array
+ */
+ public function getData(): array
+ {
+ if ($this->data === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->data;
+ }
+
+ /**
+ * Set the command data
+ *
+ * @param array $data
+ *
+ * @return $this
+ */
+ public function setData(array $data): self
+ {
+ $this->data = $data;
+
+ return $this;
+ }
+
+ /**
+ * Get the name of the endpoint
+ *
+ * @return string
+ */
+ public function getEndpoint(): string
+ {
+ if ($this->endpoint === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->endpoint;
+ }
+
+ /**
+ * Set the name of the endpoint
+ *
+ * @param string $endpoint
+ *
+ * @return $this
+ */
+ public function setEndpoint(string $endpoint): self
+ {
+ $this->endpoint = $endpoint;
+
+ return $this;
+ }
+
+ /**
+ * Get the HTTP method to use
+ *
+ * @return string
+ */
+ public function getMethod(): string
+ {
+ return $this->method;
+ }
+
+ /**
+ * Set the HTTP method to use
+ *
+ * @param string $method All uppercase HTTP method name. Case-sensitive.
+ *
+ * @return $this
+ */
+ public function setMethod(string $method): self
+ {
+ $this->method = $method;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Command/IcingaCommand.php b/library/Icingadb/Command/IcingaCommand.php
new file mode 100644
index 0000000..7b5c5cf
--- /dev/null
+++ b/library/Icingadb/Command/IcingaCommand.php
@@ -0,0 +1,22 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command;
+
+/**
+ * Base class for commands sent to an Icinga instance
+ */
+abstract class IcingaCommand
+{
+ /**
+ * Get the name of the command
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ $nsParts = explode('\\', get_called_class());
+ return substr_replace(end($nsParts), '', -7); // Remove 'Command' Suffix
+ }
+}
diff --git a/library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php b/library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php
new file mode 100644
index 0000000..d275d9b
--- /dev/null
+++ b/library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php
@@ -0,0 +1,109 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Instance;
+
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+
+/**
+ * Enable or disable a feature of an Icinga instance
+ */
+class ToggleInstanceFeatureCommand extends IcingaCommand
+{
+ /**
+ * Feature for enabling or disabling active host checks on an Icinga instance
+ */
+ const FEATURE_ACTIVE_HOST_CHECKS = 'active_host_checks_enabled';
+
+ /**
+ * Feature for enabling or disabling active service checks on an Icinga instance
+ */
+ const FEATURE_ACTIVE_SERVICE_CHECKS = 'active_service_checks_enabled';
+
+ /**
+ * Feature for enabling or disabling host and service event handlers on an Icinga instance
+ */
+ const FEATURE_EVENT_HANDLERS = 'event_handlers_enabled';
+
+ /**
+ * Feature for enabling or disabling host and service flap detection on an Icinga instance
+ */
+ const FEATURE_FLAP_DETECTION = 'flap_detection_enabled';
+
+ /**
+ * Feature for enabling or disabling host and service notifications on an Icinga instance
+ */
+ const FEATURE_NOTIFICATIONS = 'notifications_enabled';
+
+ /**
+ * Feature for enabling or disabling the processing of host and service performance data on an Icinga instance
+ */
+ const FEATURE_PERFORMANCE_DATA = 'process_performance_data';
+
+ /**
+ * Feature that is to be enabled or disabled
+ *
+ * @var string
+ */
+ protected $feature;
+
+ /**
+ * Whether the feature should be enabled or disabled
+ *
+ * @var bool
+ */
+ protected $enabled;
+
+ /**
+ * Set the feature that is to be enabled or disabled
+ *
+ * @param string $feature
+ *
+ * @return $this
+ */
+ public function setFeature(string $feature): self
+ {
+ $this->feature = $feature;
+
+ return $this;
+ }
+
+ /**
+ * Get the feature that is to be enabled or disabled
+ *
+ * @return string
+ */
+ public function getFeature(): string
+ {
+ if ($this->feature === null) {
+ throw new \LogicException('You have to set the feature first before getting it.');
+ }
+
+ return $this->feature;
+ }
+
+ /**
+ * Set whether the feature should be enabled or disabled
+ *
+ * @param bool $enabled
+ *
+ * @return $this
+ */
+ public function setEnabled(bool $enabled = true): self
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the feature should be enabled or disabled
+ *
+ * @return ?bool
+ */
+ public function getEnabled()
+ {
+ return $this->enabled;
+ }
+}
diff --git a/library/Icingadb/Command/Object/AcknowledgeProblemCommand.php b/library/Icingadb/Command/Object/AcknowledgeProblemCommand.php
new file mode 100644
index 0000000..baae24c
--- /dev/null
+++ b/library/Icingadb/Command/Object/AcknowledgeProblemCommand.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Acknowledge a host or service problem
+ */
+class AcknowledgeProblemCommand extends WithCommentCommand
+{
+ /**
+ * Whether the acknowledgement is sticky
+ *
+ * Sticky acknowledgements remain until the host or service recovers. Non-sticky acknowledgements will be
+ * automatically removed when the host or service state changes.
+ *
+ * @var bool
+ */
+ protected $sticky = false;
+
+ /**
+ * Whether to send a notification about the acknowledgement
+
+ * @var bool
+ */
+ protected $notify = false;
+
+ /**
+ * Whether the comment associated with the acknowledgement is persistent
+ *
+ * Persistent comments are not lost the next time the monitoring host restarts.
+ *
+ * @var bool
+ */
+ protected $persistent = false;
+
+ /**
+ * Optional time when the acknowledgement should expire
+ *
+ * @var int
+ */
+ protected $expireTime;
+
+ /**
+ * Set whether the acknowledgement is sticky
+ *
+ * @param bool $sticky
+ *
+ * @return $this
+ */
+ public function setSticky(bool $sticky = true): self
+ {
+ $this->sticky = $sticky;
+
+ return $this;
+ }
+
+ /**
+ * Is the acknowledgement sticky?
+ *
+ * @return bool
+ */
+ public function getSticky(): bool
+ {
+ return $this->sticky;
+ }
+
+ /**
+ * Set whether to send a notification about the acknowledgement
+ *
+ * @param bool $notify
+ *
+ * @return $this
+ */
+ public function setNotify(bool $notify = true): self
+ {
+ $this->notify = $notify;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to send a notification about the acknowledgement
+ *
+ * @return bool
+ */
+ public function getNotify(): bool
+ {
+ return $this->notify;
+ }
+
+ /**
+ * Set whether the comment associated with the acknowledgement is persistent
+ *
+ * @param bool $persistent
+ *
+ * @return $this
+ */
+ public function setPersistent(bool $persistent = true): self
+ {
+ $this->persistent = $persistent;
+
+ return $this;
+ }
+
+ /**
+ * Is the comment associated with the acknowledgement is persistent?
+ *
+ * @return bool
+ */
+ public function getPersistent(): bool
+ {
+ return $this->persistent;
+ }
+
+ /**
+ * Set the time when the acknowledgement should expire
+ *
+ * @param int $expireTime
+ *
+ * @return $this
+ */
+ public function setExpireTime(int $expireTime): self
+ {
+ $this->expireTime = $expireTime;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the acknowledgement should expire
+ *
+ * @return ?int
+ */
+ public function getExpireTime()
+ {
+ return $this->expireTime;
+ }
+}
diff --git a/library/Icingadb/Command/Object/AddCommentCommand.php b/library/Icingadb/Command/Object/AddCommentCommand.php
new file mode 100644
index 0000000..c853b25
--- /dev/null
+++ b/library/Icingadb/Command/Object/AddCommentCommand.php
@@ -0,0 +1,42 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Add a comment to a host or service
+ */
+class AddCommentCommand extends WithCommentCommand
+{
+ /**
+ * Optional time when the acknowledgement should expire
+ *
+ * @var int
+ */
+ protected $expireTime;
+
+ /**
+ * Set the time when the acknowledgement should expire
+ *
+ * @param int $expireTime
+ *
+ * @return $this
+ */
+ public function setExpireTime(int $expireTime): self
+ {
+ $this->expireTime = $expireTime;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the acknowledgement should expire
+ *
+ * @return ?int
+ */
+ public function getExpireTime()
+ {
+ return $this->expireTime;
+ }
+}
diff --git a/library/Icingadb/Command/Object/CommandAuthor.php b/library/Icingadb/Command/Object/CommandAuthor.php
new file mode 100644
index 0000000..f323b63
--- /dev/null
+++ b/library/Icingadb/Command/Object/CommandAuthor.php
@@ -0,0 +1,45 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+trait CommandAuthor
+{
+ /**
+ * Author of the command
+ *
+ * @var string
+ */
+ protected $author;
+
+ /**
+ * Set the author
+ *
+ * @param string $author
+ *
+ * @return $this
+ */
+ public function setAuthor(string $author): self
+ {
+ $this->author = $author;
+
+ return $this;
+ }
+
+ /**
+ * Get the author
+ *
+ * @return string
+ */
+ public function getAuthor(): string
+ {
+ if ($this->author === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->author;
+ }
+}
diff --git a/library/Icingadb/Command/Object/DeleteCommentCommand.php b/library/Icingadb/Command/Object/DeleteCommentCommand.php
new file mode 100644
index 0000000..c06a73c
--- /dev/null
+++ b/library/Icingadb/Command/Object/DeleteCommentCommand.php
@@ -0,0 +1,13 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Delete a host or service comment
+ */
+class DeleteCommentCommand extends ObjectsCommand
+{
+ use CommandAuthor;
+}
diff --git a/library/Icingadb/Command/Object/DeleteDowntimeCommand.php b/library/Icingadb/Command/Object/DeleteDowntimeCommand.php
new file mode 100644
index 0000000..7b4c282
--- /dev/null
+++ b/library/Icingadb/Command/Object/DeleteDowntimeCommand.php
@@ -0,0 +1,13 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Delete a host or service downtime
+ */
+class DeleteDowntimeCommand extends ObjectsCommand
+{
+ use CommandAuthor;
+}
diff --git a/library/Icingadb/Command/Object/GetObjectCommand.php b/library/Icingadb/Command/Object/GetObjectCommand.php
new file mode 100644
index 0000000..8448f8d
--- /dev/null
+++ b/library/Icingadb/Command/Object/GetObjectCommand.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+class GetObjectCommand extends ObjectsCommand
+{
+ /** @var array */
+ protected $attributes;
+
+ /**
+ * Get the attributes to query
+ *
+ * @return ?array
+ */
+ public function getAttributes()
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Set the attributes to query
+ *
+ * @param array $attributes
+ *
+ * @return $this
+ */
+ public function setAttributes(array $attributes): self
+ {
+ $this->attributes = $attributes;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ObjectsCommand.php b/library/Icingadb/Command/Object/ObjectsCommand.php
new file mode 100644
index 0000000..3de6c83
--- /dev/null
+++ b/library/Icingadb/Command/Object/ObjectsCommand.php
@@ -0,0 +1,67 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+use ArrayIterator;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use ipl\Orm\Model;
+use Traversable;
+
+/**
+ * Base class for commands that involve monitored objects, i.e. hosts or services
+ */
+abstract class ObjectsCommand extends IcingaCommand
+{
+ /**
+ * Involved objects
+ *
+ * @var Traversable<Model>
+ */
+ protected $objects;
+
+ /**
+ * Set the involved objects
+ *
+ * @param Traversable<Model> $objects
+ *
+ * @return $this
+ */
+ public function setObjects(Traversable $objects): self
+ {
+ $this->objects = $objects;
+
+ return $this;
+ }
+
+ /**
+ * Set the involved object
+ *
+ * @param Model $object
+ *
+ * @return $this
+ *
+ * @deprecated Use setObjects() instead
+ */
+ public function setObject(Model $object): self
+ {
+ return $this->setObjects(new ArrayIterator([$object]));
+ }
+
+ /**
+ * Get the involved objects
+ *
+ * @return Traversable
+ */
+ public function getObjects(): Traversable
+ {
+ if ($this->objects === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->objects;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ProcessCheckResultCommand.php b/library/Icingadb/Command/Object/ProcessCheckResultCommand.php
new file mode 100644
index 0000000..24ae2f3
--- /dev/null
+++ b/library/Icingadb/Command/Object/ProcessCheckResultCommand.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Submit a passive check result for a host or service
+ */
+class ProcessCheckResultCommand extends ObjectsCommand
+{
+ /**
+ * Host up
+ */
+ const HOST_UP = 0;
+
+ /**
+ * Host down
+ */
+ const HOST_DOWN = 1;
+
+ /**
+ * Service ok
+ */
+ const SERVICE_OK = 0;
+
+ /**
+ * Service warning
+ */
+ const SERVICE_WARNING = 1;
+
+ /**
+ * Service critical
+ */
+ const SERVICE_CRITICAL = 2;
+
+ /**
+ * Service unknown
+ */
+ const SERVICE_UNKNOWN = 3;
+
+ /**
+ * Status code of the host or service check result
+ *
+ * @var int
+ */
+ protected $status;
+
+ /**
+ * Text output of the host or service check result
+ *
+ * @var string
+ */
+ protected $output;
+
+ /**
+ * Optional performance data of the host or service check result
+ *
+ * @var string
+ */
+ protected $performanceData;
+
+ /**
+ * Set the status code of the host or service check result
+ *
+ * @param int $status
+ *
+ * @return $this
+ */
+ public function setStatus(int $status): self
+ {
+ $this->status = $status;
+
+ return $this;
+ }
+
+ /**
+ * Get the status code of the host or service check result
+ *
+ * @return int
+ */
+ public function getStatus(): int
+ {
+ if ($this->status === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->status;
+ }
+
+ /**
+ * Set the text output of the host or service check result
+ *
+ * @param string $output
+ *
+ * @return $this
+ */
+ public function setOutput(string $output): self
+ {
+ $this->output = $output;
+
+ return $this;
+ }
+
+ /**
+ * Get the text output of the host or service check result
+ *
+ * @return ?string
+ */
+ public function getOutput()
+ {
+ return $this->output;
+ }
+
+ /**
+ * Set the performance data of the host or service check result
+ *
+ * @param string|null $performanceData
+ *
+ * @return $this
+ */
+ public function setPerformanceData(string $performanceData = null): self
+ {
+ $this->performanceData = $performanceData;
+
+ return $this;
+ }
+
+ /**
+ * Get the performance data of the host or service check result
+ *
+ * @return ?string
+ */
+ public function getPerformanceData()
+ {
+ return $this->performanceData;
+ }
+}
diff --git a/library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php b/library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php
new file mode 100644
index 0000000..88964fb
--- /dev/null
+++ b/library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php
@@ -0,0 +1,42 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Schedule and propagate host downtime
+ */
+class PropagateHostDowntimeCommand extends ScheduleHostDowntimeCommand
+{
+ /**
+ * Whether the downtime for child hosts are all set to be triggered by this' host downtime
+ *
+ * @var bool
+ */
+ protected $triggered = false;
+
+ /**
+ * Set whether the downtime for child hosts are all set to be triggered by this' host downtime
+ *
+ * @param bool $triggered
+ *
+ * @return $this
+ */
+ public function setTriggered(bool $triggered = true): self
+ {
+ $this->triggered = $triggered;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the downtime for child hosts are all set to be triggered by this' host downtime
+ *
+ * @return bool
+ */
+ public function getTriggered(): bool
+ {
+ return $this->triggered;
+ }
+}
diff --git a/library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php b/library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php
new file mode 100644
index 0000000..4c4a9b3
--- /dev/null
+++ b/library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php
@@ -0,0 +1,13 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Remove a problem acknowledgement from a host or service
+ */
+class RemoveAcknowledgementCommand extends ObjectsCommand
+{
+ use CommandAuthor;
+}
diff --git a/library/Icingadb/Command/Object/ScheduleCheckCommand.php b/library/Icingadb/Command/Object/ScheduleCheckCommand.php
new file mode 100644
index 0000000..88a7fd3
--- /dev/null
+++ b/library/Icingadb/Command/Object/ScheduleCheckCommand.php
@@ -0,0 +1,86 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Schedule a check
+ */
+class ScheduleCheckCommand extends ObjectsCommand
+{
+ /**
+ * Time when the next check of a host or service is to be scheduled
+ *
+ * If active checks are disabled on a host- or service-specific or program-wide basis or the host or service is
+ * already scheduled to be checked at an earlier time, etc. The check may not actually be scheduled at the time
+ * specified. This behaviour can be overridden by setting `ScheduledCheck::$forced' to true.
+ *
+ * @var int Unix timestamp
+ */
+ protected $checkTime;
+
+ /**
+ * Whether the check is forced
+ *
+ * Forced checks are performed regardless of what time it is (e.g. time period restrictions are ignored) and whether
+ * or not active checks are enabled on a host- or service-specific or program-wide basis.
+ *
+ * @var bool
+ */
+ protected $forced = false;
+
+ /**
+ * Set the time when the next check of a host or service is to be scheduled
+ *
+ * @param int $checkTime Unix timestamp
+ *
+ * @return $this
+ */
+ public function setCheckTime(int $checkTime): self
+ {
+ $this->checkTime = $checkTime;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the next check of a host or service is to be scheduled
+ *
+ * @return int Unix timestamp
+ */
+ public function getCheckTime(): int
+ {
+ if ($this->checkTime === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->checkTime;
+ }
+
+ /**
+ * Set whether the check is forced
+ *
+ * @param bool $forced
+ *
+ * @return $this
+ */
+ public function setForced(bool $forced = true): self
+ {
+ $this->forced = $forced;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the check is forced
+ *
+ * @return bool
+ */
+ public function getForced(): bool
+ {
+ return $this->forced;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php b/library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php
new file mode 100644
index 0000000..0e4d84f
--- /dev/null
+++ b/library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php
@@ -0,0 +1,42 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Schedule a host downtime
+ */
+class ScheduleHostDowntimeCommand extends ScheduleServiceDowntimeCommand
+{
+ /**
+ * Whether to schedule a downtime for all services associated with a particular host
+ *
+ * @var bool
+ */
+ protected $forAllServices = false;
+
+ /**
+ * Set whether to schedule a downtime for all services associated with a particular host
+ *
+ * @param bool $forAllServices
+ *
+ * @return $this
+ */
+ public function setForAllServices(bool $forAllServices = true): self
+ {
+ $this->forAllServices = $forAllServices;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to schedule a downtime for all services associated with a particular host
+ *
+ * @return bool
+ */
+ public function getForAllServices(): bool
+ {
+ return $this->forAllServices;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php b/library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php
new file mode 100644
index 0000000..3bad28e
--- /dev/null
+++ b/library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php
@@ -0,0 +1,196 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Schedule a service downtime
+ */
+class ScheduleServiceDowntimeCommand extends AddCommentCommand
+{
+ /**
+ * Downtime starts at the exact time specified
+ *
+ * If `Downtime::$fixed' is set to false, the time between `Downtime::$start' and `Downtime::$end' at which a
+ * host or service transitions to a problem state determines the time at which the downtime actually starts.
+ * The downtime will then last for `Downtime::$duration' seconds.
+ *
+ * @var int Unix timestamp
+ */
+ protected $start;
+
+ /**
+ * Downtime ends at the exact time specified
+ *
+ * If `Downtime::$fixed' is set to false, the time between `Downtime::$start' and `Downtime::$end' at which a
+ * host or service transitions to a problem state determines the time at which the downtime actually starts.
+ * The downtime will then last for `Downtime::$duration' seconds.
+ *
+ * @var int Unix timestamp
+ */
+ protected $end;
+
+ /**
+ * Whether it's a fixed or flexible downtime
+ *
+ * @var bool
+ */
+ protected $fixed = true;
+
+ /**
+ * ID of the downtime which triggers this downtime
+ *
+ * The start of this downtime is triggered by the start of the other scheduled host or service downtime.
+ *
+ * @var int|null
+ */
+ protected $triggerId;
+
+ /**
+ * The duration in seconds the downtime must last if it's a flexible downtime
+ *
+ * If `Downtime::$fixed' is set to false, the downtime will last for the duration in seconds specified, even
+ * if the host or service recovers before the downtime expires.
+ *
+ * @var int|null
+ */
+ protected $duration;
+
+ /**
+ * Set the time when the downtime should start
+ *
+ * @param int $start Unix timestamp
+ *
+ * @return $this
+ */
+ public function setStart(int $start): self
+ {
+ $this->start = $start;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the downtime should start
+ *
+ * @return int Unix timestamp
+ */
+ public function getStart(): int
+ {
+ if ($this->start === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->start;
+ }
+
+ /**
+ * Set the time when the downtime should end
+ *
+ * @param int $end Unix timestamp
+ *
+ * @return $this
+ */
+ public function setEnd(int $end): self
+ {
+ $this->end = $end;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the downtime should end
+ *
+ * @return int Unix timestamp
+ */
+ public function getEnd(): int
+ {
+ if ($this->start === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->end;
+ }
+
+ /**
+ * Set whether it's a fixed or flexible downtime
+ *
+ * @param boolean $fixed
+ *
+ * @return $this
+ */
+ public function setFixed(bool $fixed = true): self
+ {
+ $this->fixed = $fixed;
+
+ return $this;
+ }
+
+ /**
+ * Is the downtime fixed?
+ *
+ * @return boolean
+ */
+ public function getFixed(): bool
+ {
+ return $this->fixed;
+ }
+
+ /**
+ * Set the ID of the downtime which triggers this downtime
+ *
+ * @param int $triggerId
+ *
+ * @return $this
+ */
+ public function setTriggerId(int $triggerId): self
+ {
+ $this->triggerId = $triggerId;
+
+ return $this;
+ }
+
+ /**
+ * Get the ID of the downtime which triggers this downtime
+ *
+ * @return int|null
+ */
+ public function getTriggerId()
+ {
+ return $this->triggerId;
+ }
+
+ /**
+ * Set the duration in seconds the downtime must last if it's a flexible downtime
+ *
+ * @param int $duration
+ *
+ * @return $this
+ */
+ public function setDuration(int $duration): self
+ {
+ $this->duration = $duration;
+
+ return $this;
+ }
+
+ /**
+ * Get the duration in seconds the downtime must last if it's a flexible downtime
+ *
+ * @return int|null
+ */
+ public function getDuration()
+ {
+ return $this->duration;
+ }
+
+ public function getName(): string
+ {
+ return 'ScheduleDowntime';
+ }
+}
diff --git a/library/Icingadb/Command/Object/SendCustomNotificationCommand.php b/library/Icingadb/Command/Object/SendCustomNotificationCommand.php
new file mode 100644
index 0000000..de90620
--- /dev/null
+++ b/library/Icingadb/Command/Object/SendCustomNotificationCommand.php
@@ -0,0 +1,44 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Send custom notifications for a host or service
+ */
+class SendCustomNotificationCommand extends WithCommentCommand
+{
+ /**
+ * Whether the notification is forced
+ *
+ * Forced notifications are sent out regardless of time restrictions and whether or not notifications are enabled.
+ *
+ * @var bool
+ */
+ protected $forced;
+
+ /**
+ * Get whether to force the notification
+ *
+ * @return ?bool
+ */
+ public function getForced()
+ {
+ return $this->forced;
+ }
+
+ /**
+ * Set whether to force the notification
+ *
+ * @param bool $forced
+ *
+ * @return $this
+ */
+ public function setForced(bool $forced = true): self
+ {
+ $this->forced = $forced;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php b/library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php
new file mode 100644
index 0000000..ec66289
--- /dev/null
+++ b/library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php
@@ -0,0 +1,108 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Enable or disable a feature of an Icinga object, i.e. host or service
+ */
+class ToggleObjectFeatureCommand extends ObjectsCommand
+{
+ /**
+ * Feature for enabling or disabling active checks of a host or service
+ */
+ const FEATURE_ACTIVE_CHECKS = 'active_checks_enabled';
+
+ /**
+ * Feature for enabling or disabling passive checks of a host or service
+ */
+ const FEATURE_PASSIVE_CHECKS = 'passive_checks_enabled';
+
+ /**
+ * Feature for enabling or disabling notifications for a host or service
+ *
+ * Notifications will be sent out only if notifications are enabled on a program-wide basis as well.
+ */
+ const FEATURE_NOTIFICATIONS = 'notifications_enabled';
+
+ /**
+ * Feature for enabling or disabling event handler for a host or service
+ */
+ const FEATURE_EVENT_HANDLER = 'event_handler_enabled';
+
+ /**
+ * Feature for enabling or disabling flap detection for a host or service.
+ *
+ * In order to enable flap detection flap detection must be enabled on a program-wide basis as well.
+ */
+ const FEATURE_FLAP_DETECTION = 'flapping_enabled';
+
+ /**
+ * Feature that is to be enabled or disabled
+ *
+ * @var string
+ */
+ protected $feature;
+
+ /**
+ * Whether the feature should be enabled or disabled
+ *
+ * @var bool
+ */
+ protected $enabled;
+
+ /**
+ * Set the feature that is to be enabled or disabled
+ *
+ * @param string $feature
+ *
+ * @return $this
+ */
+ public function setFeature(string $feature): self
+ {
+ $this->feature = $feature;
+
+ return $this;
+ }
+
+ /**
+ * Get the feature that is to be enabled or disabled
+ *
+ * @return string
+ */
+ public function getFeature(): string
+ {
+ if ($this->feature === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->feature;
+ }
+
+ /**
+ * Set whether the feature should be enabled or disabled
+ *
+ * @param bool $enabled
+ *
+ * @return $this
+ */
+ public function setEnabled(bool $enabled = true): self
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the feature should be enabled or disabled
+ *
+ * @return ?bool
+ */
+ public function getEnabled()
+ {
+ return $this->enabled;
+ }
+}
diff --git a/library/Icingadb/Command/Object/WithCommentCommand.php b/library/Icingadb/Command/Object/WithCommentCommand.php
new file mode 100644
index 0000000..fc92eb1
--- /dev/null
+++ b/library/Icingadb/Command/Object/WithCommentCommand.php
@@ -0,0 +1,50 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Base class for commands adding comments
+ */
+abstract class WithCommentCommand extends ObjectsCommand
+{
+ use CommandAuthor;
+
+ /**
+ * Comment
+ *
+ * @var string
+ */
+ protected $comment;
+
+ /**
+ * Set the comment
+ *
+ * @param string $comment
+ *
+ * @return $this
+ */
+ public function setComment(string $comment): self
+ {
+ $this->comment = $comment;
+
+ return $this;
+ }
+
+ /**
+ * Get the comment
+ *
+ * @return string
+ */
+ public function getComment(): string
+ {
+ if ($this->comment === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->comment;
+ }
+}
diff --git a/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php b/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php
new file mode 100644
index 0000000..37075c9
--- /dev/null
+++ b/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php
@@ -0,0 +1,353 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Renderer;
+
+use Icinga\Module\Icingadb\Command\IcingaApiCommand;
+use Icinga\Module\Icingadb\Command\Object\GetObjectCommand;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Command\Instance\ToggleInstanceFeatureCommand;
+use Icinga\Module\Icingadb\Command\Object\AcknowledgeProblemCommand;
+use Icinga\Module\Icingadb\Command\Object\AddCommentCommand;
+use Icinga\Module\Icingadb\Command\Object\DeleteCommentCommand;
+use Icinga\Module\Icingadb\Command\Object\DeleteDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\ProcessCheckResultCommand;
+use Icinga\Module\Icingadb\Command\Object\PropagateHostDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\RemoveAcknowledgementCommand;
+use Icinga\Module\Icingadb\Command\Object\ScheduleHostDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\ScheduleCheckCommand;
+use Icinga\Module\Icingadb\Command\Object\ScheduleServiceDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\SendCustomNotificationCommand;
+use Icinga\Module\Icingadb\Command\Object\ToggleObjectFeatureCommand;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use InvalidArgumentException;
+use ipl\Orm\Model;
+use LogicException;
+use Traversable;
+
+/**
+ * Icinga command renderer for the Icinga command file
+ */
+class IcingaApiCommandRenderer implements IcingaCommandRendererInterface
+{
+ /**
+ * Name of the Icinga application object
+ *
+ * @var string
+ */
+ protected $app = 'app';
+
+ /**
+ * Get the name of the Icinga application object
+ *
+ * @return string
+ */
+ public function getApp(): string
+ {
+ return $this->app;
+ }
+
+ /**
+ * Set the name of the Icinga application object
+ *
+ * @param string $app
+ *
+ * @return $this
+ */
+ public function setApp(string $app): self
+ {
+ $this->app = $app;
+
+ return $this;
+ }
+
+ /**
+ * Apply filter to query data
+ *
+ * @param array $data
+ * @param Traversable<Model> $objects
+ *
+ * @return ?Model Any of the objects (useful for further type-dependent handling)
+ */
+ protected function applyFilter(array &$data, Traversable $objects): ?Model
+ {
+ $object = null;
+
+ foreach ($objects as $object) {
+ if ($object instanceof Service) {
+ $data['services'][] = sprintf('%s!%s', $object->host->name, $object->name);
+ } else {
+ $data['hosts'][] = $object->name;
+ }
+ }
+
+ return $object;
+ }
+
+ /**
+ * Get the sub-route of the endpoint for an object
+ *
+ * @param Model $object
+ *
+ * @return string
+ */
+ protected function getObjectPluralType(Model $object): string
+ {
+ if ($object instanceof Host) {
+ return 'hosts';
+ }
+
+ if ($object instanceof Service) {
+ return 'services';
+ }
+
+ throw new LogicException(sprintf('Invalid object type %s provided', get_class($object)));
+ }
+
+ /**
+ * Render a command
+ *
+ * @param IcingaCommand $command
+ *
+ * @return IcingaApiCommand
+ */
+ public function render(IcingaCommand $command): IcingaApiCommand
+ {
+ $renderMethod = 'render' . $command->getName();
+ if (! method_exists($this, $renderMethod)) {
+ throw new InvalidArgumentException(
+ sprintf('Can\'t render command. Method %s not found', $renderMethod)
+ );
+ }
+
+ return $this->$renderMethod($command);
+ }
+
+ public function renderGetObject(GetObjectCommand $command): IcingaApiCommand
+ {
+ $data = [
+ 'all_joins' => 1,
+ 'attrs' => $command->getAttributes() ?: []
+ ];
+
+ $endpoint = 'objects/' . $this->getObjectPluralType($this->applyFilter($data, $command->getObjects()));
+
+ return IcingaApiCommand::create($endpoint, $data)->setMethod('GET');
+ }
+
+ public function renderAddComment(AddCommentCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/add-comment';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comment' => $command->getComment()
+ ];
+
+ if ($command->getExpireTime() !== null) {
+ $data['expiry'] = $command->getExpireTime();
+ }
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderSendCustomNotification(SendCustomNotificationCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/send-custom-notification';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comment' => $command->getComment(),
+ 'force' => $command->getForced()
+ ];
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderProcessCheckResult(ProcessCheckResultCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/process-check-result';
+ $data = [
+ 'exit_status' => $command->getStatus(),
+ 'plugin_output' => $command->getOutput(),
+ 'performance_data' => $command->getPerformanceData()
+ ];
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderScheduleCheck(ScheduleCheckCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/reschedule-check';
+ $data = [
+ 'next_check' => $command->getCheckTime(),
+ 'force' => $command->getForced()
+ ];
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderScheduleDowntime(ScheduleServiceDowntimeCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/schedule-downtime';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comment' => $command->getComment(),
+ 'start_time' => $command->getStart(),
+ 'end_time' => $command->getEnd(),
+ 'duration' => $command->getDuration(),
+ 'fixed' => $command->getFixed(),
+ 'trigger_name' => $command->getTriggerId()
+ ];
+
+ if ($command instanceof PropagateHostDowntimeCommand) {
+ $data['child_options'] = $command->getTriggered() ? 1 : 2;
+ }
+
+ if ($command instanceof ScheduleHostDowntimeCommand && $command->getForAllServices()) {
+ $data['all_services'] = true;
+ }
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderAcknowledgeProblem(AcknowledgeProblemCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/acknowledge-problem';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comment' => $command->getComment(),
+ 'sticky' => $command->getSticky(),
+ 'notify' => $command->getNotify(),
+ 'persistent' => $command->getPersistent()
+ ];
+
+ if ($command->getExpireTime() !== null) {
+ $data['expiry'] = $command->getExpireTime();
+ }
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderToggleObjectFeature(ToggleObjectFeatureCommand $command): IcingaApiCommand
+ {
+ switch ($command->getFeature()) {
+ case ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS:
+ $attr = 'enable_active_checks';
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS:
+ $attr = 'enable_passive_checks';
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS:
+ $attr = 'enable_notifications';
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER:
+ $attr = 'enable_event_handler';
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION:
+ $attr = 'enable_flapping';
+ break;
+ default:
+ throw new InvalidArgumentException($command->getFeature());
+ }
+
+ $endpoint = 'objects/';
+ $objects = $command->getObjects();
+
+ $data = [
+ 'attrs' => [
+ $attr => $command->getEnabled()
+ ]
+ ];
+
+
+ $endpoint .= $this->getObjectPluralType($this->applyFilter($data, $objects));
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderDeleteComment(DeleteCommentCommand $command): IcingaApiCommand
+ {
+ $comments = [];
+
+ foreach ($command->getObjects() as $object) {
+ $comments[] = $object->name;
+ }
+
+ $endpoint = 'actions/remove-comment';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comments' => $comments
+ ];
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderDeleteDowntime(DeleteDowntimeCommand $command): IcingaApiCommand
+ {
+ $downtimes = [];
+
+ foreach ($command->getObjects() as $object) {
+ $downtimes[] = $object->name;
+ }
+
+ $endpoint = 'actions/remove-downtime';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'downtimes' => $downtimes
+ ];
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderRemoveAcknowledgement(RemoveAcknowledgementCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/remove-acknowledgement';
+ $data = ['author' => $command->getAuthor()];
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderToggleInstanceFeature(ToggleInstanceFeatureCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'objects/icingaapplications/' . $this->getApp();
+
+ switch ($command->getFeature()) {
+ case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_HOST_CHECKS:
+ $attr = 'enable_host_checks';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_SERVICE_CHECKS:
+ $attr = 'enable_service_checks';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_EVENT_HANDLERS:
+ $attr = 'enable_event_handlers';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_FLAP_DETECTION:
+ $attr = 'enable_flapping';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_NOTIFICATIONS:
+ $attr = 'enable_notifications';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_PERFORMANCE_DATA:
+ $attr = 'enable_perfdata';
+ break;
+ default:
+ throw new InvalidArgumentException($command->getFeature());
+ }
+
+ $data = [
+ 'attrs' => [
+ $attr => $command->getEnabled()
+ ]
+ ];
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+}
diff --git a/library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php b/library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php
new file mode 100644
index 0000000..50dd90c
--- /dev/null
+++ b/library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php
@@ -0,0 +1,12 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Renderer;
+
+/**
+ * Interface for Icinga command renderer
+ */
+interface IcingaCommandRendererInterface
+{
+}
diff --git a/library/Icingadb/Command/Transport/ApiCommandException.php b/library/Icingadb/Command/Transport/ApiCommandException.php
new file mode 100644
index 0000000..5449a7d
--- /dev/null
+++ b/library/Icingadb/Command/Transport/ApiCommandException.php
@@ -0,0 +1,14 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Exception thrown if a command was not successful
+ */
+class ApiCommandException extends IcingaException
+{
+}
diff --git a/library/Icingadb/Command/Transport/ApiCommandTransport.php b/library/Icingadb/Command/Transport/ApiCommandTransport.php
new file mode 100644
index 0000000..370d705
--- /dev/null
+++ b/library/Icingadb/Command/Transport/ApiCommandTransport.php
@@ -0,0 +1,353 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+use Icinga\Application\Hook\AuditHook;
+use Icinga\Application\Logger;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Command\IcingaApiCommand;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use Icinga\Module\Icingadb\Command\Renderer\IcingaApiCommandRenderer;
+use Icinga\Util\Json;
+
+/**
+ * Command transport over Icinga 2's REST API
+ */
+class ApiCommandTransport implements CommandTransportInterface
+{
+ /**
+ * Transport identifier
+ */
+ const TRANSPORT = 'api';
+
+ /**
+ * API host
+ *
+ * @var string
+ */
+ protected $host;
+
+ /**
+ * API password
+ *
+ * @var string
+ */
+ protected $password;
+
+ /**
+ * API port
+ *
+ * @var int
+ */
+ protected $port = 5665;
+
+ /**
+ * Command renderer
+ *
+ * @var IcingaApiCommandRenderer
+ */
+ protected $renderer;
+
+ /**
+ * API username
+ *
+ * @var string
+ */
+ protected $username;
+
+ /**
+ * Create a new API command transport
+ */
+ public function __construct()
+ {
+ $this->renderer = new IcingaApiCommandRenderer();
+ }
+
+ /**
+ * Set the name of the Icinga application object
+ *
+ * @param string $app
+ *
+ * @return $this
+ */
+ public function setApp(string $app): self
+ {
+ $this->renderer->setApp($app);
+
+ return $this;
+ }
+
+ /**
+ * Get the API host
+ *
+ * @return string
+ */
+ public function getHost(): string
+ {
+ if ($this->host === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->host;
+ }
+
+ /**
+ * Set the API host
+ *
+ * @param string $host
+ *
+ * @return $this
+ */
+ public function setHost(string $host): self
+ {
+ $this->host = $host;
+
+ return $this;
+ }
+
+ /**
+ * Get the API password
+ *
+ * @return string
+ */
+ public function getPassword(): string
+ {
+ if ($this->password === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->password;
+ }
+
+ /**
+ * Set the API password
+ *
+ * @param string $password
+ *
+ * @return $this
+ */
+ public function setPassword(string $password): self
+ {
+ $this->password = $password;
+
+ return $this;
+ }
+
+ /**
+ * Get the API port
+ *
+ * @return int
+ */
+ public function getPort(): int
+ {
+ return $this->port;
+ }
+
+ /**
+ * Set the API port
+ *
+ * @param int $port
+ *
+ * @return $this
+ */
+ public function setPort(int $port): self
+ {
+ $this->port = $port;
+
+ return $this;
+ }
+
+ /**
+ * Get the API username
+ *
+ * @return string
+ */
+ public function getUsername(): string
+ {
+ if ($this->username === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->username;
+ }
+
+ /**
+ * Set the API username
+ *
+ * @param string $username
+ *
+ * @return $this
+ */
+ public function setUsername(string $username): self
+ {
+ $this->username = $username;
+
+ return $this;
+ }
+
+ /**
+ * Get URI for endpoint
+ *
+ * @param string $endpoint
+ *
+ * @return string
+ */
+ protected function getUriFor(string $endpoint): string
+ {
+ return sprintf('https://%s:%u/v1/%s', $this->getHost(), $this->getPort(), $endpoint);
+ }
+
+ /**
+ * Send the given command to the icinga2's REST API
+ *
+ * @param IcingaApiCommand $command
+ *
+ * @return mixed
+ */
+ protected function sendCommand(IcingaApiCommand $command)
+ {
+ Logger::debug(
+ 'Sending Icinga command "%s" to the API "%s:%u"',
+ $command->getEndpoint(),
+ $this->getHost(),
+ $this->getPort()
+ );
+
+ $data = $command->getData();
+ $payload = Json::encode($data);
+ AuditHook::logActivity(
+ 'monitoring/command',
+ "Issued command {$command->getEndpoint()} with the following payload: $payload",
+ $data
+ );
+
+ $headers = ['Accept' => 'application/json'];
+ if ($command->getMethod() !== 'POST') {
+ $headers['X-HTTP-Method-Override'] = $command->getMethod();
+ }
+
+ try {
+ $response = (new Client())
+ ->post($this->getUriFor($command->getEndpoint()), [
+ 'auth' => [$this->getUsername(), $this->getPassword()],
+ 'headers' => $headers,
+ 'json' => $command->getData(),
+ 'http_errors' => false,
+ 'verify' => false
+ ]);
+ } catch (GuzzleException $e) {
+ throw new CommandTransportException(
+ 'Can\'t connect to the Icinga 2 API: %u %s',
+ $e->getCode(),
+ $e->getMessage()
+ );
+ }
+
+ try {
+ $responseData = Json::decode((string) $response->getBody(), true);
+ } catch (JsonDecodeException $e) {
+ throw new CommandTransportException(
+ 'Got invalid JSON response from the Icinga 2 API: %s',
+ $e->getMessage()
+ );
+ }
+
+ if (! isset($responseData['results']) || empty($responseData['results'])) {
+ if (isset($responseData['error'])) {
+ throw new ApiCommandException(
+ 'Can\'t send external Icinga command: %u %s',
+ $responseData['error'],
+ $responseData['status']
+ );
+ }
+
+ return;
+ }
+
+ $errorResult = $responseData['results'][0];
+ if (isset($errorResult['code']) && ($errorResult['code'] < 200 || $errorResult['code'] >= 300)) {
+ throw new ApiCommandException(
+ 'Can\'t send external Icinga command: %u %s',
+ $errorResult['code'],
+ $errorResult['status']
+ );
+ }
+
+ return $responseData['results'];
+ }
+
+ /**
+ * Send the Icinga command over the Icinga 2 API
+ *
+ * @param IcingaCommand $command
+ * @param int|null $now
+ *
+ * @throws CommandTransportException
+ *
+ * @return mixed
+ */
+ public function send(IcingaCommand $command, int $now = null)
+ {
+ return $this->sendCommand($this->renderer->render($command));
+ }
+
+ /**
+ * Try to connect to the API
+ *
+ * @return void
+ *
+ * @throws CommandTransportException In case the connection was not successful
+ */
+ public function probe()
+ {
+ try {
+ $response = (new Client(['timeout' => 15]))
+ ->get($this->getUriFor(''), [
+ 'auth' => [$this->getUsername(), $this->getPassword()],
+ 'headers' => ['Accept' => 'application/json'],
+ 'http_errors' => false,
+ 'verify' => false
+ ]);
+ } catch (GuzzleException $e) {
+ throw new CommandTransportException(
+ 'Can\'t connect to the Icinga 2 API: %u %s',
+ $e->getCode(),
+ $e->getMessage()
+ );
+ }
+
+ try {
+ $responseData = Json::decode((string) $response->getBody(), true);
+ } catch (JsonDecodeException $e) {
+ throw new CommandTransportException(
+ 'Got invalid JSON response from the Icinga 2 API: %s',
+ $e->getMessage()
+ );
+ }
+
+ if (! isset($responseData['results']) || empty($responseData['results'])) {
+ throw new CommandTransportException(
+ 'Got invalid response from the Icinga 2 API: %s',
+ JSON::encode($responseData)
+ );
+ }
+
+ $result = array_pop($responseData['results']);
+ if (! isset($result['user']) || $result['user'] !== $this->getUsername()) {
+ throw new CommandTransportException(
+ 'Got invalid response from the Icinga 2 API: %s',
+ JSON::encode($responseData)
+ );
+ }
+ }
+}
diff --git a/library/Icingadb/Command/Transport/CommandTransport.php b/library/Icingadb/Command/Transport/CommandTransport.php
new file mode 100644
index 0000000..ea125bc
--- /dev/null
+++ b/library/Icingadb/Command/Transport/CommandTransport.php
@@ -0,0 +1,130 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+
+/**
+ * Command transport
+ */
+class CommandTransport implements CommandTransportInterface
+{
+ /**
+ * Transport configuration
+ *
+ * @var Config
+ */
+ protected static $config;
+
+ /**
+ * Get transport configuration
+ *
+ * @return Config
+ *
+ * @throws ConfigurationError
+ */
+ public static function getConfig(): Config
+ {
+ if (static::$config === null) {
+ $config = Config::module('icingadb', 'commandtransports');
+ if ($config->isEmpty()) {
+ throw new ConfigurationError(
+ t('No command transports have been configured in "%s".'),
+ $config->getConfigFile()
+ );
+ }
+
+ static::$config = $config;
+ }
+
+ return static::$config;
+ }
+
+ /**
+ * Create a transport from config
+ *
+ * @param ConfigObject $config
+ *
+ * @return ApiCommandTransport
+ *
+ * @throws ConfigurationError
+ */
+ public static function createTransport(ConfigObject $config): ApiCommandTransport
+ {
+ $config = clone $config;
+ switch (strtolower($config->transport)) {
+ case ApiCommandTransport::TRANSPORT:
+ $transport = new ApiCommandTransport();
+ break;
+ default:
+ throw new ConfigurationError(
+ t('Cannot create command transport "%s". Invalid transport defined in "%s". Use one of: %s.'),
+ $config->transport,
+ static::getConfig()->getConfigFile(),
+ join(', ', [ApiCommandTransport::TRANSPORT])
+ );
+ }
+
+ unset($config->transport);
+ foreach ($config as $key => $value) {
+ $method = 'set' . ucfirst($key);
+ if (! method_exists($transport, $method)) {
+ // Ignore settings from config that don't have a setter on the transport instead of throwing an
+ // exception here because the transport should throw an exception if it's not fully set up
+ // when being about to send a command
+ continue;
+ }
+
+ $transport->$method($value);
+ }
+
+ return $transport;
+ }
+
+ /**
+ * Send the given command over an appropriate Icinga command transport
+ *
+ * This will try one configured transport after another until the command has been successfully sent.
+ *
+ * @param IcingaCommand $command The command to send
+ * @param int|null $now Timestamp of the command or null for now
+ *
+ * @throws CommandTransportException If sending the Icinga command failed
+ *
+ * @return mixed
+ */
+ public function send(IcingaCommand $command, int $now = null)
+ {
+ $errors = [];
+
+ foreach (static::getConfig() as $name => $transportConfig) {
+ $transport = static::createTransport($transportConfig);
+
+ try {
+ $result = $transport->send($command, $now);
+ } catch (CommandTransportException $e) {
+ Logger::error($e);
+ $errors[] = sprintf('%s: %s.', $name, rtrim($e->getMessage(), '.'));
+ continue; // Try the next transport
+ }
+
+ return $result; // The command was successfully sent
+ }
+
+ if (! empty($errors)) {
+ throw new CommandTransportException(implode("\n", $errors));
+ }
+
+ throw new CommandTransportException(t(
+ 'Failed to send external Icinga command. No transport has been configured'
+ . ' for this instance. Please contact your Icinga Web administrator.'
+ ));
+ }
+}
diff --git a/library/Icingadb/Command/Transport/CommandTransportConfig.php b/library/Icingadb/Command/Transport/CommandTransportConfig.php
new file mode 100644
index 0000000..e17fa04
--- /dev/null
+++ b/library/Icingadb/Command/Transport/CommandTransportConfig.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Icinga\Repository\IniRepository;
+
+class CommandTransportConfig extends IniRepository
+{
+ protected $configs = [
+ 'transport' => [
+ 'name' => 'commandtransports',
+ 'module' => 'icingadb',
+ 'keyColumn' => 'name'
+ ]
+ ];
+
+ protected $queryColumns = [
+ 'transport' => [
+ 'name',
+ 'transport',
+
+ // API options
+ 'host',
+ 'port',
+ 'username',
+ 'password'
+ ]
+ ];
+}
diff --git a/library/Icingadb/Command/Transport/CommandTransportException.php b/library/Icingadb/Command/Transport/CommandTransportException.php
new file mode 100644
index 0000000..2ca89d9
--- /dev/null
+++ b/library/Icingadb/Command/Transport/CommandTransportException.php
@@ -0,0 +1,14 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Exception thrown if a command was not sent
+ */
+class CommandTransportException extends IcingaException
+{
+}
diff --git a/library/Icingadb/Command/Transport/CommandTransportInterface.php b/library/Icingadb/Command/Transport/CommandTransportInterface.php
new file mode 100644
index 0000000..ad07cb9
--- /dev/null
+++ b/library/Icingadb/Command/Transport/CommandTransportInterface.php
@@ -0,0 +1,23 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+
+/**
+ * Interface for Icinga command transports
+ */
+interface CommandTransportInterface
+{
+ /**
+ * Send an Icinga command over the Icinga command transport
+ *
+ * @param IcingaCommand $command The command to send
+ * @param int|null $now Timestamp of the command or null for now
+ *
+ * @throws CommandTransportException If sending the Icinga command failed
+ */
+ public function send(IcingaCommand $command, int $now = null);
+}
diff --git a/library/Icingadb/Common/Auth.php b/library/Icingadb/Common/Auth.php
new file mode 100644
index 0000000..d25526e
--- /dev/null
+++ b/library/Icingadb/Common/Auth.php
@@ -0,0 +1,389 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Authentication\Auth as IcingaAuth;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Icingadb\Authentication\ObjectAuthorization;
+use Icinga\Util\StringHelper;
+use ipl\Orm\Compat\FilterProcessor;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\UnionQuery;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+
+trait Auth
+{
+ public function getAuth(): IcingaAuth
+ {
+ return IcingaAuth::getInstance();
+ }
+
+ /**
+ * Check whether access to the given route is permitted
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function isPermittedRoute(string $name): bool
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return true;
+ }
+
+ // The empty array is for PHP pre 7.4, older versions require at least a single param for array_merge
+ $routeDenylist = array_flip(array_merge([], ...array_map(function ($restriction) {
+ return StringHelper::trimSplit($restriction);
+ }, $this->getAuth()->getRestrictions('icingadb/denylist/routes'))));
+
+ return ! array_key_exists($name, $routeDenylist);
+ }
+
+ /**
+ * Check whether the permission is granted on the object
+ *
+ * @param string $permission
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public function isGrantedOn(string $permission, Model $object): bool
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return $this->getAuth()->hasPermission($permission);
+ }
+
+ return ObjectAuthorization::grantsOn($permission, $object);
+ }
+
+ /**
+ * Check whether the permission is granted on objects matching the type and filter
+ *
+ * The check will be performed on every object matching the filter. Though the result
+ * only allows to determine whether the permission is granted on **any** or *none*
+ * of the objects in question. Any subsequent call to {@see Auth::isGrantedOn} will
+ * make use of the underlying results the check has determined in order to avoid
+ * unnecessary queries.
+ *
+ * @param string $permission
+ * @param string $type
+ * @param Filter\Rule $filter
+ * @param bool $cache Pass `false` to not perform the check on every object
+ *
+ * @return bool
+ */
+ public function isGrantedOnType(string $permission, string $type, Filter\Rule $filter, bool $cache = true): bool
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return $this->getAuth()->hasPermission($permission);
+ }
+
+ return ObjectAuthorization::grantsOnType($permission, $type, $filter, $cache);
+ }
+
+ /**
+ * Check whether the filter matches the given object
+ *
+ * @param string $queryString
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public function isMatchedOn(string $queryString, Model $object): bool
+ {
+ return ObjectAuthorization::matchesOn($queryString, $object);
+ }
+
+ /**
+ * Apply Icinga DB Web's restrictions depending on what is queried
+ *
+ * This will apply `icingadb/filter/objects` in any case. `icingadb/filter/services` is only
+ * applied to queries fetching services and `icingadb/filter/hosts` is applied to queries
+ * fetching either hosts or services. It also applies custom variable restrictions and
+ * obfuscations. (`icingadb/denylist/variables` and `icingadb/protect/variables`)
+ *
+ * @param Query $query
+ *
+ * @return void
+ */
+ public function applyRestrictions(Query $query)
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return;
+ }
+
+ if ($query instanceof UnionQuery) {
+ $queries = $query->getUnions();
+ } else {
+ $queries = [$query];
+ }
+
+ $orgQuery = $query;
+ foreach ($queries as $query) {
+ $relations = [$query->getModel()->getTableName()];
+ foreach ($query->getWith() as $relationPath => $relation) {
+ $relations[$relationPath] = $relation->getTarget()->getTableName();
+ }
+
+ $customVarRelationName = array_search('customvar_flat', $relations, true);
+ $applyServiceRestriction = in_array('service', $relations, true);
+ $applyHostRestriction = in_array('host', $relations, true)
+ // Hosts and services have a special relation as a service can't exist without its host.
+ // Hence why the hosts restriction is also applied if only services are queried.
+ || $applyServiceRestriction;
+
+ $hostStateRelation = array_search('host_state', $relations, true);
+ $serviceStateRelation = array_search('service_state', $relations, true);
+
+ $resolver = $query->getResolver();
+
+ $queryFilter = Filter::any();
+ $obfuscationRules = Filter::any();
+ foreach ($this->getAuth()->getUser()->getRoles() as $role) {
+ $roleFilter = Filter::all();
+
+ if ($customVarRelationName !== false) {
+ if (($restriction = $role->getRestrictions('icingadb/denylist/variables'))) {
+ $roleFilter->add($this->parseDenylist(
+ $restriction,
+ $customVarRelationName
+ ? $resolver->qualifyColumn('flatname', $customVarRelationName)
+ : 'flatname'
+ ));
+ }
+
+ if (($restriction = $role->getRestrictions('icingadb/protect/variables'))) {
+ $obfuscationRules->add($this->parseDenylist(
+ $restriction,
+ $customVarRelationName
+ ? $resolver->qualifyColumn('flatname', $customVarRelationName)
+ : 'flatname'
+ ));
+ }
+ }
+
+ if ($customVarRelationName === false || count($relations) > 1) {
+ if (($restriction = $role->getRestrictions('icingadb/filter/objects'))) {
+ $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/objects'));
+ }
+
+ if ($applyHostRestriction && ($restriction = $role->getRestrictions('icingadb/filter/hosts'))) {
+ $hostFilter = $this->parseRestriction($restriction, 'icingadb/filter/hosts');
+ if ($orgQuery instanceof UnionQuery) {
+ $this->forceQueryOptimization($hostFilter, 'hostgroup.name');
+ }
+
+ $roleFilter->add($hostFilter);
+ }
+
+ if (
+ $applyServiceRestriction
+ && ($restriction = $role->getRestrictions('icingadb/filter/services'))
+ ) {
+ $serviceFilter = $this->parseRestriction($restriction, 'icingadb/filter/services');
+ if ($orgQuery instanceof UnionQuery) {
+ $this->forceQueryOptimization($serviceFilter, 'servicegroup.name');
+ }
+
+ $roleFilter->add(Filter::any(Filter::unlike('service.id', '*'), $serviceFilter));
+ }
+ }
+
+ if (! $roleFilter->isEmpty()) {
+ $queryFilter->add($roleFilter);
+ }
+ }
+
+ if (! $this->getAuth()->hasPermission('icingadb/object/show-source')) {
+ // In case the user does not have permission to see the object's `Source` tab, then the user must be
+ // restricted from accessing the executed command for the object.
+ $columns = $query->getColumns();
+ $commandColumns = [];
+ if ($hostStateRelation !== false) {
+ $commandColumns[] = $resolver->qualifyColumn('check_commandline', $hostStateRelation);
+ }
+
+ if ($serviceStateRelation !== false) {
+ $commandColumns[] = $resolver->qualifyColumn('check_commandline', $serviceStateRelation);
+ }
+
+ if (! empty($columns)) {
+ foreach ($commandColumns as $commandColumn) {
+ $commandColumnPath = array_search($commandColumn, $columns, true);
+ if ($commandColumnPath !== false) {
+ $columns[$commandColumn] = new Expression("'***'");
+ unset($columns[$commandColumnPath]);
+ }
+ }
+
+ $query->columns($columns);
+ } else {
+ $query->withoutColumns($commandColumns);
+ }
+ }
+
+ if (! $obfuscationRules->isEmpty()) {
+ $flatvaluePath = $customVarRelationName
+ ? $resolver->qualifyColumn('flatvalue', $customVarRelationName)
+ : 'flatvalue';
+
+ $columns = $query->getColumns();
+ if (empty($columns)) {
+ $columns = [
+ $customVarRelationName
+ ? $resolver->qualifyColumn('flatname', $customVarRelationName)
+ : 'flatname',
+ $flatvaluePath
+ ];
+ }
+
+ $flatvalue = null;
+ if (isset($columns[$flatvaluePath])) {
+ $flatvalue = $columns[$flatvaluePath];
+ } else {
+ $flatvaluePathAt = array_search($flatvaluePath, $columns, true);
+ if ($flatvaluePathAt !== false) {
+ $flatvalue = $columns[$flatvaluePathAt];
+ if (is_int($flatvaluePathAt)) {
+ unset($columns[$flatvaluePathAt]);
+ } else {
+ $flatvaluePath = $flatvaluePathAt;
+ }
+ }
+ }
+
+ if ($flatvalue !== null) {
+ // TODO: The four lines below are needed because there is still no way to postpone filter column
+ // qualification. (i.e. Just like the expression, filter rules need to be handled the same
+ // so that their columns are qualified lazily when assembling the query)
+ $queryClone = clone $query;
+ $queryClone->getSelectBase()->resetWhere();
+ FilterProcessor::apply($obfuscationRules, $queryClone);
+ $where = $queryClone->getSelectBase()->getWhere();
+
+ $values = [];
+ $rendered = $query->getDb()->getQueryBuilder()->buildCondition($where, $values);
+ $columns[$flatvaluePath] = new Expression(
+ "CASE WHEN (" . $rendered . ") THEN (%s) ELSE '***' END",
+ [$flatvalue],
+ ...$values
+ );
+
+ $query->columns($columns);
+ }
+ }
+
+ $query->filter($queryFilter);
+ }
+ }
+
+ /**
+ * Parse the given restriction
+ *
+ * @param string $queryString
+ * @param string $restriction The name of the restriction
+ *
+ * @return Filter\Rule
+ */
+ protected function parseRestriction(string $queryString, string $restriction): Filter\Rule
+ {
+ $allowedColumns = [
+ 'host.name',
+ 'hostgroup.name',
+ 'host.user.name',
+ 'host.usergroup.name',
+ 'service.name',
+ 'servicegroup.name',
+ 'service.user.name',
+ 'service.usergroup.name',
+ '(host|service).vars.<customvar-name>' => function ($c) {
+ return preg_match('/^(?:host|service)\.vars\./i', $c);
+ }
+ ];
+
+ return QueryString::fromString($queryString)
+ ->on(
+ QueryString::ON_CONDITION,
+ function (Filter\Condition $condition) use (
+ $restriction,
+ $queryString,
+ $allowedColumns
+ ) {
+ foreach ($allowedColumns as $column) {
+ if (is_callable($column)) {
+ if ($column($condition->getColumn())) {
+ return;
+ }
+ } elseif ($column === $condition->getColumn()) {
+ return;
+ }
+ }
+
+ throw new ConfigurationError(
+ t(
+ 'Cannot apply restriction %s using the filter %s.'
+ . ' You can only use the following columns: %s'
+ ),
+ $restriction,
+ $queryString,
+ join(
+ ', ',
+ array_map(
+ function ($k, $v) {
+ return is_string($k) ? $k : $v;
+ },
+ array_keys($allowedColumns),
+ $allowedColumns
+ )
+ )
+ );
+ }
+ )->parse();
+ }
+
+ /**
+ * Parse the given denylist
+ *
+ * @param string $denylist Comma separated list of column names
+ * @param string $column The column which should not equal any of the denylisted names
+ *
+ * @return Filter\None
+ */
+ protected function parseDenylist(string $denylist, string $column): Filter\None
+ {
+ $filter = Filter::none();
+ foreach (explode(',', $denylist) as $value) {
+ $filter->add(Filter::like($column, trim($value)));
+ }
+
+ return $filter;
+ }
+
+ /**
+ * Force query optimization on the given service/host filter rule
+ *
+ * Applies forceOptimization, when the given filter rule contains the given filter column
+ *
+ * @param Filter\Rule $filterRule
+ * @param string $filterColumn
+ *
+ * @return void
+ */
+ protected function forceQueryOptimization(Filter\Rule $filterRule, string $filterColumn)
+ {
+ // TODO: This is really a very poor solution is therefore only a quick fix.
+ // We need to somehow manage to make this more enjoyable and creative!
+ if ($filterRule instanceof Filter\Chain) {
+ foreach ($filterRule as $rule) {
+ $this->forceQueryOptimization($rule, $filterColumn);
+ }
+ } elseif ($filterRule->getColumn() === $filterColumn) {
+ $filterRule->metaData()->set('forceOptimization', true);
+ }
+ }
+}
diff --git a/library/Icingadb/Common/BaseFilter.php b/library/Icingadb/Common/BaseFilter.php
new file mode 100644
index 0000000..5b1791f
--- /dev/null
+++ b/library/Icingadb/Common/BaseFilter.php
@@ -0,0 +1,13 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+/**
+ * @deprecated Use {@see \ipl\Stdlib\BaseFilter} instead. This will be removed with version 1.1
+ */
+trait BaseFilter
+{
+ use \ipl\Stdlib\BaseFilter;
+}
diff --git a/library/Icingadb/Common/BaseStatusBar.php b/library/Icingadb/Common/BaseStatusBar.php
new file mode 100644
index 0000000..add176d
--- /dev/null
+++ b/library/Icingadb/Common/BaseStatusBar.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Orm\Model;
+use ipl\Stdlib\BaseFilter;
+
+abstract class BaseStatusBar extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ /** @var ServicestateSummary|HoststateSummary */
+ protected $summary;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'status-bar'];
+
+ /**
+ * Create a host or service status bar
+ *
+ * @param ServicestateSummary|HoststateSummary $summary
+ */
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ abstract protected function assembleTotal(BaseHtmlElement $total): void;
+
+ abstract protected function createStateBadges(): BaseHtmlElement;
+
+ protected function createCount(): BaseHtmlElement
+ {
+ $total = Html::tag('span', ['class' => 'item-count']);
+
+ $this->assembleTotal($total);
+
+ return $total;
+ }
+
+ protected function assemble(): void
+ {
+ $this->add([
+ $this->createCount(),
+ $this->createStateBadges()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Common/CaptionDisabled.php b/library/Icingadb/Common/CaptionDisabled.php
new file mode 100644
index 0000000..2cee178
--- /dev/null
+++ b/library/Icingadb/Common/CaptionDisabled.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait CaptionDisabled
+{
+ /** @var bool */
+ protected $captionDisabled = false;
+
+ /**
+ * @return bool
+ */
+ public function isCaptionDisabled(): bool
+ {
+ return $this->captionDisabled;
+ }
+
+ /**
+ * @param bool $captionDisabled
+ *
+ * @return $this
+ */
+ public function setCaptionDisabled(bool $captionDisabled = true): self
+ {
+ $this->captionDisabled = $captionDisabled;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Common/CommandActions.php b/library/Icingadb/Common/CommandActions.php
new file mode 100644
index 0000000..2cd13fe
--- /dev/null
+++ b/library/Icingadb/Common/CommandActions.php
@@ -0,0 +1,308 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\AcknowledgeProblemForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\AddCommentForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ProcessCheckResultForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ScheduleCheckForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ScheduleHostDowntimeForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ScheduleServiceDowntimeForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\SendCustomNotificationForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
+use Icinga\Security\SecurityException;
+use Icinga\Web\Notification;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Web\Url;
+
+/**
+ * Trait CommandActions
+ */
+trait CommandActions
+{
+ /** @var Query $commandTargets */
+ protected $commandTargets;
+
+ /** @var Model $commandTargetModel */
+ protected $commandTargetModel;
+
+ /**
+ * Get url to view command targets, used as redirection target
+ *
+ * @return Url
+ */
+ abstract protected function getCommandTargetsUrl(): Url;
+
+ /**
+ * Get status of toggleable features
+ *
+ * @return object
+ */
+ protected function getFeatureStatus()
+ {
+ }
+
+ /**
+ * Fetch command targets
+ *
+ * @return Query|Model[]
+ */
+ abstract protected function fetchCommandTargets();
+
+ /**
+ * Get command targets
+ *
+ * @return Query|Model[]
+ */
+ protected function getCommandTargets()
+ {
+ if (! isset($this->commandTargets)) {
+ $this->commandTargets = $this->fetchCommandTargets();
+ }
+
+ return $this->commandTargets;
+ }
+
+ /**
+ * Get the model of the command targets
+ *
+ * @return Model
+ */
+ protected function getCommandTargetModel(): Model
+ {
+ if (! isset($this->commandTargetModel)) {
+ $commandTargets = $this->getCommandTargets();
+ if (is_array($commandTargets) && !empty($commandTargets)) {
+ $this->commandTargetModel = $commandTargets[0];
+ } else {
+ $this->commandTargetModel = $commandTargets->getModel();
+ }
+ }
+
+ return $this->commandTargetModel;
+ }
+
+ /**
+ * Check whether the permission is granted on any of the command targets
+ *
+ * @param string $permission
+ *
+ * @return bool
+ */
+ protected function isGrantedOnCommandTargets(string $permission): bool
+ {
+ $commandTargets = $this->getCommandTargets();
+ if (is_array($commandTargets)) {
+ foreach ($commandTargets as $commandTarget) {
+ if ($this->isGrantedOn($permission, $commandTarget)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return $this->isGrantedOnType(
+ $permission,
+ $this->getCommandTargetModel()->getTableName(),
+ $commandTargets->getFilter()
+ );
+ }
+
+ /**
+ * Assert that the permission is granted on any of the command targets
+ *
+ * @param string $permission
+ *
+ * @throws SecurityException
+ */
+ protected function assertIsGrantedOnCommandTargets(string $permission)
+ {
+ if (! $this->isGrantedOnCommandTargets($permission)) {
+ throw new SecurityException('No permission for %s', $permission);
+ }
+ }
+
+ /**
+ * Handle and register the given command form
+ *
+ * @param string|CommandForm $form
+ *
+ * @return void
+ */
+ protected function handleCommandForm($form)
+ {
+ $isXhr = $this->getRequest()->isXmlHttpRequest();
+ if ($isXhr && $this->getRequest()->isApiRequest()) {
+ // Prevents the framework already, this is just a fail-safe
+ $this->httpBadRequest('Responding with JSON during a Web request is not supported');
+ }
+
+ if (is_string($form)) {
+ /** @var CommandForm $form */
+ $form = new $form();
+ }
+
+ $form->setObjects($this->getCommandTargets());
+
+ if ($isXhr) {
+ $this->handleWebRequest($form);
+ } else {
+ $this->handleApiRequest($form);
+ }
+ }
+
+ /**
+ * Handle a Web request for the given form
+ *
+ * @param CommandForm $form
+ *
+ * @return void
+ */
+ protected function handleWebRequest(CommandForm $form): void
+ {
+ $actionUrl = $this->getRequest()->getUrl();
+ if ($this->view->compact) {
+ $actionUrl = clone $actionUrl;
+ // TODO: This solves https://github.com/Icinga/icingadb-web/issues/124 but I'd like to omit this
+ // entirely. I think it should be solved like https://github.com/Icinga/icingaweb2/pull/4300 so
+ // that a request's url object still has params like showCompact and _dev
+ $actionUrl->getParams()->add('showCompact', true);
+ }
+
+ $form->setAction($actionUrl->getAbsoluteUrl());
+ $form->on($form::ON_SUCCESS, function () {
+ // This forces the column to reload nearly instantly after the redirect
+ // and ensures the effect of the command is visible to the user asap
+ $this->getResponse()->setAutoRefreshInterval(1);
+
+ $this->redirectNow($this->getCommandTargetsUrl());
+ });
+
+ $form->handleRequest($this->getServerRequest());
+
+ $this->addContent($form);
+ }
+
+ /**
+ * Handle an API request for the given form
+ *
+ * @param CommandForm $form
+ *
+ * @return never
+ */
+ protected function handleApiRequest(CommandForm $form)
+ {
+ $form->setIsApiTarget();
+ $form->on($form::ON_SUCCESS, function () {
+ $this->getResponse()
+ ->json()
+ ->setSuccessData(Notification::getInstance()->popMessages())
+ ->sendResponse();
+ });
+
+ $form->handleRequest($this->getServerRequest());
+
+ $errors = [];
+ foreach ($form->getElements() as $element) {
+ $errors[$element->getName()] = $element->getMessages();
+ }
+
+ $response = $this->getResponse()->json();
+ $response->setHttpResponseCode(422);
+ $response->setFailData($errors)
+ ->sendResponse();
+ }
+
+ public function acknowledgeAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/acknowledge-problem');
+ $this->setTitle(t('Acknowledge Problem'));
+ $this->handleCommandForm(AcknowledgeProblemForm::class);
+ }
+
+ public function addCommentAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/comment/add');
+ $this->setTitle(t('Add Comment'));
+ $this->handleCommandForm(AddCommentForm::class);
+ }
+
+ public function checkNowAction()
+ {
+ if (! $this->isGrantedOnCommandTargets('icingadb/command/schedule-check/active-only')) {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/schedule-check');
+ }
+
+ $this->handleCommandForm(CheckNowForm::class);
+ }
+
+ public function processCheckresultAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/process-check-result');
+ $this->setTitle(t('Submit Passive Check Result'));
+ $this->handleCommandForm(ProcessCheckResultForm::class);
+ }
+
+ public function removeAcknowledgementAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/remove-acknowledgement');
+ $this->handleCommandForm(RemoveAcknowledgementForm::class);
+ }
+
+ public function scheduleCheckAction()
+ {
+ if (! $this->isGrantedOnCommandTargets('icingadb/command/schedule-check/active-only')) {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/schedule-check');
+ }
+
+ $this->setTitle(t('Reschedule Check'));
+ $this->handleCommandForm(ScheduleCheckForm::class);
+ }
+
+ public function scheduleDowntimeAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/downtime/schedule');
+
+ switch ($this->getCommandTargetModel()->getTableName()) {
+ case 'host':
+ $this->setTitle(t('Schedule Host Downtime'));
+ $this->handleCommandForm(ScheduleHostDowntimeForm::class);
+ break;
+ case 'service':
+ $this->setTitle(t('Schedule Service Downtime'));
+ $this->handleCommandForm(ScheduleServiceDowntimeForm::class);
+ break;
+ }
+ }
+
+ public function sendCustomNotificationAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/send-custom-notification');
+ $this->setTitle(t('Send Custom Notification'));
+ $this->handleCommandForm(SendCustomNotificationForm::class);
+ }
+
+ public function toggleFeaturesAction()
+ {
+ $commandObjects = $this->getCommandTargets();
+ $form = null;
+ if (count($commandObjects) > 1) {
+ $this->isGrantedOnCommandTargets('i/am-only-used/to-establish/the-object-auth-cache');
+ $form = new ToggleObjectFeaturesForm($this->getFeatureStatus());
+ } else {
+ foreach ($commandObjects as $object) {
+ // There's only a single result, a foreach is the most compatible way to retrieve the object
+ $form = new ToggleObjectFeaturesForm($object);
+ }
+ }
+
+ $this->handleCommandForm($form);
+ }
+}
diff --git a/library/Icingadb/Common/Database.php b/library/Icingadb/Common/Database.php
new file mode 100644
index 0000000..8fa87cc
--- /dev/null
+++ b/library/Icingadb/Common/Database.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Config as AppConfig;
+use Icinga\Data\ResourceFactory;
+use Icinga\Exception\ConfigurationError;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Config as SqlConfig;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\QueryBuilder;
+use ipl\Sql\Select;
+use PDO;
+
+trait Database
+{
+ /** @var Connection Connection to the Icinga database */
+ private $db;
+
+ /**
+ * Get the connection to the Icinga database
+ *
+ * @return Connection
+ *
+ * @throws ConfigurationError If the related resource configuration does not exist
+ */
+ public function getDb(): Connection
+ {
+ if ($this->db === null) {
+ $config = new SqlConfig(ResourceFactory::getResourceConfig(
+ AppConfig::module('icingadb')->get('icingadb', 'resource')
+ ));
+
+ $config->options = [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ];
+ if ($config->db === 'mysql') {
+ $config->options[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET SESSION SQL_MODE='STRICT_TRANS_TABLES"
+ . ",NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'";
+ }
+
+ $this->db = new Connection($config);
+
+ $adapter = $this->db->getAdapter();
+ if ($adapter instanceof Pgsql) {
+ $quoted = $adapter->quoteIdentifier('user');
+ $this->db->getQueryBuilder()
+ ->on(QueryBuilder::ON_SELECT_ASSEMBLED, function (&$sql) use ($quoted) {
+ // user is a reserved key word in PostgreSQL, so we need to quote it.
+ // TODO(lippserd): This is pretty hacky,
+ // reconsider how to properly implement identifier quoting.
+ $sql = str_replace(' user ', sprintf(' %s ', $quoted), $sql);
+ $sql = str_replace(' user.', sprintf(' %s.', $quoted), $sql);
+ $sql = str_replace('(user.', sprintf('(%s.', $quoted), $sql);
+ })
+ ->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select) {
+ // For SELECT DISTINCT, all ORDER BY columns must appear in SELECT list.
+ if (! $select->getDistinct() || ! $select->hasOrderBy()) {
+ return;
+ }
+
+ $candidates = [];
+ foreach ($select->getOrderBy() as list($columnOrAlias, $_)) {
+ if ($columnOrAlias instanceof Expression) {
+ // Expressions can be and include anything,
+ // also columns that aren't already part of the SELECT list,
+ // so we're not trying to guess anything here.
+ // Such expressions must be in the SELECT list if necessary and
+ // referenced manually with an alias in ORDER BY.
+ continue;
+ }
+
+ $candidates[$columnOrAlias] = true;
+ }
+
+ foreach ($select->getColumns() as $alias => $column) {
+ if (is_int($alias)) {
+ if ($column instanceof Expression) {
+ // This is the complement to the above consideration.
+ // If it is an unaliased expression, ignore it.
+ continue;
+ }
+ } else {
+ unset($candidates[$alias]);
+ }
+
+ if (! $column instanceof Expression) {
+ unset($candidates[$column]);
+ }
+ }
+
+ if (! empty($candidates)) {
+ $select->columns(array_keys($candidates));
+ }
+ });
+ }
+ }
+
+ return $this->db;
+ }
+}
diff --git a/library/Icingadb/Common/DetailActions.php b/library/Icingadb/Common/DetailActions.php
new file mode 100644
index 0000000..b182b1f
--- /dev/null
+++ b/library/Icingadb/Common/DetailActions.php
@@ -0,0 +1,145 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+trait DetailActions
+{
+ /** @var bool */
+ protected $detailActionsDisabled = false;
+
+ /**
+ * Set whether this list should be an action-list
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setDetailActionsDisabled(bool $state = true): self
+ {
+ $this->detailActionsDisabled = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether this list should be an action-list
+ *
+ * @return bool
+ */
+ public function getDetailActionsDisabled(): bool
+ {
+ return $this->detailActionsDisabled;
+ }
+
+ /**
+ * Prepare this list as action-list
+ *
+ * @return $this
+ */
+ public function initializeDetailActions(): self
+ {
+ $this->getAttributes()
+ ->registerAttributeCallback('class', function () {
+ return $this->getDetailActionsDisabled() ? null : 'action-list';
+ })
+ ->registerAttributeCallback('data-icinga-multiselect-count-label', function () {
+ return $this->getDetailActionsDisabled() ? null : t('%d Item(s) selected');
+ })
+ ->registerAttributeCallback('data-icinga-multiselect-hint-label', function () {
+ return $this->getDetailActionsDisabled()
+ ? null
+ : t('Use shift/cmd + click/arrow keys to select multiple items');
+ });
+
+ return $this;
+ }
+
+ /**
+ * Set the url to use for multiple selected list items
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ protected function setMultiselectUrl(Url $url): self
+ {
+ $this->getAttributes()
+ ->registerAttributeCallback('data-icinga-multiselect-url', function () use ($url) {
+ return $this->getDetailActionsDisabled() ? null : (string) $url;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Set the url to use for a single selected list item
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ protected function setDetailUrl(Url $url): self
+ {
+ $this->getAttributes()
+ ->registerAttributeCallback('data-icinga-detail-url', function () use ($url) {
+ return $this->getDetailActionsDisabled() ? null : (string) $url;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Associate the given element with the given multi-selection filter
+ *
+ * @param BaseHtmlElement $element
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function addMultiselectFilterAttribute(BaseHtmlElement $element, Filter\Rule $filter): self
+ {
+ $element->getAttributes()
+ ->registerAttributeCallback('data-icinga-multiselect-filter', function () use ($filter) {
+ if ($this->getDetailActionsDisabled()) {
+ return null;
+ }
+
+ $queryString = QueryString::render($filter);
+ if ($filter instanceof Filter\Chain) {
+ $queryString = '(' . $queryString . ')';
+ }
+
+ return $queryString;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Associate the given element with the given single-selection filter
+ *
+ * @param BaseHtmlElement $element
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function addDetailFilterAttribute(BaseHtmlElement $element, Filter\Rule $filter): self
+ {
+ $element->getAttributes()
+ ->registerAttributeCallback('data-action-item', function () {
+ return ! $this->getDetailActionsDisabled();
+ })
+ ->registerAttributeCallback('data-icinga-detail-filter', function () use ($filter) {
+ return $this->getDetailActionsDisabled() ? null : QueryString::render($filter);
+ });
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Common/HostLink.php b/library/Icingadb/Common/HostLink.php
new file mode 100644
index 0000000..3387220
--- /dev/null
+++ b/library/Icingadb/Common/HostLink.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\StateBall;
+
+trait HostLink
+{
+ protected function createHostLink(Host $host, bool $withStateBall = false): BaseHtmlElement
+ {
+ $content = [];
+
+ if ($withStateBall) {
+ $content[] = new StateBall($host->state->getStateText(), StateBall::SIZE_MEDIUM);
+ $content[] = ' ';
+ }
+
+ $content[] = $host->display_name;
+
+ return Html::tag('a', ['href' => Links::host($host), 'class' => 'subject'], $content);
+ }
+}
diff --git a/library/Icingadb/Common/HostLinks.php b/library/Icingadb/Common/HostLinks.php
new file mode 100644
index 0000000..e8f2880
--- /dev/null
+++ b/library/Icingadb/Common/HostLinks.php
@@ -0,0 +1,76 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Web\Url;
+
+abstract class HostLinks
+{
+ public static function acknowledge(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/acknowledge', ['name' => $host->name]);
+ }
+
+ public static function addComment(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/add-comment', ['name' => $host->name]);
+ }
+
+ public static function checkNow(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/check-now', ['name' => $host->name]);
+ }
+
+ public static function scheduleCheck(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/schedule-check', ['name' => $host->name]);
+ }
+
+ public static function comments(Host $host): Url
+ {
+ return Url::fromPath('icingadb/comments', ['host.name' => $host->name]);
+ }
+
+ public static function downtimes(Host $host): Url
+ {
+ return Url::fromPath('icingadb/downtimes', ['host.name' => $host->name]);
+ }
+
+ public static function history(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/history', ['name' => $host->name]);
+ }
+
+ public static function removeAcknowledgement(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/remove-acknowledgement', ['name' => $host->name]);
+ }
+
+ public static function scheduleDowntime(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/schedule-downtime', ['name' => $host->name]);
+ }
+
+ public static function sendCustomNotification(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/send-custom-notification', ['name' => $host->name]);
+ }
+
+ public static function processCheckresult(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/process-checkresult', ['name' => $host->name]);
+ }
+
+ public static function toggleFeatures(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/toggle-features', ['name' => $host->name]);
+ }
+
+ public static function services(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/services', ['name' => $host->name]);
+ }
+}
diff --git a/library/Icingadb/Common/HostStates.php b/library/Icingadb/Common/HostStates.php
new file mode 100644
index 0000000..06b9236
--- /dev/null
+++ b/library/Icingadb/Common/HostStates.php
@@ -0,0 +1,107 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+/**
+ * Collection of possible host states.
+ */
+class HostStates
+{
+ const UP = 0;
+
+ const DOWN = 1;
+
+ const PENDING = 99;
+
+ /**
+ * Get the integer value of the given textual host state
+ *
+ * @param string $state
+ *
+ * @return int
+ *
+ * @throws \InvalidArgumentException If the given host state is invalid, i.e. not known
+ */
+ public static function int(string $state): int
+ {
+ switch (strtolower($state)) {
+ case 'up':
+ $int = self::UP;
+ break;
+ case 'down':
+ $int = self::DOWN;
+ break;
+ case 'pending':
+ $int = self::PENDING;
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid host state %d', $state));
+ }
+
+ return $int;
+ }
+
+ /**
+ * Get the textual representation of the passed host state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given host state is invalid, i.e. not known
+ */
+ public static function text(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::UP:
+ $text = 'up';
+ break;
+ case $state === self::DOWN:
+ $text = 'down';
+ break;
+ case $state === self::PENDING:
+ $text = 'pending';
+ break;
+ case $state === null:
+ $text = 'not-available';
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid host state %d', $state));
+ }
+
+ return $text;
+ }
+
+ /**
+ * Get the translated textual representation of the passed host state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given host state is invalid, i.e. not known
+ */
+ public static function translated(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::UP:
+ $text = t('up');
+ break;
+ case $state === self::DOWN:
+ $text = t('down');
+ break;
+ case $state === self::PENDING:
+ $text = t('pending');
+ break;
+ case $state === null:
+ $text = t('not available');
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid host state %d', $state));
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icingadb/Common/IcingaRedis.php b/library/Icingadb/Common/IcingaRedis.php
new file mode 100644
index 0000000..a22a0f0
--- /dev/null
+++ b/library/Icingadb/Common/IcingaRedis.php
@@ -0,0 +1,323 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Exception;
+use Generator;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Predis\Client as Redis;
+
+class IcingaRedis
+{
+ /** @var static The singleton */
+ protected static $instance;
+
+ /** @var Redis Connection to the Icinga Redis */
+ private $redis;
+
+ /** @var bool true if no connection attempt was successful */
+ private $redisUnavailable = false;
+
+ /**
+ * Get the singleton
+ *
+ * @return static
+ */
+ public static function instance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new static();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get whether Redis is unavailable
+ *
+ * @return bool
+ */
+ public static function isUnavailable(): bool
+ {
+ $self = self::instance();
+
+ if (! $self->redisUnavailable && $self->redis === null) {
+ try {
+ $self->getConnection();
+ } catch (Exception $_) {
+ // getConnection already logs the error
+ }
+ }
+
+ return $self->redisUnavailable;
+ }
+
+ /**
+ * Get the connection to the Icinga Redis
+ *
+ * @return Redis
+ *
+ * @throws Exception
+ */
+ public function getConnection(): Redis
+ {
+ if ($this->redisUnavailable) {
+ throw new Exception('Redis is still not available');
+ } elseif ($this->redis === null) {
+ try {
+ $primaryRedis = $this->getPrimaryRedis();
+ } catch (Exception $e) {
+ try {
+ $secondaryRedis = $this->getSecondaryRedis();
+ } catch (Exception $ee) {
+ $this->redisUnavailable = true;
+ Logger::error($ee);
+
+ throw $e;
+ }
+
+ if ($secondaryRedis === null) {
+ $this->redisUnavailable = true;
+
+ throw $e;
+ }
+
+ $this->redis = $secondaryRedis;
+
+ return $this->redis;
+ }
+
+ $primaryTimestamp = $this->getLastIcingaHeartbeat($primaryRedis);
+
+ if ($primaryTimestamp <= time() - 60) {
+ $secondaryRedis = $this->getSecondaryRedis();
+
+ if ($secondaryRedis === null) {
+ $this->redis = $primaryRedis;
+
+ return $this->redis;
+ }
+
+ $secondaryTimestamp = $this->getLastIcingaHeartbeat($secondaryRedis);
+
+ if ($secondaryTimestamp > $primaryTimestamp) {
+ $this->redis = $secondaryRedis;
+ } else {
+ $this->redis = $primaryRedis;
+ }
+ } else {
+ $this->redis = $primaryRedis;
+ }
+ }
+
+ return $this->redis;
+ }
+
+ /**
+ * Fetch host states
+ *
+ * @param array $ids The host ids to fetch results for
+ * @param array $columns The columns to include in the results
+ *
+ * @return Generator
+ */
+ public static function fetchHostState(array $ids, array $columns): Generator
+ {
+ return self::fetchState('icinga:host:state', $ids, $columns);
+ }
+
+ /**
+ * Fetch service states
+ *
+ * @param array $ids The service ids to fetch results for
+ * @param array $columns The columns to include in the results
+ *
+ * @return Generator
+ */
+ public static function fetchServiceState(array $ids, array $columns): Generator
+ {
+ return self::fetchState('icinga:service:state', $ids, $columns);
+ }
+
+ /**
+ * Fetch object states
+ *
+ * @param string $key The object key to access
+ * @param array $ids The object ids to fetch results for
+ * @param array $columns The columns to include in the results
+ *
+ * @return Generator
+ */
+ protected static function fetchState(string $key, array $ids, array $columns): Generator
+ {
+ try {
+ $results = self::instance()->getConnection()->hmget($key, $ids);
+ } catch (Exception $_) {
+ // The error has already been logged elsewhere
+ return;
+ }
+
+ foreach ($results as $i => $json) {
+ if ($json !== null) {
+ $data = json_decode($json, true);
+ $keyMap = array_fill_keys($columns, null);
+ unset($keyMap['is_overdue']); // Is calculated by Icinga DB, not Icinga 2, hence it's never in redis
+
+ // TODO: Remove once https://github.com/Icinga/icinga2/issues/9427 is fixed
+ $data['state_type'] = $data['state_type'] === 0 ? 'soft' : 'hard';
+
+ if (isset($data['in_downtime']) && is_bool($data['in_downtime'])) {
+ $data['in_downtime'] = $data['in_downtime'] ? 'y' : 'n';
+ }
+
+ if (isset($data['is_acknowledged']) && is_int($data['is_acknowledged'])) {
+ $data['is_acknowledged'] = $data['is_acknowledged'] ? 'y' : 'n';
+ }
+
+ yield $ids[$i] => array_intersect_key(array_merge($keyMap, $data), $keyMap);
+ }
+ }
+ }
+
+ /**
+ * Get the last icinga heartbeat from redis
+ *
+ * @param Redis|null $redis
+ *
+ * @return float|int|null
+ */
+ public static function getLastIcingaHeartbeat(Redis $redis = null)
+ {
+ if ($redis === null) {
+ $redis = self::instance()->getConnection();
+ }
+
+ // Predis doesn't support streams (yet).
+ // https://github.com/predis/predis/issues/607#event-3640855190
+ $rs = $redis->executeRaw(['XREAD', 'COUNT', '1', 'STREAMS', 'icinga:stats', '0']);
+
+ if (! is_array($rs)) {
+ return null;
+ }
+
+ $key = null;
+
+ foreach ($rs[0][1][0][1] as $kv) {
+ if ($key === null) {
+ $key = $kv;
+ } else {
+ if ($key === 'timestamp') {
+ return $kv / 1000;
+ }
+
+ $key = null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the primary redis instance
+ *
+ * @param Config|null $moduleConfig
+ * @param Config|null $redisConfig
+ *
+ * @return Redis
+ */
+ public static function getPrimaryRedis(Config $moduleConfig = null, Config $redisConfig = null): Redis
+ {
+ if ($moduleConfig === null) {
+ $moduleConfig = Config::module('icingadb');
+ }
+
+ if ($redisConfig === null) {
+ $redisConfig = Config::module('icingadb', 'redis');
+ }
+
+ $section = $redisConfig->getSection('redis1');
+
+ $redis = new Redis([
+ 'host' => $section->get('host', 'localhost'),
+ 'port' => $section->get('port', 6380),
+ 'password' => $section->get('password', ''),
+ 'timeout' => 0.5
+ ] + self::getTlsParams($moduleConfig));
+
+ $redis->ping();
+
+ return $redis;
+ }
+
+ /**
+ * Get the secondary redis instance if exists
+ *
+ * @param Config|null $moduleConfig
+ * @param Config|null $redisConfig
+ *
+ * @return ?Redis
+ */
+ public static function getSecondaryRedis(Config $moduleConfig = null, Config $redisConfig = null)
+ {
+ if ($moduleConfig === null) {
+ $moduleConfig = Config::module('icingadb');
+ }
+
+ if ($redisConfig === null) {
+ $redisConfig = Config::module('icingadb', 'redis');
+ }
+
+ $section = $redisConfig->getSection('redis2');
+ $host = $section->host;
+
+ if (empty($host)) {
+ return null;
+ }
+
+ $redis = new Redis([
+ 'host' => $host,
+ 'port' => $section->get('port', 6380),
+ 'password' => $section->get('password', ''),
+ 'timeout' => 0.5
+ ] + self::getTlsParams($moduleConfig));
+
+ $redis->ping();
+
+ return $redis;
+ }
+
+ private static function getTlsParams(Config $config): array
+ {
+ $config = $config->getSection('redis');
+
+ if (! $config->get('tls', false)) {
+ return [];
+ }
+
+ $ssl = [];
+
+ if ($config->get('insecure')) {
+ $ssl['verify_peer'] = false;
+ $ssl['verify_peer_name'] = false;
+ } else {
+ $ca = $config->get('ca');
+
+ if ($ca !== null) {
+ $ssl['cafile'] = $ca;
+ }
+ }
+
+ $cert = $config->get('cert');
+ $key = $config->get('key');
+
+ if ($cert !== null && $key !== null) {
+ $ssl['local_cert'] = $cert;
+ $ssl['local_pk'] = $key;
+ }
+
+ return ['scheme' => 'tls', 'ssl' => $ssl];
+ }
+}
diff --git a/library/Icingadb/Common/Icons.php b/library/Icingadb/Common/Icons.php
new file mode 100644
index 0000000..cac9f32
--- /dev/null
+++ b/library/Icingadb/Common/Icons.php
@@ -0,0 +1,30 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+class Icons
+{
+ const COMMENT = 'comment';
+
+ const HOST_DOWN = 'sitemap';
+
+ const IN_DOWNTIME = 'plug';
+
+ const IS_ACKNOWLEDGED = 'check';
+
+ const IS_FLAPPING = 'bolt';
+
+ const IS_PERSISTENT = 'thumbtack';
+
+ const NOTIFICATION = 'bell';
+
+ const REMOVE = 'trash';
+
+ const USER = 'user';
+
+ const USERGROUP = 'users';
+
+ const WARNING = 'exclamation-triangle';
+}
diff --git a/library/Icingadb/Common/Links.php b/library/Icingadb/Common/Links.php
new file mode 100644
index 0000000..5968e5f
--- /dev/null
+++ b/library/Icingadb/Common/Links.php
@@ -0,0 +1,143 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\NotificationHistory;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use ipl\Web\Url;
+
+abstract class Links
+{
+ public static function comment(Comment $comment): Url
+ {
+ return Url::fromPath('icingadb/comment', ['name' => $comment->name]);
+ }
+
+ public static function comments(): Url
+ {
+ return Url::fromPath('icingadb/comments');
+ }
+
+ public static function commentsDelete(): Url
+ {
+ return Url::fromPath('icingadb/comments/delete');
+ }
+
+ public static function commentsDetails(): Url
+ {
+ return Url::fromPath('icingadb/comments/details');
+ }
+
+ public static function downtime(Downtime $downtime): Url
+ {
+ return Url::fromPath('icingadb/downtime', ['name' => $downtime->name]);
+ }
+
+ public static function downtimes(): Url
+ {
+ return Url::fromPath('icingadb/downtimes');
+ }
+
+ public static function downtimesDelete(): Url
+ {
+ return Url::fromPath('icingadb/downtimes/delete');
+ }
+
+ public static function downtimesDetails(): Url
+ {
+ return Url::fromPath('icingadb/downtimes/details');
+ }
+
+ public static function host(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host', ['name' => $host->name]);
+ }
+
+ public static function hostSource(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/source', ['name' => $host->name]);
+ }
+
+ public static function hostsDetails(): Url
+ {
+ return Url::fromPath('icingadb/hosts/details');
+ }
+
+ public static function hostgroup($hostgroup): Url
+ {
+ return Url::fromPath('icingadb/hostgroup', ['name' => $hostgroup->name]);
+ }
+
+ public static function hosts(): Url
+ {
+ return Url::fromPath('icingadb/hosts');
+ }
+
+ public static function service(Service $service, Host $host): Url
+ {
+ return Url::fromPath('icingadb/service', ['name' => $service->name, 'host.name' => $host->name]);
+ }
+
+ public static function serviceSource(Service $service, Host $host): Url
+ {
+ return Url::fromPath('icingadb/service/source', ['name' => $service->name, 'host.name' => $host->name]);
+ }
+
+ public static function servicesDetails(): Url
+ {
+ return Url::fromPath('icingadb/services/details');
+ }
+
+ public static function servicegroup($servicegroup): Url
+ {
+ return Url::fromPath('icingadb/servicegroup', ['name' => $servicegroup->name]);
+ }
+
+ public static function services(): Url
+ {
+ return Url::fromPath('icingadb/services');
+ }
+
+ public static function toggleHostsFeatures(): Url
+ {
+ return Url::fromPath('icingadb/hosts/toggle-features');
+ }
+
+ public static function toggleServicesFeatures(): Url
+ {
+ return Url::fromPath('icingadb/services/toggle-features');
+ }
+
+ public static function user(User $user): Url
+ {
+ return Url::fromPath('icingadb/user', ['name' => $user->name]);
+ }
+
+ public static function usergroup(Usergroup $usergroup): Url
+ {
+ return Url::fromPath('icingadb/usergroup', ['name' => $usergroup->name]);
+ }
+
+ public static function users(): Url
+ {
+ return Url::fromPath('icingadb/users');
+ }
+
+ public static function usergroups(): Url
+ {
+ return Url::fromPath('icingadb/usergroups');
+ }
+
+ public static function event(History $event): Url
+ {
+ return Url::fromPath('icingadb/event', ['id' => bin2hex($event->id)]);
+ }
+}
diff --git a/library/Icingadb/Common/ListItemCommonLayout.php b/library/Icingadb/Common/ListItemCommonLayout.php
new file mode 100644
index 0000000..5a11be3
--- /dev/null
+++ b/library/Icingadb/Common/ListItemCommonLayout.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+
+trait ListItemCommonLayout
+{
+ use CaptionDisabled;
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ $header->addHtml($this->createTitle());
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->addHtml($this->createHeader());
+ if (!$this->isCaptionDisabled()) {
+ $main->addHtml($this->createCaption());
+ }
+ }
+}
diff --git a/library/Icingadb/Common/ListItemDetailedLayout.php b/library/Icingadb/Common/ListItemDetailedLayout.php
new file mode 100644
index 0000000..23aa017
--- /dev/null
+++ b/library/Icingadb/Common/ListItemDetailedLayout.php
@@ -0,0 +1,23 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+
+trait ListItemDetailedLayout
+{
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ $header->add($this->createTitle());
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->add($this->createHeader());
+ $main->add($this->createCaption());
+ $main->add($this->createFooter());
+ }
+}
diff --git a/library/Icingadb/Common/ListItemMinimalLayout.php b/library/Icingadb/Common/ListItemMinimalLayout.php
new file mode 100644
index 0000000..3cdf3a9
--- /dev/null
+++ b/library/Icingadb/Common/ListItemMinimalLayout.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+
+trait ListItemMinimalLayout
+{
+ use CaptionDisabled;
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ $header->add($this->createTitle());
+ if (! $this->isCaptionDisabled()) {
+ $header->add($this->createCaption());
+ }
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->add($this->createHeader());
+ }
+}
diff --git a/library/Icingadb/Common/LoadMore.php b/library/Icingadb/Common/LoadMore.php
new file mode 100644
index 0000000..ad44e59
--- /dev/null
+++ b/library/Icingadb/Common/LoadMore.php
@@ -0,0 +1,111 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Generator;
+use Icinga\Module\Icingadb\Widget\ItemList\PageSeparatorItem;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Orm\ResultSet;
+use ipl\Web\Url;
+
+trait LoadMore
+{
+ /** @var int */
+ protected $pageSize;
+
+ /** @var int */
+ protected $pageNumber;
+
+ /** @var Url */
+ protected $loadMoreUrl;
+
+ /**
+ * Set the page size
+ *
+ * @param int $size
+ *
+ * @return $this
+ */
+ public function setPageSize(int $size): self
+ {
+ $this->pageSize = $size;
+
+ return $this;
+ }
+
+ /**
+ * Set the page number
+ *
+ * @param int $number
+ *
+ * @return $this
+ */
+ public function setPageNumber(int $number): self
+ {
+ $this->pageNumber = $number;
+
+ return $this;
+ }
+
+ /**
+ * Set the url to fetch more items
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setLoadMoreUrl(Url $url): self
+ {
+ $this->loadMoreUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Iterate over the given data
+ *
+ * Add the page separator and the "LoadMore" button at the desired position
+ *
+ * @param ResultSet $result
+ *
+ * @return Generator
+ */
+ protected function getIterator(ResultSet $result): Generator
+ {
+ $count = 0;
+ $pageNumber = $this->pageNumber ?: 1;
+
+ if ($pageNumber > 1) {
+ $this->add(new PageSeparatorItem($pageNumber));
+ }
+
+ foreach ($result as $data) {
+ $count++;
+
+ if ($count % $this->pageSize === 0) {
+ $pageNumber++;
+ } elseif ($count > $this->pageSize && $count % $this->pageSize === 1) {
+ $this->add(new PageSeparatorItem($pageNumber));
+ }
+
+ yield $data;
+ }
+
+ if ($count > 0 && $this->loadMoreUrl !== null) {
+ $showMore = (new ShowMore(
+ $result,
+ $this->loadMoreUrl->setParam('page', $pageNumber)
+ ->setAnchor('page-' . ($pageNumber))
+ ))
+ ->setLabel(t('Load More'))
+ ->setAttributes([
+ 'class' => 'load-more',
+ 'data-no-icinga-ajax' => true
+ ]);
+
+ $this->add($showMore->setTag('li')->addAttributes(['class' => 'list-item']));
+ }
+ }
+}
diff --git a/library/Icingadb/Common/Macros.php b/library/Icingadb/Common/Macros.php
new file mode 100644
index 0000000..4842c27
--- /dev/null
+++ b/library/Icingadb/Common/Macros.php
@@ -0,0 +1,120 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Compat\CompatHost;
+use Icinga\Module\Icingadb\Compat\CompatService;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\ResultSet;
+
+use function ipl\Stdlib\get_php_type;
+
+trait Macros
+{
+ /**
+ * Get the given string with macros being resolved
+ *
+ * @param string $input The string in which to look for macros
+ * @param Model|CompatService|CompatHost $object The host or service used to resolve the macros
+ *
+ * @return string
+ */
+ public function expandMacros(string $input, $object): string
+ {
+ if (preg_match_all('@\$([^\$\s]+)\$@', $input, $matches)) {
+ foreach ($matches[1] as $key => $value) {
+ $newValue = $this->resolveMacro($value, $object);
+ if ($newValue !== $value) {
+ $input = str_replace($matches[0][$key], $newValue, $input);
+ }
+ }
+ }
+
+ return $input;
+ }
+
+ /**
+ * Resolve a macro based on the given object
+ *
+ * @param string $macro The macro to resolve
+ * @param Model|CompatService|CompatHost $object The host or service used to resolve the macros
+ *
+ * @return string
+ */
+ public function resolveMacro(string $macro, $object): string
+ {
+ if ($object instanceof Host || (property_exists($object, 'type') && $object->type === 'host')) {
+ $objectType = 'host';
+ } else {
+ $objectType = 'service';
+ }
+
+ $path = null;
+ $macroType = $objectType;
+ $isCustomVar = false;
+ if (preg_match('/^((host|service)\.)?vars\.(.+)/', $macro, $matches)) {
+ if (! empty($matches[2])) {
+ $macroType = $matches[2];
+ }
+
+ $path = $matches[3];
+ $isCustomVar = true;
+ } elseif (preg_match('/^(\w+)\.(.+)/', $macro, $matches)) {
+ $macroType = $matches[1];
+ $path = $matches[2];
+ }
+
+ try {
+ if ($path !== null) {
+ if ($macroType !== $objectType) {
+ $value = $object->$macroType;
+ } else {
+ $value = $object;
+ }
+
+ $properties = explode('.', $path);
+
+ do {
+ $column = array_shift($properties);
+ if ($value instanceof Query || $value instanceof ResultSet || is_array($value)) {
+ Logger::debug(
+ 'Failed to resolve property "%s" on a "%s" type.',
+ $isCustomVar ? 'vars' : $column,
+ get_php_type($value)
+ );
+ $value = null;
+ break;
+ }
+
+ if ($isCustomVar) {
+ $value = $value->vars[$path];
+ break;
+ }
+
+ $value = $value->$column;
+ } while (! empty($properties) && $value !== null);
+ } else {
+ $value = $object->$macro;
+ }
+ } catch (\Exception $e) {
+ $value = null;
+ Logger::debug('Unable to resolve macro "%s". An error occurred: %s', $macro, $e);
+ }
+
+ if ($value instanceof Query || $value instanceof ResultSet || is_array($value)) {
+ Logger::debug(
+ 'It is not allowed to use "%s" as a macro which produces a "%s" type as a result.',
+ $macro,
+ get_php_type($value)
+ );
+ $value = null;
+ }
+
+ return $value !== null ? $value : $macro;
+ }
+}
diff --git a/library/Icingadb/Common/NoSubjectLink.php b/library/Icingadb/Common/NoSubjectLink.php
new file mode 100644
index 0000000..76c9a84
--- /dev/null
+++ b/library/Icingadb/Common/NoSubjectLink.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait NoSubjectLink
+{
+ /** @var bool */
+ protected $noSubjectLink = false;
+
+ /**
+ * Set whether a list item's subject should be a link
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setNoSubjectLink(bool $state = true): self
+ {
+ $this->noSubjectLink = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether a list item's subject should be a link
+ *
+ * @return bool
+ */
+ public function getNoSubjectLink(): bool
+ {
+ return $this->noSubjectLink;
+ }
+}
diff --git a/library/Icingadb/Common/ObjectInspectionDetail.php b/library/Icingadb/Common/ObjectInspectionDetail.php
new file mode 100644
index 0000000..b30797b
--- /dev/null
+++ b/library/Icingadb/Common/ObjectInspectionDetail.php
@@ -0,0 +1,348 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use DateTime;
+use DateTimeZone;
+use Exception;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Widget\Detail\CustomVarTable;
+use Icinga\Util\Format;
+use Icinga\Util\Json;
+use InvalidArgumentException;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+use ipl\Web\Widget\CopyToClipboard;
+use ipl\Web\Widget\EmptyState;
+
+abstract class ObjectInspectionDetail extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => ['object-detail', 'inspection-detail']];
+
+ /** @var Model */
+ protected $object;
+
+ /** @var array */
+ protected $attrs;
+
+ /** @var array */
+ protected $joins;
+
+ public function __construct(Model $object, array $apiResult)
+ {
+ $this->object = $object;
+ $this->attrs = $apiResult['attrs'];
+ $this->joins = $apiResult['joins'];
+ }
+
+ /**
+ * Render the object source location
+ *
+ * @return ?array
+ */
+ protected function createSourceLocation()
+ {
+ if (! isset($this->attrs['source_location'])) {
+ return;
+ }
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Source Location'))),
+ FormattedString::create(
+ t('You can find this object in %s on line %s.'),
+ new HtmlElement('strong', null, Text::create($this->attrs['source_location']['path'])),
+ new HtmlElement('strong', null, Text::create($this->attrs['source_location']['first_line']))
+ )
+ ];
+ }
+
+ /**
+ * Render object's last check result
+ *
+ * @return ?array
+ */
+ protected function createLastCheckResult()
+ {
+ if (! isset($this->attrs['last_check_result'])) {
+ return;
+ }
+
+ $command = $this->attrs['last_check_result']['command'];
+ if (is_array($command)) {
+ $command = join(' ', array_map('escapeshellarg', $command));
+ }
+
+ $denylist = [
+ 'command',
+ 'output',
+ 'type',
+ 'active'
+ ];
+
+ if ($command) {
+ $execCommand = new HtmlElement('pre', null, Text::create($command));
+ CopyToClipboard::attachTo($execCommand);
+ } else {
+ $execCommand = new EmptyState(t('n. a.'));
+ }
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Executed Command'))),
+ $execCommand,
+ new HtmlElement('h2', null, Text::create(t('Execution Details'))),
+ $this->createNameValueTable(
+ array_diff_key($this->attrs['last_check_result'], array_flip($denylist)),
+ [
+ 'execution_end' => [$this, 'formatTimestamp'],
+ 'execution_start' => [$this, 'formatTimestamp'],
+ 'schedule_end' => [$this, 'formatTimestamp'],
+ 'schedule_start' => [$this, 'formatTimestamp'],
+ 'ttl' => [$this, 'formatSeconds'],
+ 'state' => [$this, 'formatState']
+ ]
+ )
+ ];
+ }
+
+ protected function createRedisInfo(): array
+ {
+ $title = new HtmlElement('h2', null, Text::create(t('Volatile State Details')));
+
+ try {
+ $json = IcingaRedis::instance()->getConnection()
+ ->hGet("icinga:{$this->object->getTableName()}:state", bin2hex($this->object->id));
+ } catch (Exception $e) {
+ return [$title, sprintf('Failed to load redis data: %s', $e->getMessage())];
+ }
+
+ if (! $json) {
+ return [$title, new EmptyState(t('No data available in redis'))];
+ }
+
+ try {
+ $data = Json::decode($json, true);
+ } catch (JsonDecodeException $e) {
+ return [$title, sprintf('Failed to decode redis data: %s', $e->getMessage())];
+ }
+
+ $denylist = [
+ 'commandline',
+ 'environment_id',
+ 'id'
+ ];
+
+ return [$title, $this->createNameValueTable(
+ array_diff_key($data, array_flip($denylist)),
+ [
+ 'last_state_change' => [$this, 'formatMillisecondTimestamp'],
+ 'last_update' => [$this, 'formatMillisecondTimestamp'],
+ 'next_check' => [$this, 'formatMillisecondTimestamp'],
+ 'next_update' => [$this, 'formatMillisecondTimestamp'],
+ 'check_timeout' => [$this, 'formatMilliseconds'],
+ 'execution_time' => [$this, 'formatMilliseconds'],
+ 'latency' => [$this, 'formatMilliseconds'],
+ 'hard_state' => [$this, 'formatState'],
+ 'previous_soft_state' => [$this, 'formatState'],
+ 'previous_hard_state' => [$this, 'formatState'],
+ 'state' => [$this, 'formatState']
+ ]
+ )];
+ }
+
+ protected function createAttributes(): array
+ {
+ $denylist = [
+ 'name',
+ '__name',
+ 'host_name',
+ 'display_name',
+ 'last_check_result',
+ 'source_location',
+ 'templates',
+ 'package',
+ 'version',
+ 'type',
+ 'active',
+ 'paused',
+ 'ha_mode'
+ ];
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Object Attributes'))),
+ $this->createNameValueTable(
+ array_diff_key($this->attrs, array_flip($denylist)),
+ [
+ 'acknowledgement_expiry' => [$this, 'formatTimestamp'],
+ 'acknowledgement_last_change' => [$this, 'formatTimestamp'],
+ 'check_timeout' => [$this, 'formatSeconds'],
+ 'flapping_last_change' => [$this, 'formatTimestamp'],
+ 'last_check' => [$this, 'formatTimestamp'],
+ 'last_hard_state_change' => [$this, 'formatTimestamp'],
+ 'last_state_change' => [$this, 'formatTimestamp'],
+ 'last_state_ok' => [$this, 'formatTimestamp'],
+ 'last_state_up' => [$this, 'formatTimestamp'],
+ 'last_state_warning' => [$this, 'formatTimestamp'],
+ 'last_state_critical' => [$this, 'formatTimestamp'],
+ 'last_state_down' => [$this, 'formatTimestamp'],
+ 'last_state_unknown' => [$this, 'formatTimestamp'],
+ 'last_state_unreachable' => [$this, 'formatTimestamp'],
+ 'next_check' => [$this, 'formatTimestamp'],
+ 'next_update' => [$this, 'formatTimestamp'],
+ 'previous_state_change' => [$this, 'formatTimestamp'],
+ 'check_interval' => [$this, 'formatSeconds'],
+ 'retry_interval' => [$this, 'formatSeconds'],
+ 'last_hard_state' => [$this, 'formatState'],
+ 'last_state' => [$this, 'formatState'],
+ 'state' => [$this, 'formatState']
+ ]
+ )
+ ];
+ }
+
+ protected function createCustomVariables()
+ {
+ $query = $this->object->customvar
+ ->columns(['name', 'value']);
+
+ $result = [];
+ foreach ($query as $row) {
+ $result[$row->name] = json_decode($row->value, true) ?? $row->value;
+ }
+
+ if (! empty($result)) {
+ $vars = new CustomVarTable($result);
+ } else {
+ $vars = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Custom Variables'))),
+ $vars
+ ];
+ }
+
+ /**
+ * Format the given value as a json
+ *
+ * @param mixed $json
+ *
+ * @return BaseHtmlElement|string
+ */
+ private function formatJson($json)
+ {
+ if (is_scalar($json)) {
+ return Json::encode($json, JSON_UNESCAPED_SLASHES);
+ }
+
+ return new HtmlElement(
+ 'pre',
+ null,
+ Text::create(Json::encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
+ );
+ }
+
+ /**
+ * Format the given timestamp
+ *
+ * @param int|float|null $ts
+ *
+ * @return EmptyState|string
+ */
+ private function formatTimestamp($ts)
+ {
+ if (empty($ts)) {
+ return new EmptyState(t('n. a.'));
+ }
+
+ if (is_float($ts)) {
+ $dt = DateTime::createFromFormat('U.u', sprintf('%F', $ts));
+ } else {
+ $dt = (new DateTime())->setTimestamp($ts);
+ }
+
+ return $dt->setTimezone(new DateTimeZone('UTC'))
+ ->format('Y-m-d\TH:i:s.vP');
+ }
+
+ /**
+ * Format the given timestamp (in milliseconds)
+ *
+ * @param int|float|null $ms
+ *
+ * @return EmptyState|string
+ */
+ private function formatMillisecondTimestamp($ms)
+ {
+ return $this->formatTimestamp($ms / 1000.0);
+ }
+
+ private function formatSeconds($s): string
+ {
+ return Format::seconds($s);
+ }
+
+ private function formatMilliseconds($ms): string
+ {
+ return Format::seconds($ms / 1000.0);
+ }
+
+ private function formatState(int $state)
+ {
+ try {
+ switch (true) {
+ case $this->object instanceof Host:
+ return HostStates::text($state);
+ case $this->object instanceof Service:
+ return ServiceStates::text($state);
+ default:
+ return $state;
+ }
+ } catch (InvalidArgumentException $_) {
+ // The Icinga 2 API sometimes delivers strange details
+ return (string) $state;
+ }
+ }
+
+ private function createNameValueTable(array $data, array $formatters): Table
+ {
+ $table = new Table();
+ $table->addAttributes(['class' => 'name-value-table']);
+ foreach ($data as $name => $value) {
+ if (empty($value) && ($value === null || is_string($value) || is_array($value))) {
+ $value = new EmptyState(t('n. a.'));
+ } else {
+ try {
+ if (isset($formatters[$name])) {
+ $value = call_user_func($formatters[$name], $value);
+ } else {
+ $value = $this->formatJson($value);
+
+ if ($value instanceof BaseHtmlElement) {
+ CopyToClipboard::attachTo($value);
+ }
+ }
+ } catch (Exception $e) {
+ $value = new EmptyState(IcingaException::describe($e));
+ }
+ }
+
+ $table->addHtml(Table::tr([
+ Table::th($name),
+ Table::td($value)
+ ]));
+ }
+
+ return $table;
+ }
+}
diff --git a/library/Icingadb/Common/ObjectLinkDisabled.php b/library/Icingadb/Common/ObjectLinkDisabled.php
new file mode 100644
index 0000000..ca8283f
--- /dev/null
+++ b/library/Icingadb/Common/ObjectLinkDisabled.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait ObjectLinkDisabled
+{
+ /** @var bool */
+ protected $objectLinkDisabled = false;
+
+ /**
+ * Set whether list items should render host and service links
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setObjectLinkDisabled(bool $state = true): self
+ {
+ $this->objectLinkDisabled = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether list items should render host and service links
+ *
+ * @return bool
+ */
+ public function getObjectLinkDisabled(): bool
+ {
+ return $this->objectLinkDisabled;
+ }
+}
diff --git a/library/Icingadb/Common/SearchControls.php b/library/Icingadb/Common/SearchControls.php
new file mode 100644
index 0000000..7927da0
--- /dev/null
+++ b/library/Icingadb/Common/SearchControls.php
@@ -0,0 +1,69 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use ipl\Html\Html;
+use ipl\Orm\Query;
+use ipl\Web\Control\SearchBar;
+use ipl\Web\Url;
+use ipl\Web\Widget\ContinueWith;
+
+trait SearchControls
+{
+ use \ipl\Web\Compat\SearchControls {
+ \ipl\Web\Compat\SearchControls::createSearchBar as private webCreateSearchBar;
+ }
+
+ public function fetchFilterColumns(Query $query): array
+ {
+ return iterator_to_array(ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver()));
+ }
+
+ /**
+ * Create and return the SearchBar
+ *
+ * @param Query $query The query being filtered
+ * @param Url $redirectUrl Url to redirect to upon success
+ * @param array $preserveParams Query params to preserve when redirecting
+ *
+ * @return SearchBar
+ */
+ public function createSearchBar(Query $query, ...$params): SearchBar
+ {
+ $searchBar = $this->webCreateSearchBar($query, ...$params);
+
+ if (($wrapper = $searchBar->getWrapper()) && ! $wrapper->getWrapper()) {
+ // TODO: Remove this once ipl-web v0.7.0 is required
+ $searchBar->addWrapper(Html::tag('div', ['class' => 'search-controls']));
+ }
+
+ return $searchBar;
+ }
+
+ /**
+ * Create and return a ContinueWith
+ *
+ * This will automatically be appended to the SearchBar's wrapper. It's not necessary
+ * to add it separately as control or content!
+ *
+ * @param Url $detailsUrl
+ * @param SearchBar $searchBar
+ *
+ * @return ContinueWith
+ */
+ public function createContinueWith(Url $detailsUrl, SearchBar $searchBar): ContinueWith
+ {
+ $continueWith = new ContinueWith($detailsUrl, [$searchBar, 'getFilter']);
+ $continueWith->setTitle(t('Show bulk processing actions for all filtered results'));
+ $continueWith->setBaseTarget('_next');
+ $continueWith->getAttributes()
+ ->set('id', $this->getRequest()->protectId('continue-with'));
+
+ $searchBar->getWrapper()->add($continueWith);
+
+ return $continueWith;
+ }
+}
diff --git a/library/Icingadb/Common/ServiceLink.php b/library/Icingadb/Common/ServiceLink.php
new file mode 100644
index 0000000..75ac6c6
--- /dev/null
+++ b/library/Icingadb/Common/ServiceLink.php
@@ -0,0 +1,40 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\FormattedString;
+use ipl\Html\Html;
+use ipl\Web\Widget\StateBall;
+
+trait ServiceLink
+{
+ protected function createServiceLink(Service $service, Host $host, bool $withStateBall = false): FormattedString
+ {
+ $content = [];
+
+ if ($withStateBall) {
+ $content[] = new StateBall($service->state->getStateText(), StateBall::SIZE_MEDIUM);
+ $content[] = ' ';
+ }
+
+ $content[] = $service->display_name;
+
+ return Html::sprintf(
+ t('%s on %s', '<service> on <host>'),
+ Html::tag('a', ['href' => Links::service($service, $host), 'class' => 'subject'], $content),
+ Html::tag(
+ 'a',
+ ['href' => Links::host($host), 'class' => 'subject'],
+ [
+ new StateBall($host->state->getStateText(), StateBall::SIZE_MEDIUM),
+ ' ',
+ $host->display_name
+ ]
+ )
+ );
+ }
+}
diff --git a/library/Icingadb/Common/ServiceLinks.php b/library/Icingadb/Common/ServiceLinks.php
new file mode 100644
index 0000000..368be48
--- /dev/null
+++ b/library/Icingadb/Common/ServiceLinks.php
@@ -0,0 +1,108 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Web\Url;
+
+abstract class ServiceLinks
+{
+ public static function acknowledge(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/acknowledge',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function addComment(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/add-comment',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function checkNow(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/check-now',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function scheduleCheck(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/schedule-check',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function comments(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/comments',
+ ['service.name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function downtimes(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/downtimes',
+ ['service.name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function history(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/history',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function removeAcknowledgement(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/remove-acknowledgement',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function scheduleDowntime(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/schedule-downtime',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function sendCustomNotification(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/send-custom-notification',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function processCheckresult(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/process-checkresult',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function toggleFeatures(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/toggle-features',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+}
diff --git a/library/Icingadb/Common/ServiceStates.php b/library/Icingadb/Common/ServiceStates.php
new file mode 100644
index 0000000..526f95e
--- /dev/null
+++ b/library/Icingadb/Common/ServiceStates.php
@@ -0,0 +1,129 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+/**
+ * Collection of possible service states.
+ */
+class ServiceStates
+{
+ const OK = 0;
+
+ const WARNING = 1;
+
+ const CRITICAL = 2;
+
+ const UNKNOWN = 3;
+
+ const PENDING = 99;
+
+ /**
+ * Get the integer value of the given textual service state
+ *
+ * @param string $state
+ *
+ * @return int
+ *
+ * @throws \InvalidArgumentException If the given service state is invalid, i.e. not known
+ */
+ public static function int(string $state): int
+ {
+ switch (strtolower($state)) {
+ case 'ok':
+ $int = self::OK;
+ break;
+ case 'warning':
+ $int = self::WARNING;
+ break;
+ case 'critical':
+ $int = self::CRITICAL;
+ break;
+ case 'unknown':
+ $int = self::UNKNOWN;
+ break;
+ case 'pending':
+ $int = self::PENDING;
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid service state %d', $state));
+ }
+
+ return $int;
+ }
+
+ /**
+ * Get the textual representation of the passed service state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given service state is invalid, i.e. not known
+ */
+ public static function text(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::OK:
+ $text = 'ok';
+ break;
+ case $state === self::WARNING:
+ $text = 'warning';
+ break;
+ case $state === self::CRITICAL:
+ $text = 'critical';
+ break;
+ case $state === self::UNKNOWN:
+ $text = 'unknown';
+ break;
+ case $state === self::PENDING:
+ $text = 'pending';
+ break;
+ case $state === null:
+ $text = 'not-available';
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid service state %d', $state));
+ }
+
+ return $text;
+ }
+
+ /**
+ * Get the translated textual representation of the passed service state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given service state is invalid, i.e. not known
+ */
+ public static function translated(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::OK:
+ $text = t('ok');
+ break;
+ case $state === self::WARNING:
+ $text = t('warning');
+ break;
+ case $state === self::CRITICAL:
+ $text = t('critical');
+ break;
+ case $state === self::UNKNOWN:
+ $text = t('unknown');
+ break;
+ case $state === self::PENDING:
+ $text = t('pending');
+ break;
+ case $state === null:
+ $text = t('not available');
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid service state %d', $state));
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icingadb/Common/StateBadges.php b/library/Icingadb/Common/StateBadges.php
new file mode 100644
index 0000000..c9c5c89
--- /dev/null
+++ b/library/Icingadb/Common/StateBadges.php
@@ -0,0 +1,195 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Stdlib\BaseFilter;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+abstract class StateBadges extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ /** @var object $item */
+ protected $item;
+
+ /** @var string */
+ protected $type;
+
+ /** @var string Prefix */
+ protected $prefix;
+
+ /** @var Url Badge link */
+ protected $url;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'state-badges'];
+
+ /**
+ * Create a new widget for state badges
+ *
+ * @param object $item
+ */
+ public function __construct($item)
+ {
+ $this->item = $item;
+ $this->type = $this->getType();
+ $this->prefix = $this->getPrefix();
+ $this->url = $this->getBaseUrl();
+ }
+
+ /**
+ * Get the badge base URL
+ *
+ * @return Url
+ */
+ abstract protected function getBaseUrl(): Url;
+
+ /**
+ * Get the type of the items
+ *
+ * @return string
+ */
+ abstract protected function getType(): string;
+
+ /**
+ * Get the prefix for accessing state information
+ *
+ * @return string
+ */
+ abstract protected function getPrefix(): string;
+
+ /**
+ * Get the integer of the given state text
+ *
+ * @param string $state
+ *
+ * @return int
+ */
+ abstract protected function getStateInt(string $state): int;
+
+ /**
+ * Get the badge URL
+ *
+ * @return Url
+ */
+ public function getUrl(): Url
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the badge URL
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setUrl(Url $url): self
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Create a badge link
+ *
+ * @param mixed $content
+ * @param ?Filter\Rule $filter
+ *
+ * @return Link
+ */
+ public function createLink($content, Filter\Rule $filter = null): Link
+ {
+ $url = clone $this->getUrl();
+
+ $urlFilter = Filter::all();
+ if ($filter !== null) {
+ $urlFilter->add($filter);
+ }
+
+ if ($this->hasBaseFilter()) {
+ $urlFilter->add($this->getBaseFilter());
+ }
+
+ if (! $urlFilter->isEmpty()) {
+ $url->setFilter($urlFilter);
+ }
+
+ return new Link($content, $url);
+ }
+
+ /**
+ * Create a state bade
+ *
+ * @param string $state
+ *
+ * @return ?BaseHtmlElement
+ */
+ protected function createBadge(string $state)
+ {
+ $key = $this->prefix . "_{$state}";
+
+ if (isset($this->item->$key) && $this->item->$key) {
+ return Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$key, $state),
+ Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state))
+ ));
+ }
+
+ return null;
+ }
+
+ /**
+ * Create a state group
+ *
+ * @param string $state
+ *
+ * @return ?BaseHtmlElement
+ */
+ protected function createGroup(string $state)
+ {
+ $content = [];
+ $handledKey = $this->prefix . "_{$state}_handled";
+ $unhandledKey = $this->prefix . "_{$state}_unhandled";
+
+ if (isset($this->item->$unhandledKey) && $this->item->$unhandledKey) {
+ $content[] = Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$unhandledKey, $state),
+ Filter::all(
+ Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)),
+ Filter::equal($this->type . '.state.is_handled', 'n'),
+ Filter::equal($this->type . '.state.is_reachable', 'y')
+ )
+ ));
+ }
+
+ if (isset($this->item->$handledKey) && $this->item->$handledKey) {
+ $content[] = Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$handledKey, $state, true),
+ Filter::all(
+ Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)),
+ Filter::any(
+ Filter::equal($this->type . '.state.is_handled', 'y'),
+ Filter::equal($this->type . '.state.is_reachable', 'n')
+ )
+ )
+ ));
+ }
+
+ if (empty($content)) {
+ return null;
+ }
+
+ return Html::tag('li', Html::tag('ul', $content));
+ }
+}
diff --git a/library/Icingadb/Common/TicketLinks.php b/library/Icingadb/Common/TicketLinks.php
new file mode 100644
index 0000000..6cf7e76
--- /dev/null
+++ b/library/Icingadb/Common/TicketLinks.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Hook;
+
+trait TicketLinks
+{
+ /** @var bool */
+ protected $ticketLinkEnabled = false;
+
+ /**
+ * Set whether list items should render host and service links
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setTicketLinkEnabled(bool $state = true): self
+ {
+ $this->ticketLinkEnabled = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether list items should render host and service links
+ *
+ * @return bool
+ */
+ public function getTicketLinkEnabled(): bool
+ {
+ return $this->ticketLinkEnabled;
+ }
+
+ /**
+ * Get whether list items should render host and service links
+ *
+ * @return string
+ */
+ public function createTicketLinks($text): string
+ {
+ if (Hook::has('ticket')) {
+ $tickets = Hook::first('ticket');
+ }
+
+ if ($this->getTicketLinkEnabled() && isset($tickets)) {
+ /** @var \Icinga\Application\Hook\TicketHook $tickets */
+ return $tickets->createLinks($text);
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icingadb/Common/ViewMode.php b/library/Icingadb/Common/ViewMode.php
new file mode 100644
index 0000000..841f28b
--- /dev/null
+++ b/library/Icingadb/Common/ViewMode.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait ViewMode
+{
+ /** @var string */
+ protected $viewMode;
+
+ /**
+ * Get the view mode
+ *
+ * @return ?string
+ */
+ public function getViewMode()
+ {
+ return $this->viewMode;
+ }
+
+ /**
+ * Set the view mode
+ *
+ * @param string $viewMode
+ *
+ * @return $this
+ */
+ public function setViewMode(string $viewMode): self
+ {
+ $this->viewMode = $viewMode;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Compat/CompatHost.php b/library/Icingadb/Compat/CompatHost.php
new file mode 100644
index 0000000..12be31f
--- /dev/null
+++ b/library/Icingadb/Compat/CompatHost.php
@@ -0,0 +1,103 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Compat;
+
+use Icinga\Module\Monitoring\Object\Host;
+
+class CompatHost extends Host
+{
+ use CompatObject;
+
+ private $legacyColumns = [
+ 'host_action_url' => ['path' => ['action_url', 'action_url']],
+ 'action_url' => ['path' => ['action_url', 'action_url']],
+ 'host_address' => ['path' => ['address']],
+ 'host_address6' => ['path' => ['address6']],
+ 'host_alias' => ['path' => ['display_name']],
+ 'host_check_interval' => ['path' => ['check_interval']],
+ 'host_display_name' => ['path' => ['display_name']],
+ 'host_icon_image' => ['path' => ['icon_image', 'icon_image']],
+ 'host_icon_image_alt' => ['path' => ['icon_image_alt']],
+ 'host_name' => ['path' => ['name']],
+ 'host_notes' => ['path' => ['notes']],
+ 'host_notes_url' => ['path' => ['notes_url', 'notes_url']],
+ 'host_acknowledged' => [
+ 'path' => ['state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'host_acknowledgement_type' => [
+ 'path' => ['state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'host_active_checks_enabled' => [
+ 'path' => ['active_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_active_checks_enabled_changed' => null,
+ 'host_attempt' => null,
+ 'host_check_command' => ['path' => ['checkcommand_name']],
+ 'host_check_execution_time' => ['path' => ['state', 'execution_time']],
+ 'host_check_latency' => ['path' => ['state', 'latency']],
+ 'host_check_source' => ['path' => ['state', 'check_source']],
+ 'host_check_timeperiod' => ['path' => ['check_timeperiod_name']],
+ 'host_current_check_attempt' => ['path' => ['state', 'check_attempt']],
+ 'host_current_notification_number' => null,
+ 'host_event_handler_enabled' => [
+ 'path' => ['event_handler_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_event_handler_enabled_changed' => null,
+ 'host_flap_detection_enabled' => [
+ 'path' => ['flapping_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_flap_detection_enabled_changed' => null,
+ 'host_handled' => [
+ 'path' => ['state', 'is_handled'],
+ 'type' => 'bool'
+ ],
+ 'host_in_downtime' => [
+ 'path' => ['state', 'in_downtime'],
+ 'type' => 'bool'
+ ],
+ 'host_is_flapping' => [
+ 'path' => ['state', 'is_flapping'],
+ 'type' => 'bool'
+ ],
+ 'host_is_reachable' => [
+ 'path' => ['state', 'is_reachable'],
+ 'type' => 'bool'
+ ],
+ 'host_last_check' => ['path' => ['state', 'last_update']],
+ 'host_last_notification' => null,
+ 'host_last_state_change' => ['path' => ['state', 'last_state_change']],
+ 'host_long_output' => ['path' => ['state', 'long_output']],
+ 'host_max_check_attempts' => ['path' => ['max_check_attempts']],
+ 'host_next_check' => ['path' => ['state', 'next_check']],
+ 'host_next_update' => ['path' => ['state', 'next_update']],
+ 'host_notifications_enabled' => [
+ 'path' => ['notifications_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_notifications_enabled_changed' => null,
+ 'host_obsessing' => null,
+ 'host_obsessing_changed' => null,
+ 'host_output' => ['path' => ['state', 'output']],
+ 'host_passive_checks_enabled' => [
+ 'path' => ['passive_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_passive_checks_enabled_changed' => null,
+ 'host_percent_state_change' => null,
+ 'host_perfdata' => ['path' => ['state', 'performance_data']],
+ 'host_process_perfdata' => [
+ 'path' => ['perfdata_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_state' => ['path' => ['state', 'soft_state']],
+ 'host_state_type' => ['path' => ['state', 'state_type']],
+ 'instance_name' => null
+ ];
+}
diff --git a/library/Icingadb/Compat/CompatObject.php b/library/Icingadb/Compat/CompatObject.php
new file mode 100644
index 0000000..6a30751
--- /dev/null
+++ b/library/Icingadb/Compat/CompatObject.php
@@ -0,0 +1,373 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Compat;
+
+use Icinga\Exception\NotImplementedError;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\Servicegroup;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use InvalidArgumentException;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use LogicException;
+
+use function ipl\Stdlib\get_php_type;
+
+trait CompatObject
+{
+ use Auth;
+ use Database;
+
+ /** @var array Non-obscured custom variables */
+ protected $rawCustomvars;
+
+ /** @var array Non-obscured host custom variables */
+ protected $rawHostCustomvars;
+
+ /** @var Model $object */
+ private $object;
+
+ public function __construct(Model $object)
+ {
+ $this->object = $object;
+ }
+
+ public static function fromModel(Model $object)
+ {
+ switch (true) {
+ case $object instanceof Host:
+ return new CompatHost($object);
+ case $object instanceof Service:
+ return new CompatService($object);
+ default:
+ throw new InvalidArgumentException(sprintf(
+ 'Host or Service Model instance expected, got "%s" instead.',
+ get_php_type($object)
+ ));
+ }
+ }
+
+ /**
+ * Get this object's name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return $this->object->name;
+ }
+
+ public function fetch(): bool
+ {
+ return true;
+ }
+
+ protected function fetchRawCustomvars(): self
+ {
+ if ($this->rawCustomvars !== null) {
+ return $this;
+ }
+
+ $vars = $this->object->customvar->execute();
+
+ $customVars = [];
+ foreach ($vars as $row) {
+ $customVars[$row->name] = $row->value;
+ }
+
+ $this->rawCustomvars = $customVars;
+
+ return $this;
+ }
+
+ protected function fetchRawHostCustomvars(): self
+ {
+ if ($this->rawHostCustomvars !== null) {
+ return $this;
+ }
+
+ $vars = $this->object->host->customvar->execute();
+
+ $customVars = [];
+ foreach ($vars as $row) {
+ $customVars[$row->name] = $row->value;
+ }
+
+ $this->rawHostCustomvars = $customVars;
+
+ return $this;
+ }
+
+ public function fetchComments()
+ {
+ $this->comments = [];
+
+ return $this;
+ }
+
+ public function fetchContactgroups()
+ {
+ $this->contactgroups = [];
+
+ return $this;
+ }
+
+ public function fetchContacts()
+ {
+ $this->contacts = [];
+
+ return $this;
+ }
+
+ public function fetchCustomvars(): self
+ {
+ if ($this->customvars !== null) {
+ return $this;
+ }
+
+ $this->customvars = (new CustomvarFlat())->unFlattenVars($this->object->customvar_flat);
+
+ return $this;
+ }
+
+ public function fetchHostVariables()
+ {
+ if (isset($this->hostVariables)) {
+ return $this;
+ }
+
+ $this->hostVariables = [];
+ foreach ($this->object->customvar as $customvar) {
+ $this->hostVariables[strtolower($customvar->name)] = json_decode($customvar->value);
+ }
+
+ return $this;
+ }
+
+ public function fetchServiceVariables()
+ {
+ if (isset($this->serviceVariables)) {
+ return $this;
+ }
+
+ $this->serviceVariables = [];
+ foreach ($this->object->customvar as $customvar) {
+ $this->serviceVariables[strtolower($customvar->name)] = json_decode($customvar->value);
+ }
+
+ return $this;
+ }
+
+ public function fetchDowntimes()
+ {
+ $this->downtimes = [];
+
+ return $this;
+ }
+
+ public function fetchEventhistory()
+ {
+ $this->eventhistory = [];
+
+ return $this;
+ }
+
+ public function fetchHostgroups()
+ {
+ if ($this->type === self::TYPE_HOST) {
+ $hostname = $this->object->name;
+ $hostgroupQuery = clone $this->object->hostgroup;
+ } else {
+ $hostname = $this->object->host->name;
+ $hostgroupQuery = clone $this->object->host->hostgroup;
+ }
+
+ $hostgroupQuery
+ ->columns(['name', 'display_name'])
+ ->filter(Filter::equal('host.name', $hostname));
+
+ /** @var Query $hostgroupQuery */
+ $this->hostgroups = [];
+ foreach ($hostgroupQuery as $hostgroup) {
+ $this->hostgroups[$hostgroup->name] = $hostgroup->display_name;
+ }
+
+ return $this;
+ }
+
+ public function fetchServicegroups()
+ {
+ if ($this->type === self::TYPE_HOST) {
+ $hostname = $this->object->name;
+ $query = Servicegroup::on($this->getDb());
+ } else {
+ $hostname = $this->object->host->name;
+ $query = (clone $this->object->servicegroup);
+ }
+
+ $query
+ ->columns(['name', 'display_name'])
+ ->filter(Filter::equal('host.name', $hostname));
+
+ if ($this->type === self::TYPE_SERVICE) {
+ $query->filter(Filter::equal('service.name', $this->object->name));
+ }
+
+ $this->servicegroups = [];
+ foreach ($query as $serviceGroup) {
+ $this->servicegroups[$serviceGroup->name] = $serviceGroup->display_name;
+ }
+
+ return $this;
+ }
+
+ public function fetchStats()
+ {
+ $query = ServicestateSummary::on($this->getDb());
+
+ if ($this->type === self::TYPE_HOST) {
+ $query->filter(Filter::equal('host.name', $this->object->name));
+ } else {
+ $query->filter(Filter::all(
+ Filter::equal('host.name', $this->object->host->name),
+ Filter::equal('service.name', $this->object->name)
+ ));
+ }
+
+ $result = $query->first();
+
+ $this->stats = (object) [
+ 'services_total' => $result->services_total,
+ 'services_ok' => $result->services_ok,
+ 'services_critical' => $result->services_critical_handled + $result->services_critical_unhandled,
+ 'services_critical_unhandled' => $result->services_critical_unhandled,
+ 'services_critical_handled' => $result->services_critical_handled,
+ 'services_warning' => $result->services_warning_handled + $result->services_warning_unhandled,
+ 'services_warning_unhandled' => $result->services_warning_unhandled,
+ 'services_warning_handled' => $result->services_warning_handled,
+ 'services_unknown' => $result->services_unknown_handled + $result->services_unknown_unhandled,
+ 'services_unknown_unhandled' => $result->services_unknown_unhandled,
+ 'services_unknown_handled' => $result->services_unknown_handled,
+ 'services_pending' => $result->services_pending
+ ];
+
+ return $this;
+ }
+
+ public function __get($name)
+ {
+ if (property_exists($this, $name)) {
+ if ($this->$name === null) {
+ $fetchMethod = 'fetch' . ucfirst($name);
+ $this->$fetchMethod();
+ }
+
+ return $this->$name;
+ }
+
+ if (preg_match('/^_(host|service)_(.+)/i', $name, $matches)) {
+ switch (strtolower($matches[1])) {
+ case $this->type:
+ $customvars = $this->fetchRawCustomvars()->rawCustomvars;
+ break;
+ case self::TYPE_HOST:
+ $customvars = $this->fetchRawHostCustomvars()->rawHostCustomvars;
+ break;
+ case self::TYPE_SERVICE:
+ throw new LogicException('Cannot fetch service custom variables for non-service objects');
+ }
+
+ $variableName = strtolower($matches[2]);
+ if (isset($customvars[$variableName])) {
+ return $customvars[$variableName];
+ }
+
+ return null; // Unknown custom variables MUST NOT throw an error
+ }
+
+ if (! array_key_exists($name, $this->legacyColumns) && ! $this->object->hasProperty($name)) {
+ if (isset($this->customvars[$name])) {
+ return $this->customvars[$name];
+ }
+
+ if (substr($name, 0, strlen($this->prefix)) !== $this->prefix) {
+ $name = $this->prefix . $name;
+ }
+ }
+
+ if (array_key_exists($name, $this->legacyColumns)) {
+ $opts = $this->legacyColumns[$name];
+ if ($opts === null) {
+ return null;
+ }
+
+ $path = $opts['path'];
+ $value = null;
+
+ if (! empty($path)) {
+ $value = $this->object;
+
+ do {
+ $col = array_shift($path);
+ $value = $value->$col;
+ } while (! empty($path) && $value !== null);
+ }
+
+ if (isset($opts['type'])) {
+ $method = 'get' . ucfirst($opts['type']) . 'Type';
+ $value = $this->$method($value);
+ }
+
+ return $value;
+ }
+
+ return $this->object->$name;
+ }
+
+ public function __isset($name)
+ {
+ if (property_exists($this, $name)) {
+ return isset($this->$name);
+ }
+
+ if (isset($this->legacyColumns[$name]) || isset($this->object->$name)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @throws NotImplementedError Don't use!
+ */
+ protected function getDataView()
+ {
+ throw new NotImplementedError('getDataView() is not supported');
+ }
+
+ /**
+ * Get the bool type of the given value as an int
+ *
+ * @param bool|string $value
+ *
+ * @return ?int
+ */
+ private function getBoolType($value)
+ {
+ switch ($value) {
+ case false:
+ return 0;
+ case true:
+ return 1;
+ case 'sticky':
+ return 2;
+ }
+ }
+}
diff --git a/library/Icingadb/Compat/CompatService.php b/library/Icingadb/Compat/CompatService.php
new file mode 100644
index 0000000..7fe599e
--- /dev/null
+++ b/library/Icingadb/Compat/CompatService.php
@@ -0,0 +1,156 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Compat;
+
+use Icinga\Module\Monitoring\Object\Service;
+
+class CompatService extends Service
+{
+ use CompatObject;
+
+ private $legacyColumns = [
+ 'instance_name' => null,
+ 'host_attempt' => null,
+ 'host_icon_image' => ['path' => ['host', 'icon_image', 'icon_image']],
+ 'host_icon_image_alt' => ['path' => ['host', 'icon_image_alt']],
+ 'host_acknowledged' => [
+ 'path' => ['host', 'state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'host_active_checks_enabled' => [
+ 'path' => ['host', 'active_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_address' => ['path' => ['host', 'address']],
+ 'host_address6' => ['path' => ['host', 'address6']],
+ 'host_alias' => ['path' => ['host', 'display_name']],
+ 'host_display_name' => ['path' => ['host', 'display_name']],
+ 'host_handled' => [
+ 'path' => ['host', 'state', 'is_handled'],
+ 'type' => 'bool'
+ ],
+ 'host_in_downtime' => [
+ 'path' => ['host', 'state', 'in_downtime'],
+ 'type' => 'bool'
+ ],
+ 'host_is_flapping' => [
+ 'path' => ['host', 'state', 'is_flapping'],
+ 'type' => 'bool'
+ ],
+ 'host_last_state_change' => ['path' => ['host', 'state', 'last_state_change']],
+ 'host_name' => ['path' => ['host', 'name']],
+ 'host_notifications_enabled' => [
+ 'path' => ['host', 'notifications_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_passive_checks_enabled' => [
+ 'path' => ['host', 'passive_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_state' => ['path' => ['host', 'state', 'soft_state']],
+ 'host_state_type' => ['path' => ['host', 'state', 'state_type']],
+ 'service_icon_image' => ['path' => ['icon_image', 'icon_image']],
+ 'service_icon_image_alt' => ['path' => ['icon_image_alt']],
+ 'service_acknowledged' => [
+ 'path' => ['state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'service_acknowledgement_type' => [
+ 'path' => ['state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'service_action_url' => ['path' => ['action_url', 'action_url']],
+ 'action_url' => ['path' => ['action_url', 'action_url']],
+ 'service_active_checks_enabled' => [
+ 'path' => ['active_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_active_checks_enabled_changed' => null,
+ 'service_attempt' => null,
+ 'service_check_command' => ['path' => ['checkcommand_name']],
+ 'service_check_execution_time' => ['path' => ['state', 'execution_time']],
+ 'service_check_interval' => ['path' => ['check_interval']],
+ 'service_check_latency' => ['path' => ['state', 'latency']],
+ 'service_check_source' => ['path' => ['state', 'check_source']],
+ 'service_check_timeperiod' => ['path' => ['check_timeperiod_name']],
+ 'service_current_notification_number' => null,
+ 'service_description' => ['path' => ['name']],
+ 'service_display_name' => ['path' => ['display_name']],
+ 'service_event_handler_enabled' => [
+ 'path' => ['event_handler_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_event_handler_enabled_changed' => null,
+ 'service_flap_detection_enabled' => [
+ 'path' => ['flapping_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_flap_detection_enabled_changed' => null,
+ 'service_handled' => [
+ 'path' => ['state', 'is_handled'],
+ 'type' => 'bool'
+ ],
+ 'service_in_downtime' => [
+ 'path' => ['state', 'in_downtime'],
+ 'type' => 'bool'
+ ],
+ 'service_is_flapping' => [
+ 'path' => ['state', 'is_flapping'],
+ 'type' => 'bool'
+ ],
+ 'service_is_reachable' => [
+ 'path' => ['state', 'is_reachable'],
+ 'type' => 'bool'
+ ],
+ 'service_last_check' => ['path' => ['state', 'last_update']],
+ 'service_last_notification' => null,
+ 'service_last_state_change' => ['path' => ['state', 'last_state_change']],
+ 'service_long_output' => ['path' => ['state', 'long_output']],
+ 'service_next_check' => ['path' => ['state', 'next_check']],
+ 'service_next_update' => ['path' => ['state', 'next_update']],
+ 'service_notes' => ['path' => ['notes']],
+ 'service_notes_url' => ['path' => ['notes_url', 'notes_url']],
+ 'service_notifications_enabled' => [
+ 'path' => ['notifications_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_notifications_enabled_changed' => null,
+ 'service_obsessing' => null,
+ 'service_obsessing_changed' => null,
+ 'service_output' => ['path' => ['state', 'output']],
+ 'service_passive_checks_enabled' => [
+ 'path' => ['passive_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_passive_checks_enabled_changed' => null,
+ 'service_percent_state_change' => null,
+ 'service_perfdata' => ['path' => ['state', 'performance_data']],
+ 'service_process_perfdata' => [
+ 'path' => ['perfdata_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_state' => ['path' => ['state', 'soft_state']],
+ 'service_state_type' => ['path' => ['state', 'state_type']]
+ ];
+
+ /**
+ * Get this service's host
+ *
+ * @return CompatHost
+ */
+ public function getHost(): CompatHost
+ {
+ if ($this->host === null) {
+ $this->host = new CompatHost($this->object->host);
+ }
+
+ return $this->host;
+ }
+
+ protected function fetchHost()
+ {
+ $this->getHost();
+ }
+}
diff --git a/library/Icingadb/Compat/UrlMigrator.php b/library/Icingadb/Compat/UrlMigrator.php
new file mode 100644
index 0000000..47780be
--- /dev/null
+++ b/library/Icingadb/Compat/UrlMigrator.php
@@ -0,0 +1,1353 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Compat;
+
+use Icinga\Web\UrlParams;
+use InvalidArgumentException;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class UrlMigrator
+{
+ const NO_YES = ['n', 'y'];
+ const USE_EXPR = 'use-expr';
+ const SORT_ONLY = 'sort-only';
+ const LOWER_EXPR = 'lower-expr';
+ const DROP = 'drop';
+
+ const SUPPORTED_PATHS = [
+ 'monitoring/list/hosts' => ['hosts', 'icingadb/hosts'],
+ 'monitoring/hosts/show' => ['multipleHosts', 'icingadb/hosts/details'],
+ 'monitoring/host/show' => ['host', 'icingadb/host'],
+ 'monitoring/host/services' => ['host', 'icingadb/host/services'],
+ 'monitoring/host/history' => ['host', 'icingadb/host/history'],
+ 'monitoring/list/services' => ['services', 'icingadb/services'],
+ 'monitoring/list/servicegrid' => ['servicegrid', 'icingadb/services/grid'],
+ 'monitoring/services/show' => ['multipleServices', 'icingadb/services/details'],
+ 'monitoring/service/show' => ['service', 'icingadb/service'],
+ 'monitoring/service/history' => ['service', 'icingadb/service/history'],
+ 'monitoring/list/hostgroups' => ['hostgroups', 'icingadb/hostgroups'],
+ 'monitoring/list/servicegroups' => ['servicegroups', 'icingadb/servicegroups'],
+ 'monitoring/list/contactgroups' => ['contactgroups', 'icingadb/usergroups'],
+ 'monitoring/list/contacts' => ['contacts', 'icingadb/users'],
+ 'monitoring/list/comments' => ['comments', 'icingadb/comments'],
+ 'monitoring/list/downtimes' => ['downtimes', 'icingadb/downtimes'],
+ 'monitoring/list/eventhistory' => ['history', 'icingadb/history'],
+ 'monitoring/list/notifications' => ['notificationHistory', 'icingadb/notifications'],
+ 'monitoring/health/info' => [null, 'icingadb/health'],
+ 'monitoring/health/stats' => [null, 'icingadb/health'],
+ 'monitoring/tactical' => ['services', 'icingadb/tactical']
+ ];
+
+ public static function isSupportedUrl(Url $url): bool
+ {
+ $supportedPaths = self::SUPPORTED_PATHS;
+ return isset($supportedPaths[ltrim($url->getPath(), '/')]);
+ }
+
+ public static function hasParamTransformer(string $name): bool
+ {
+ return method_exists(new self(), $name . 'Parameters');
+ }
+
+ public static function hasQueryTransformer(string $name): bool
+ {
+ return method_exists(new self(), $name . 'Columns');
+ }
+
+ public static function transformUrl(Url $url): Url
+ {
+ if (! self::isSupportedUrl($url)) {
+ throw new InvalidArgumentException(sprintf('Url path "%s" is not supported', $url->getPath()));
+ }
+
+ list($transformer, $dbRoute) = self::SUPPORTED_PATHS[ltrim($url->getPath(), '/')];
+
+ $url = clone $url;
+ $url->setPath($dbRoute);
+
+ if (! $url->getParams()->isEmpty()) {
+ [$params, $filter] = self::transformParams($url, $transformer);
+ $url->setParams($params);
+
+ if (! $filter->isEmpty()) {
+ $filter = QueryString::parse((string) $filter);
+ $filter = self::transformFilter($filter, $transformer);
+ $url->setFilter($filter ?: null);
+ }
+ }
+
+ return $url;
+ }
+
+ public static function transformParams(Url $url, string $transformerName = null): array
+ {
+ $transformer = new self();
+
+ $params = self::commonParameters();
+ $columns = self::commonColumns();
+
+ if ($transformerName !== null) {
+ if (! self::hasQueryTransformer($transformerName)) {
+ throw new InvalidArgumentException(sprintf('Transformer "%s" is not supported', $transformerName));
+ }
+
+ if (self::hasParamTransformer($transformerName)) {
+ $params = array_merge($params, $transformer->{$transformerName . 'Parameters'}());
+ }
+
+ $columns = array_merge($columns, $transformer->{$transformerName . 'Columns'}());
+ }
+
+ $columnRewriter = function ($column) use ($columns, $transformer) {
+ $rewritten = $transformer->rewrite(Filter::equal($column, 'bogus'), $columns);
+ if ($rewritten === false) {
+ return false;
+ } elseif ($rewritten instanceof Filter\Condition) {
+ return $rewritten->getColumn();
+ }
+
+ return $column;
+ };
+
+ $urlParams = $url->onlyWith(array_keys($params))->getParams();
+ $urlFilter = $url->without(array_keys($params))->getParams();
+
+ $newParams = new UrlParams();
+ foreach ($urlParams->toArray(false) as $name => $value) {
+ if (is_int($name)) {
+ $name = $value;
+ $value = true;
+ } else {
+ $value = rawurldecode($value);
+ }
+
+ $name = rawurldecode($name);
+
+ if (! isset($params[$name]) || $params[$name] === self::USE_EXPR) {
+ $newParams->add($name, $value);
+ } elseif ($params[$name] === self::DROP) {
+ // pass
+ } elseif (is_callable($params[$name])) {
+ $result = $params[$name]($value, $urlParams, $columnRewriter);
+ if ($result === false) {
+ continue;
+ } elseif (is_array($result)) {
+ [$name, $value] = $result;
+ } elseif ($result !== null) {
+ $value = $result;
+ }
+
+ $newParams->add($name, $value);
+ }
+ }
+
+ return [$newParams, $urlFilter];
+ }
+
+ /**
+ * Transform the given legacy filter
+ *
+ * @param Filter\Rule $filter
+ * @param string|null $queryTransformer
+ *
+ * @return Filter\Rule|false
+ */
+ public static function transformFilter(Filter\Rule $filter, string $queryTransformer = null)
+ {
+ $transformer = new self();
+
+ $columns = $transformer::commonColumns();
+ if ($queryTransformer !== null) {
+ if (! self::hasQueryTransformer($queryTransformer)) {
+ throw new InvalidArgumentException(sprintf('Transformer "%s" is not supported', $queryTransformer));
+ }
+
+ $columns = array_merge($columns, $transformer->{$queryTransformer . 'Columns'}());
+ }
+
+ $rewritten = $transformer->rewrite($filter, $columns);
+ return $rewritten === false ? false : ($rewritten instanceof Filter\Rule ? $rewritten : $filter);
+ }
+
+ /**
+ * Transform given legacy wildcard filters
+ *
+ * @param $filter Filter\Rule
+ *
+ * @return Filter\Chain|Filter\Condition
+ */
+ public static function transformLegacyWildcardFilter(Filter\Rule $filter)
+ {
+ if ($filter instanceof Filter\Chain) {
+ foreach ($filter as $child) {
+ $newChild = self::transformLegacyWildcardFilter($child);
+ if ($newChild !== $child) {
+ $filter->replace($child, $newChild);
+ }
+ }
+
+ return $filter;
+ } else {
+ /** @var Filter\Condition $filter */
+ return self::transformWildcardFilter($filter);
+ }
+ }
+
+ /**
+ * Rewrite the given filter and legacy columns
+ *
+ * @param Filter\Rule $filter
+ * @param array $legacyColumns
+ *
+ * @return ?mixed
+ */
+ protected function rewrite(Filter\Rule $filter, array $legacyColumns)
+ {
+ $rewritten = null;
+ if ($filter instanceof Filter\Condition) {
+ $column = $filter->getColumn();
+
+ $modelPath = null;
+ $exprRule = null;
+ if (isset($legacyColumns[$column])) {
+ if ($legacyColumns[$column] === self::DROP) {
+ return false;
+ } elseif (is_callable($legacyColumns[$column])) {
+ return $legacyColumns[$column]($filter);
+ } elseif (! is_array($legacyColumns[$column])) {
+ return null;
+ }
+
+ foreach ($legacyColumns[$column] as $modelPath => $exprRule) {
+ break;
+ }
+
+ $rewritten = $filter->setColumn($modelPath);
+
+ switch (true) {
+ case $exprRule === self::USE_EXPR:
+ break;
+ case $exprRule === self::LOWER_EXPR:
+ $filter->setValue(strtolower($filter->getValue()));
+ break;
+ case is_array($exprRule) && isset($exprRule[$filter->getValue()]):
+ $filter->setValue($exprRule[$filter->getValue()]);
+ break;
+ default:
+ $filter->setValue($exprRule);
+ }
+
+ $rewritten = self::transformWildcardFilter($rewritten);
+ } elseif (preg_match('/^_(host|service)_(.+)/i', $column, $groups)) {
+ $rewritten = $filter->setColumn($groups[1] . '.vars.' . $groups[2]);
+ $rewritten = self::transformWildcardFilter($rewritten);
+ }
+ } else {
+ /** @var Filter\Chain $filter */
+ foreach ($filter as $child) {
+ $retVal = $this->rewrite(
+ $child instanceof Filter\Condition ? clone $child : $child,
+ $legacyColumns
+ );
+ if ($retVal === false) {
+ $filter->remove($child);
+ } elseif ($retVal instanceof Filter\Rule) {
+ $filter->replace($child, $retVal);
+ }
+ }
+ }
+
+ return $rewritten;
+ }
+
+ private static function transformWildcardFilter(Filter\Condition $filter)
+ {
+ if (is_string($filter->getValue()) && strpos($filter->getValue(), '*') !== false) {
+ if ($filter instanceof Filter\Equal) {
+ return Filter::like($filter->getColumn(), $filter->getValue());
+ } elseif ($filter instanceof Filter\Unequal) {
+ return Filter::unlike($filter->getColumn(), $filter->getValue());
+ }
+ }
+
+ return $filter;
+ }
+
+ protected static function commonParameters(): array
+ {
+ return [
+ 'sort' => function ($value, $params, $rewriter) {
+ $value = $rewriter($value);
+ if ($params->has('dir')) {
+ return "{$value} {$params->get('dir')}";
+ }
+
+ return $value;
+ },
+ 'dir' => self::DROP,
+ 'limit' => self::USE_EXPR,
+ 'showCompact' => self::USE_EXPR,
+ 'showFullscreen' => self::USE_EXPR,
+ 'view' => function ($value) {
+ if ($value === 'compact') {
+ return ['showCompact', true];
+ }
+
+ return $value;
+ }
+ ];
+ }
+
+ protected static function commonColumns(): array
+ {
+ return [
+
+ // Filter columns
+ 'host' => [
+ 'host.name_ci' => self::USE_EXPR
+ ],
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'host_alias' => self::DROP,
+ 'hostgroup' => [
+ 'hostgroup.name_ci' => self::USE_EXPR
+ ],
+ 'hostgroup_alias' => [
+ 'hostgroup.display_name' => self::USE_EXPR
+ ],
+ 'service' => [
+ 'service.name_ci' => self::USE_EXPR
+ ],
+ 'service_display_name' => [
+ 'service.display_name' => self::USE_EXPR
+ ],
+ 'servicegroup' => [
+ 'servicegroup.name_ci' => self::USE_EXPR
+ ],
+ 'servicegroup_alias' => [
+ 'servicegroup.display_name' => self::USE_EXPR
+ ],
+
+ // Restriction columns
+ 'instance_name' => self::DROP,
+ 'host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'hostgroup_name' => [
+ 'hostgroup.name' => self::USE_EXPR
+ ],
+ 'service_description' => [
+ 'service.name' => self::USE_EXPR
+ ],
+ 'servicegroup_name' => [
+ 'servicegroup.name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function hostsParameters(): array
+ {
+ return [
+ 'addColumns' => function ($value, $params, $rewriter) {
+ $legacyColumns = array_filter(array_map('trim', explode(',', $value)));
+
+ $columns = [
+ 'host.state.soft_state',
+ 'host.state.last_state_change',
+ 'host.icon_image.icon_image',
+ 'host.display_name',
+ 'host.state.output',
+ 'host.state.performance_data',
+ 'host.state.is_problem'
+ ];
+ foreach ($legacyColumns as $column) {
+ $column = $rewriter($column);
+ if ($column !== false) {
+ $columns[] = $column;
+ }
+ }
+
+ return ['columns', implode(',', $columns)];
+ }
+ ];
+ }
+
+ protected static function hostsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'host_acknowledged' => [
+ 'host.state.is_acknowledged' => self::NO_YES
+ ],
+ 'host_acknowledgement_type' => [
+ 'host.state.is_acknowledged' => array_merge(self::NO_YES, ['sticky'])
+ ],
+ 'host_action_url' => [
+ 'host.action_url.action_url' => self::USE_EXPR
+ ],
+ 'host_active_checks_enabled' => [
+ 'host.active_checks_enabled' => self::NO_YES
+ ],
+ 'host_active_checks_enabled_changed' => self::DROP,
+ 'host_address' => [
+ 'host.address' => self::USE_EXPR
+ ],
+ 'host_address6' => [
+ 'host.address6' => self::USE_EXPR
+ ],
+ 'host_alias' => self::DROP,
+ 'host_check_command' => [
+ 'host.checkcommand_name' => self::USE_EXPR
+ ],
+ 'host_check_execution_time' => [
+ 'host.state.execution_time' => self::USE_EXPR
+ ],
+ 'host_check_latency' => [
+ 'host.state.latency' => self::USE_EXPR
+ ],
+ 'host_check_source' => [
+ 'host.state.check_source' => self::USE_EXPR
+ ],
+ 'host_check_timeperiod' => [
+ 'host.check_timeperiod_name' => self::USE_EXPR
+ ],
+ 'host_current_check_attempt' => [
+ 'host.state.check_attempt' => self::USE_EXPR
+ ],
+ 'host_current_notification_number' => self::DROP,
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'host_event_handler_enabled' => [
+ 'host.event_handler_enabled' => self::NO_YES
+ ],
+ 'host_event_handler_enabled_changed' => self::DROP,
+ 'host_flap_detection_enabled' => [
+ 'host.flapping_enabled' => self::NO_YES
+ ],
+ 'host_flap_detection_enabled_changed' => self::DROP,
+ 'host_handled' => [
+ 'host.state.is_handled' => self::NO_YES
+ ],
+ 'host_hard_state' => [
+ 'host.state.hard_state' => self::USE_EXPR
+ ],
+ 'host_in_downtime' => [
+ 'host.state.in_downtime' => self::NO_YES
+ ],
+ 'host_ipv4' => [
+ 'host.address_bin' => self::USE_EXPR
+ ],
+ 'host_is_flapping' => [
+ 'host.state.is_flapping' => self::NO_YES
+ ],
+ 'host_is_reachable' => [
+ 'host.state.is_reachable' => self::NO_YES
+ ],
+ 'host_last_check' => [
+ 'host.state.last_update' => self::USE_EXPR
+ ],
+ 'host_last_notification' => self::DROP,
+ 'host_last_state_change' => [
+ 'host.state.last_state_change' => self::USE_EXPR
+ ],
+ 'host_last_state_change_ts' => [
+ 'host.state.last_state_change' => self::USE_EXPR
+ ],
+ 'host_long_output' => [
+ 'host.state.long_output' => self::USE_EXPR
+ ],
+ 'host_max_check_attempts' => [
+ 'host.max_check_attempts' => self::USE_EXPR
+ ],
+ 'host_modified_host_attributes' => self::DROP,
+ 'host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'host_next_check' => [
+ 'host.state.next_check' => self::USE_EXPR
+ ],
+ 'host_notes_url' => [
+ 'host.notes_url.notes_url' => self::USE_EXPR
+ ],
+ 'host_notifications_enabled' => [
+ 'host.notifications_enabled' => self::NO_YES
+ ],
+ 'host_notifications_enabled_changed' => self::DROP,
+ 'host_obsessing' => self::DROP,
+ 'host_obsessing_changed' => self::DROP,
+ 'host_output' => [
+ 'host.state.output' => self::USE_EXPR
+ ],
+ 'host_passive_checks_enabled' => [
+ 'host.passive_checks_enabled' => self::NO_YES
+ ],
+ 'host_passive_checks_enabled_changed' => self::DROP,
+ 'host_percent_state_change' => self::DROP,
+ 'host_perfdata' => [
+ 'host.state.performance_data' => self::USE_EXPR
+ ],
+ 'host_problem' => [
+ 'host.state.is_problem' => self::NO_YES
+ ],
+ 'host_severity' => [
+ 'host.state.severity' => self::USE_EXPR
+ ],
+ 'host_state' => [
+ 'host.state.soft_state' => self::USE_EXPR
+ ],
+ 'host_state_type' => [
+ 'host.state.state_type' => ['soft', 'hard']
+ ],
+ 'host_unhandled' => [
+ 'host.state.is_handled' => array_reverse(self::NO_YES)
+ ],
+
+ // Filter columns
+ 'host_contact' => [
+ 'host.user.name' => self::USE_EXPR
+ ],
+ 'host_contactgroup' => [
+ 'host.usergroup.name' => self::USE_EXPR
+ ],
+
+ // Query columns the dataview doesn't include, added here because it's possible to filter for them anyway
+ 'host_check_interval' => self::DROP,
+ 'host_icon_image' => self::DROP,
+ 'host_icon_image_alt' => self::DROP,
+ 'host_notes' => self::DROP,
+ 'object_type' => self::DROP,
+ 'object_id' => self::DROP,
+ 'host_attempt' => self::DROP,
+ 'host_check_type' => self::DROP,
+ 'host_event_handler' => self::DROP,
+ 'host_failure_prediction_enabled' => self::DROP,
+ 'host_is_passive_checked' => self::DROP,
+ 'host_last_hard_state' => self::DROP,
+ 'host_last_hard_state_change' => self::DROP,
+ 'host_last_time_down' => self::DROP,
+ 'host_last_time_unreachable' => self::DROP,
+ 'host_last_time_up' => self::DROP,
+ 'host_next_notification' => self::DROP,
+ 'host_next_update' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ if ($filter->getValue() !== 'now') {
+ return false;
+ }
+
+ // Doesn't get dropped because there's a default dashlet using it..
+ // Though since this dashlet uses it to check for overdue hosts we'll
+ // replace it as next_update is volatile (only in redis up2date)
+ return Filter::equal('host.state.is_overdue', $filter instanceof Filter\LessThan ? 'y' : 'n');
+ },
+ 'host_no_more_notifications' => self::DROP,
+ 'host_normal_check_interval' => self::DROP,
+ 'host_problem_has_been_acknowledged' => self::DROP,
+ 'host_process_performance_data' => self::DROP,
+ 'host_retry_check_interval' => self::DROP,
+ 'host_scheduled_downtime_depth' => self::DROP,
+ 'host_status_update_time' => self::DROP,
+ 'problems' => self::DROP
+ ];
+ }
+
+ protected static function multipleHostsColumns(): array
+ {
+ return array_merge(
+ static::hostsColumns(),
+ [
+ 'host' => [
+ 'host.name' => self::USE_EXPR
+ ]
+ ]
+ );
+ }
+
+ protected static function hostColumns(): array
+ {
+ return [
+ 'host' => [
+ 'name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function servicesParameters(): array
+ {
+ return [
+ 'addColumns' => function ($value, $params, $rewriter) {
+ $legacyColumns = array_filter(array_map('trim', explode(',', $value)));
+
+ $columns = [
+ 'service.state.soft_state',
+ 'service.state.last_state_change',
+ 'service.icon_image.icon_image',
+ 'service.display_name',
+ 'service.host.display_name',
+ 'service.state.output',
+ 'service.state.performance_data',
+ 'service.state.is_problem'
+ ];
+ foreach ($legacyColumns as $column) {
+ $column = $rewriter($column);
+ if ($column !== false) {
+ $columns[] = $column;
+ }
+ }
+
+ return ['columns', implode(',', $columns)];
+ }
+ ];
+ }
+
+ protected static function servicesColumns(): array
+ {
+ return [
+ // Query columns
+ 'host_acknowledged' => [
+ 'host.state.is_acknowledged' => self::NO_YES
+ ],
+ 'host_action_url' => [
+ 'host.action_url.action_url' => self::USE_EXPR
+ ],
+ 'host_active_checks_enabled' => [
+ 'host.active_checks_enabled' => self::NO_YES
+ ],
+ 'host_address' => [
+ 'host.address' => self::USE_EXPR
+ ],
+ 'host_address6' => [
+ 'host.address6' => self::USE_EXPR
+ ],
+ 'host_alias' => self::DROP,
+ 'host_check_source' => [
+ 'host.state.check_source' => self::USE_EXPR
+ ],
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'host_handled' => [
+ 'host.state.is_handled' => self::NO_YES
+ ],
+ 'host_hard_state' => [
+ 'host.state.hard_state' => self::USE_EXPR
+ ],
+ 'host_in_downtime' => [
+ 'host.state.in_downtime' => self::NO_YES
+ ],
+ 'host_ipv4' => [
+ 'host.address_bin' => self::USE_EXPR
+ ],
+ 'host_is_flapping' => [
+ 'host.state.is_flapping' => self::NO_YES
+ ],
+ 'host_last_check' => [
+ 'host.state.last_update' => self::USE_EXPR
+ ],
+ 'host_last_hard_state' => [
+ 'host.state.previous_hard_state' => self::USE_EXPR
+ ],
+ 'host_last_hard_state_change' => self::DROP,
+ 'host_last_state_change' => [
+ 'host.state.last_state_change' => self::USE_EXPR
+ ],
+ 'host_last_time_down' => self::DROP,
+ 'host_last_time_unreachable' => self::DROP,
+ 'host_last_time_up' => self::DROP,
+ 'host_long_output' => [
+ 'host.state.long_output' => self::USE_EXPR
+ ],
+ 'host_modified_host_attributes' => self::DROP,
+ 'host_notes_url' => [
+ 'host.notes_url.notes_url' => self::USE_EXPR
+ ],
+ 'host_notifications_enabled' => [
+ 'host.notifications_enabled' => self::NO_YES
+ ],
+ 'host_output' => [
+ 'host.state.output' => self::USE_EXPR
+ ],
+ 'host_passive_checks_enabled' => [
+ 'host.passive_checks_enabled' => self::NO_YES
+ ],
+ 'host_perfdata' => [
+ 'host.state.performance_data' => self::USE_EXPR
+ ],
+ 'host_problem' => [
+ 'host.state.is_problem' => self::NO_YES
+ ],
+ 'host_severity' => [
+ 'host.state.severity' => self::USE_EXPR
+ ],
+ 'host_state' => [
+ 'host.state.soft_state' => self::USE_EXPR
+ ],
+ 'host_state_type' => [
+ 'host.state.state_type' => ['soft', 'hard']
+ ],
+ 'service_acknowledged' => [
+ 'service.state.is_acknowledged' => self::NO_YES
+ ],
+ 'service_acknowledgement_type' => [
+ 'service.state.is_acknowledged' => array_merge(self::NO_YES, ['sticky'])
+ ],
+ 'service_action_url' => [
+ 'service.action_url.action_url' => self::USE_EXPR
+ ],
+ 'service_active_checks_enabled' => [
+ 'service.active_checks_enabled' => self::NO_YES
+ ],
+ 'service_active_checks_enabled_changed' => self::DROP,
+ 'service_attempt' => [
+ 'service.state.check_attempt' => self::USE_EXPR
+ ],
+ 'service_check_command' => [
+ 'service.checkcommand_name' => self::USE_EXPR
+ ],
+ 'service_check_source' => [
+ 'service.state.check_source' => self::USE_EXPR
+ ],
+ 'service_check_timeperiod' => [
+ 'service.check_timeperiod_name' => self::USE_EXPR
+ ],
+ 'service_current_check_attempt' => [
+ 'service.state.check_attempt' => self::USE_EXPR
+ ],
+ 'service_current_notification_number' => self::DROP,
+ 'service_display_name' => [
+ 'service.display_name' => self::USE_EXPR
+ ],
+ 'service_event_handler_enabled' => [
+ 'service.event_handler_enabled' => self::NO_YES
+ ],
+ 'service_event_handler_enabled_changed' => self::DROP,
+ 'service_flap_detection_enabled' => [
+ 'service.flapping_enabled' => self::NO_YES
+ ],
+ 'service_flap_detection_enabled_changed' => self::DROP,
+ 'service_handled' => [
+ 'service.state.is_handled' => self::NO_YES
+ ],
+ 'service_hard_state' => [
+ 'service.state.hard_state' => self::USE_EXPR
+ ],
+ 'service_host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'service_in_downtime' => [
+ 'service.state.in_downtime' => self::NO_YES
+ ],
+ 'service_is_flapping' => [
+ 'service.state.is_flapping' => self::NO_YES
+ ],
+ 'service_is_reachable' => [
+ 'service.state.is_reachable' => self::NO_YES
+ ],
+ 'service_last_check' => [
+ 'service.state.last_update' => self::USE_EXPR
+ ],
+ 'service_last_hard_state' => [
+ 'service.state.previous_hard_state' => self::USE_EXPR
+ ],
+ 'service_last_hard_state_change' => self::DROP,
+ 'service_last_notification' => self::DROP,
+ 'service_last_state_change' => [
+ 'service.state.last_state_change' => self::USE_EXPR
+ ],
+ 'service_last_state_change_ts' => [
+ 'service.state.last_state_change' => self::USE_EXPR
+ ],
+ 'service_last_time_critical' => self::DROP,
+ 'service_last_time_ok' => self::DROP,
+ 'service_last_time_unknown' => self::DROP,
+ 'service_last_time_warning' => self::DROP,
+ 'service_long_output' => [
+ 'service.state.long_output' => self::USE_EXPR
+ ],
+ 'service_max_check_attempts' => [
+ 'service.max_check_attempts' => self::USE_EXPR
+ ],
+ 'service_modified_service_attributes' => self::DROP,
+ 'service_next_check' => [
+ 'service.state.next_check' => self::USE_EXPR
+ ],
+ 'service_notes' => [
+ 'service.notes' => self::USE_EXPR
+ ],
+ 'service_notes_url' => [
+ 'service.notes_url.notes_url' => self::USE_EXPR
+ ],
+ 'service_notifications_enabled' => [
+ 'service.notifications_enabled' => self::NO_YES
+ ],
+ 'service_notifications_enabled_changed' => self::DROP,
+ 'service_obsessing' => self::DROP,
+ 'service_obsessing_changed' => self::DROP,
+ 'service_output' => [
+ 'service.state.output' => self::USE_EXPR
+ ],
+ 'service_passive_checks_enabled' => [
+ 'service.passive_checks_enabled' => self::USE_EXPR
+ ],
+ 'service_passive_checks_enabled_changed' => self::DROP,
+ 'service_perfdata' => [
+ 'service.state.performance_data' => self::USE_EXPR
+ ],
+ 'service_problem' => [
+ 'service.state.is_problem' => self::NO_YES
+ ],
+ 'service_severity' => [
+ 'service.state.severity' => self::USE_EXPR
+ ],
+ 'service_state' => [
+ 'service.state.soft_state' => self::USE_EXPR
+ ],
+ 'service_state_type' => [
+ 'service.state.state_type' => ['soft', 'hard']
+ ],
+ 'service_unhandled' => [
+ 'service.state.is_handled' => array_reverse(self::NO_YES)
+ ],
+
+ // Filter columns
+ 'host_contact' => [
+ 'host.user.name' => self::USE_EXPR
+ ],
+ 'host_contactgroup' => [
+ 'host.usergroup.name' => self::USE_EXPR
+ ],
+ 'service_contact' => [
+ 'service.user.name' => self::USE_EXPR
+ ],
+ 'service_contactgroup' => [
+ 'service.usergroup.name' => self::USE_EXPR
+ ],
+ 'service_host' => [
+ 'host.name_ci' => self::USE_EXPR
+ ],
+
+ // Query columns the dataview doesn't include, added here because it's possible to filter for them anyway
+ 'host_icon_image' => self::DROP,
+ 'host_icon_image_alt' => self::DROP,
+ 'host_notes' => self::DROP,
+ 'host_acknowledgement_type' => self::DROP,
+ 'host_active_checks_enabled_changed' => self::DROP,
+ 'host_attempt' => self::DROP,
+ 'host_check_command' => self::DROP,
+ 'host_check_execution_time' => self::DROP,
+ 'host_check_latency' => self::DROP,
+ 'host_check_timeperiod_object_id' => self::DROP,
+ 'host_check_type' => self::DROP,
+ 'host_current_check_attempt' => self::DROP,
+ 'host_current_notification_number' => self::DROP,
+ 'host_event_handler' => self::DROP,
+ 'host_event_handler_enabled' => self::DROP,
+ 'host_event_handler_enabled_changed' => self::DROP,
+ 'host_failure_prediction_enabled' => self::DROP,
+ 'host_flap_detection_enabled' => self::DROP,
+ 'host_flap_detection_enabled_changed' => self::DROP,
+ 'host_is_reachable' => self::DROP,
+ 'host_last_notification' => self::DROP,
+ 'host_max_check_attempts' => self::DROP,
+ 'host_next_check' => self::DROP,
+ 'host_next_notification' => self::DROP,
+ 'host_no_more_notifications' => self::DROP,
+ 'host_normal_check_interval' => self::DROP,
+ 'host_notifications_enabled_changed' => self::DROP,
+ 'host_obsessing' => self::DROP,
+ 'host_obsessing_changed' => self::DROP,
+ 'host_passive_checks_enabled_changed' => self::DROP,
+ 'host_percent_state_change' => self::DROP,
+ 'host_problem_has_been_acknowledged' => self::DROP,
+ 'host_process_performance_data' => self::DROP,
+ 'host_retry_check_interval' => self::DROP,
+ 'host_scheduled_downtime_depth' => self::DROP,
+ 'host_status_update_time' => self::DROP,
+ 'host_unhandled' => self::DROP,
+ 'object_type' => self::DROP,
+ 'service_check_interval' => self::DROP,
+ 'service_icon_image' => self::DROP,
+ 'service_icon_image_alt' => self::DROP,
+ 'service_check_execution_time' => self::DROP,
+ 'service_check_latency' => self::DROP,
+ 'service_check_timeperiod_object_id' => self::DROP,
+ 'service_check_type' => self::DROP,
+ 'service_event_handler' => self::DROP,
+ 'service_failure_prediction_enabled' => self::DROP,
+ 'service_is_passive_checked' => self::DROP,
+ 'service_next_notification' => self::DROP,
+ 'service_next_update' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ if ($filter->getValue() !== 'now') {
+ return false;
+ }
+
+ // Doesn't get dropped because there's a default dashlet using it..
+ // Though since this dashlet uses it to check for overdue services we'll
+ // replace it as next_update is volatile (only in redis up2date)
+ return Filter::equal('service.state.is_overdue', $filter instanceof Filter\LessThan ? 'y' : 'n');
+ },
+ 'service_no_more_notifications' => self::DROP,
+ 'service_normal_check_interval' => self::DROP,
+ 'service_percent_state_change' => self::DROP,
+ 'service_problem_has_been_acknowledged' => self::DROP,
+ 'service_process_performance_data' => self::DROP,
+ 'service_retry_check_interval' => self::DROP,
+ 'service_scheduled_downtime_depth' => self::DROP,
+ 'service_status_update_time' => self::DROP,
+ 'problems' => self::DROP,
+ ];
+ }
+
+ protected static function servicegridColumns(): array
+ {
+ return array_merge(
+ static::servicesColumns(),
+ [
+ 'problems' => [
+ 'problems' => self::USE_EXPR
+ ]
+ ]
+ );
+ }
+
+ protected static function multipleServicesColumns(): array
+ {
+ return array_merge(
+ static::servicesColumns(),
+ [
+ 'host' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'service' => [
+ 'service.name' => self::USE_EXPR
+ ]
+ ]
+ );
+ }
+
+ protected static function serviceColumns(): array
+ {
+ return [
+ 'host' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'service' => [
+ 'name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function hostgroupsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'hostgroup_alias' => [
+ 'hostgroup.display_name' => self::USE_EXPR
+ ],
+ 'hosts_severity' => self::SORT_ONLY,
+ 'hosts_total' => self::SORT_ONLY,
+ 'services_total' => self::SORT_ONLY,
+
+ // Filter columns
+ 'host_contact' => [
+ 'host.user.name' => self::USE_EXPR
+ ],
+ 'host_contactgroup' => [
+ 'host.usergroup.name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function servicegroupsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'services_severity' => self::SORT_ONLY,
+ 'services_total' => self::SORT_ONLY,
+ 'servicegroup_alias' => [
+ 'servicegroup.display_name' => self::USE_EXPR
+ ],
+
+ // Filter columns
+ 'host_contact' => [
+ 'host.user.name' => self::USE_EXPR
+ ],
+ 'host_contactgroup' => [
+ 'host.usergroup.name' => self::USE_EXPR
+ ],
+ 'service_contact' => [
+ 'service.user.name' => self::USE_EXPR
+ ],
+ 'service_contactgroup' => [
+ 'service.usergroup.name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function contactgroupsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'contactgroup_name' => [
+ 'usergroup.name' => self::USE_EXPR
+ ],
+ 'contactgroup_alias' => [
+ 'usergroup.display_name' => self::USE_EXPR
+ ],
+ 'contact_count' => self::DROP,
+
+ // Filter columns
+ 'contactgroup' => [
+ 'usergroup.name_ci' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function contactsColumns(): array
+ {
+ $receivesStateNotifications = function ($state, $type = null) {
+ return function ($filter) use ($state, $type) {
+ /** @var Filter\Condition $filter */
+ $negate = $filter instanceof Filter\Unequal || $filter instanceof Filter\Unlike;
+ switch ($filter->getValue()) {
+ case '0':
+ $filter = Filter::any(
+ Filter::equal('user.notifications_enabled', 'n'),
+ Filter::unequal('user.states', $state)
+ );
+ if ($type !== null) {
+ $filter->add(Filter::unequal('user.types', $type));
+ }
+
+ break;
+ case '1':
+ $filter = Filter::all(
+ Filter::equal('user.notifications_enabled', 'y'),
+ Filter::equal('user.states', $state)
+ );
+ if ($type !== null) {
+ $filter->add(Filter::equal('user.types', $type));
+ }
+
+ break;
+ default:
+ return null;
+ }
+
+ if ($negate) {
+ $filter = Filter::none($filter);
+ }
+
+ return $filter;
+ };
+ };
+
+ return [
+
+ // Query columns
+ 'contact_object_id' => self::DROP,
+ 'contact_id' => [
+ 'user.id' => self::USE_EXPR
+ ],
+ 'contact_name' => [
+ 'user.name' => self::USE_EXPR
+ ],
+ 'contact_alias' => [
+ 'user.display_name' => self::USE_EXPR
+ ],
+ 'contact_email' => [
+ 'user.email' => self::USE_EXPR
+ ],
+ 'contact_pager' => [
+ 'user.pager' => self::USE_EXPR
+ ],
+ 'contact_has_host_notfications' => $receivesStateNotifications(['up', 'down']),
+ 'contact_has_service_notfications' => $receivesStateNotifications(['ok', 'warning', 'critical', 'unknown']),
+ 'contact_can_submit_commands' => self::DROP,
+ 'contact_notify_service_recovery' => $receivesStateNotifications(
+ ['ok', 'warning', 'critical', 'unknown'],
+ 'recovery'
+ ),
+ 'contact_notify_service_warning' => $receivesStateNotifications('warning'),
+ 'contact_notify_service_critical' => $receivesStateNotifications('critical'),
+ 'contact_notify_service_unknown' => $receivesStateNotifications('unknown'),
+ 'contact_notify_service_flapping' => $receivesStateNotifications(
+ ['ok', 'warning', 'critical', 'unknown'],
+ ['flapping_start', 'flapping_end']
+ ),
+ 'contact_notify_service_downtime' => $receivesStateNotifications(
+ ['ok', 'warning', 'critical', 'unknown'],
+ ['downtime_start', 'downtime_end', 'downtime_removed']
+ ),
+ 'contact_notify_host_recovery' => $receivesStateNotifications(['up', 'down'], 'recovery'),
+ 'contact_notify_host_down' => $receivesStateNotifications('down'),
+ 'contact_notify_host_unreachable' => self::DROP,
+ 'contact_notify_host_flapping' => $receivesStateNotifications(
+ ['up', 'down'],
+ ['flapping_start', 'flapping_end']
+ ),
+ 'contact_notify_host_downtime' => $receivesStateNotifications(
+ ['up', 'down'],
+ ['downtime_start', 'downtime_end', 'downtime_removed']
+ ),
+ 'contact_notify_host_timeperiod' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ $filter->setColumn('user.timeperiod.name_ci');
+ return Filter::all(
+ $filter,
+ Filter::equal('user.states', ['up', 'down'])
+ );
+ },
+ 'contact_notify_service_timeperiod' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ $filter->setColumn('user.timeperiod.name_ci');
+ return Filter::all(
+ $filter,
+ Filter::equal('user.states', ['ok', 'warning', 'critical', 'unknown'])
+ );
+ },
+
+ // Filter columns
+ 'contact' => [
+ 'user.name_ci' => self::USE_EXPR
+ ],
+ 'contactgroup' => [
+ 'usergroup.name_ci' => self::USE_EXPR
+ ],
+ 'contactgroup_name' => [
+ 'usergroup.name' => self::USE_EXPR
+ ],
+ 'contactgroup_alias' => [
+ 'usergroup.display_name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function commentsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'comment_author_name' => [
+ 'comment.author' => self::USE_EXPR
+ ],
+ 'comment_data' => [
+ 'comment.text' => self::USE_EXPR
+ ],
+ 'comment_expiration' => [
+ 'comment.expire_time' => self::USE_EXPR
+ ],
+ 'comment_internal_id' => self::DROP,
+ 'comment_is_persistent' => [
+ 'comment.is_persistent' => self::NO_YES
+ ],
+ 'comment_name' => [
+ 'comment.name' => self::USE_EXPR
+ ],
+ 'comment_timestamp' => [
+ 'comment.entry_time' => self::USE_EXPR
+ ],
+ 'comment_type' => [
+ 'comment.entry_type' => self::LOWER_EXPR
+ ],
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'object_type' => [
+ 'comment.object_type' => self::LOWER_EXPR
+ ],
+ 'service_display_name' => [
+ 'service.display_name' => self::USE_EXPR
+ ],
+ 'service_host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+
+ // Filter columns
+ 'comment_author' => [
+ 'comment.author' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function downtimesColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'downtime_author_name' => [
+ 'downtime.author' => self::USE_EXPR
+ ],
+ 'downtime_comment' => [
+ 'downtime.comment' => self::USE_EXPR
+ ],
+ 'downtime_duration' => [
+ 'downtime.flexible_duration' => self::USE_EXPR
+ ],
+ 'downtime_end' => [
+ 'downtime.end_time' => self::USE_EXPR
+ ],
+ 'downtime_entry_time' => [
+ 'downtime.entry_time' => self::USE_EXPR
+ ],
+ 'downtime_internal_id' => self::DROP,
+ 'downtime_is_fixed' => [
+ 'downtime.is_flexible' => array_reverse(self::NO_YES)
+ ],
+ 'downtime_is_flexible' => [
+ 'downtime.is_flexible' => self::NO_YES
+ ],
+ 'downtime_is_in_effect' => [
+ 'downtime.is_in_effect' => self::NO_YES
+ ],
+ 'downtime_name' => [
+ 'downtime.name' => self::USE_EXPR
+ ],
+ 'downtime_scheduled_end' => [
+ 'downtime.scheduled_end_time' => self::USE_EXPR
+ ],
+ 'downtime_scheduled_start' => [
+ 'downtime.scheduled_start_time' => self::USE_EXPR
+ ],
+ 'downtime_start' => [
+ 'downtime.start_time' => self::USE_EXPR
+ ],
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'host_state' => [
+ 'host.state.soft_state' => self::USE_EXPR
+ ],
+ 'object_type' => [
+ 'downtime.object_type' => self::LOWER_EXPR
+ ],
+ 'service_display_name' => [
+ 'service.display_name' => self::USE_EXPR
+ ],
+ 'service_host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'service_state' => [
+ 'service.state.soft_state' => self::USE_EXPR
+ ],
+
+ // Filter columns
+ 'downtime_author' => [
+ 'downtime.author' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function historyColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'id' => self::DROP,
+ 'object_type' => [
+ 'history.object_type' => self::LOWER_EXPR
+ ],
+ 'timestamp' => [
+ 'history.event_time' => self::USE_EXPR
+ ],
+ 'state' => [
+ 'history.state.soft_state' => self::USE_EXPR
+ ],
+ 'output' => [
+ 'history.state.output' => self::USE_EXPR
+ ],
+ 'type' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ $expr = strtolower($filter->getValue());
+
+ switch (true) {
+ // NotificationhistoryQuery
+ case substr($expr, 0, 13) === 'notification_':
+ $filter->setColumn('history.notification.type');
+ $filter->setValue([
+ 'notification_ack' => 'acknowledgement',
+ 'notification_flapping' => 'flapping_start',
+ 'notification_flapping_end' => 'flapping_end',
+ 'notification_dt_start' => 'downtime_start',
+ 'notification_dt_end' => 'downtime_end',
+ 'notification_custom' => 'custom',
+ 'notification_state' => ['problem', 'recovery']
+ ][$expr]);
+ return Filter::all($filter, Filter::equal('history.event_type', 'notification'));
+ // StatehistoryQuery
+ case in_array($expr, ['soft_state', 'hard_state'], true):
+ $filter->setColumn('history.state.state_type');
+ $filter->setValue(substr($expr, 0, 4));
+ return Filter::all($filter, Filter::equal('history.event_type', 'state_change'));
+ // DowntimestarthistoryQuery and DowntimeendhistoryQuery
+ case in_array($expr, ['dt_start', 'dt_end'], true):
+ $filter->setColumn('history.event_type');
+ $filter->setValue('downtime_' . substr($expr, 3));
+ return $filter;
+ // CommenthistoryQuery
+ case in_array($expr, ['comment', 'ack'], true):
+ $filter->setColumn('history.comment.entry_type');
+ $filter->setValue($expr);
+ return Filter::all($filter, Filter::equal('history.event_type', 'comment_add'));
+ // CommentdeletionhistoryQuery
+ case in_array($expr, ['comment_deleted', 'ack_deleted'], true):
+ $filter->setColumn('history.comment.entry_type');
+ $filter->setValue($expr);
+ return Filter::all($filter, Filter::equal('history.event_type', 'comment_remove'));
+ // FlappingstarthistoryQuery and CommenthistoryQuery
+ case in_array($expr, ['flapping', 'flapping_deleted'], true):
+ $filter->setColumn('history.event_type');
+ return $filter->setValue($expr === 'flapping' ? 'flapping_start' : 'flapping_end');
+ }
+ }
+ ];
+ }
+
+ protected static function notificationHistoryColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'notification_contact_name' => [
+ 'notification_history.user.name' => self::USE_EXPR
+ ],
+ 'notification_output' => [
+ 'notification_history.text' => self::USE_EXPR
+ ],
+ 'notification_reason' => [
+ 'notification_history.type' => [
+ 0 => ['problem', 'recovery'],
+ 1 => 'acknowledgement',
+ 2 => 'flapping_start',
+ 3 => 'flapping_end',
+ 5 => 'downtime_start',
+ 6 => 'downtime_end',
+ 7 => 'downtime_removed',
+ 8 => 'custom' // ido schema doc says it's `99`, icinga2 though uses `8`
+ ]
+ ],
+ 'notification_state' => [
+ 'notification_history.state' => self::USE_EXPR
+ ],
+ 'notification_timestamp' => [
+ 'notification_history.send_time' => self::USE_EXPR
+ ],
+ 'object_type' => [
+ 'notification_history.object_type' => self::LOWER_EXPR
+ ],
+ 'service_host_name' => [
+ 'host.name' => self::USE_EXPR
+ ]
+ ];
+ }
+}
diff --git a/library/Icingadb/Data/CsvResultSet.php b/library/Icingadb/Data/CsvResultSet.php
new file mode 100644
index 0000000..746a7e4
--- /dev/null
+++ b/library/Icingadb/Data/CsvResultSet.php
@@ -0,0 +1,85 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Data;
+
+use DateTime;
+use DateTimeZone;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+
+class CsvResultSet extends VolatileStateResults
+{
+ protected $isCacheDisabled = true;
+
+ /**
+ * @return array<string, ?string>
+ */
+ public function current(): array
+ {
+ return $this->extractKeysAndValues(parent::current());
+ }
+
+ protected function formatValue(string $key, $value): ?string
+ {
+ if (
+ $value
+ && (
+ $key === 'id'
+ || substr($key, -3) === '_id'
+ || substr($key, -3) === '.id'
+ || substr($key, -9) === '_checksum'
+ || substr($key, -4) === '_bin'
+ )
+ ) {
+ $value = bin2hex($value);
+ }
+
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ } elseif (is_string($value)) {
+ return '"' . str_replace('"', '""', $value) . '"';
+ } elseif (is_array($value)) {
+ return '"' . implode(',', $value) . '"';
+ } elseif ($value instanceof DateTime) {
+ return $value->setTimezone(new DateTimeZone('UTC'))
+ ->format('Y-m-d\TH:i:s.vP');
+ } else {
+ return $value;
+ }
+ }
+
+ protected function extractKeysAndValues(Model $model, string $path = ''): array
+ {
+ $keysAndValues = [];
+ foreach ($model as $key => $value) {
+ $keyPath = ($path ? $path . '.' : '') . $key;
+ if ($value instanceof Model) {
+ $keysAndValues += $this->extractKeysAndValues($value, $keyPath);
+ } else {
+ $keysAndValues[$keyPath] = $this->formatValue($key, $value);
+ }
+ }
+
+ return $keysAndValues;
+ }
+
+ public static function stream(Query $query): void
+ {
+ $query->setResultSetClass(__CLASS__);
+
+ foreach ($query as $i => $keysAndValues) {
+ if ($i === 0) {
+ echo implode(',', array_keys($keysAndValues));
+ }
+
+ echo "\r\n";
+
+ echo implode(',', array_values($keysAndValues));
+ }
+
+ exit;
+ }
+}
diff --git a/library/Icingadb/Data/JsonResultSet.php b/library/Icingadb/Data/JsonResultSet.php
new file mode 100644
index 0000000..73cd9ef
--- /dev/null
+++ b/library/Icingadb/Data/JsonResultSet.php
@@ -0,0 +1,80 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Data;
+
+use DateTime;
+use DateTimeZone;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Util\Json;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+
+class JsonResultSet extends VolatileStateResults
+{
+ protected $isCacheDisabled = true;
+
+ /**
+ * @return array<string, ?string>
+ */
+ public function current(): array
+ {
+ return $this->createObject(parent::current());
+ }
+
+ protected function formatValue(string $key, $value): ?string
+ {
+ if (
+ $value
+ && (
+ $key === 'id'
+ || substr($key, -3) === '_id'
+ || substr($key, -3) === '.id'
+ || substr($key, -9) === '_checksum'
+ || substr($key, -4) === '_bin'
+ )
+ ) {
+ $value = bin2hex($value);
+ }
+
+ if ($value instanceof DateTime) {
+ return $value->setTimezone(new DateTimeZone('UTC'))
+ ->format('Y-m-d\TH:i:s.vP');
+ }
+
+ return $value;
+ }
+
+ protected function createObject(Model $model): array
+ {
+ $keysAndValues = [];
+ foreach ($model as $key => $value) {
+ if ($value instanceof Model) {
+ $keysAndValues[$key] = $this->createObject($value);
+ } else {
+ $keysAndValues[$key] = $this->formatValue($key, $value);
+ }
+ }
+
+ return $keysAndValues;
+ }
+
+ public static function stream(Query $query): void
+ {
+ $query->setResultSetClass(__CLASS__);
+
+ echo '[';
+ foreach ($query as $i => $object) {
+ if ($i > 0) {
+ echo ",\n";
+ }
+
+ echo Json::sanitize($object);
+ }
+
+ echo ']';
+
+ exit;
+ }
+}
diff --git a/library/Icingadb/Data/PivotTable.php b/library/Icingadb/Data/PivotTable.php
new file mode 100644
index 0000000..1aee20c
--- /dev/null
+++ b/library/Icingadb/Data/PivotTable.php
@@ -0,0 +1,441 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Data;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Web;
+use ipl\Orm\Query;
+use ipl\Stdlib\Contract\Paginatable;
+use ipl\Stdlib\Filter;
+
+class PivotTable
+{
+ const SORT_ASC = 'asc';
+
+ /**
+ * The query to fetch as pivot table
+ *
+ * @var Query
+ */
+ protected $baseQuery;
+
+ /**
+ * X-axis pivot column
+ *
+ * @var string
+ */
+ protected $xAxisColumn;
+
+ /**
+ * Y-axis pivot column
+ *
+ * @var string
+ */
+ protected $yAxisColumn;
+
+ /**
+ * The filter being applied on the query for the x-axis
+ *
+ * @var Filter\Rule
+ */
+ protected $xAxisFilter;
+
+ /**
+ * The filter being applied on the query for the y-axis
+ *
+ * @var Filter\Rule
+ */
+ protected $yAxisFilter;
+
+ /**
+ * The query to fetch the leading x-axis rows and their headers
+ *
+ * @var Query
+ */
+ protected $xAxisQuery;
+
+ /**
+ * The query to fetch the leading y-axis rows and their headers
+ *
+ * @var Query
+ */
+ protected $yAxisQuery;
+
+ /**
+ * X-axis header column
+ *
+ * @var string|null
+ */
+ protected $xAxisHeader;
+
+ /**
+ * Y-axis header column
+ *
+ * @var string|null
+ */
+ protected $yAxisHeader;
+
+ /**
+ * Order by column and direction
+ *
+ * @var array
+ */
+ protected $order = [];
+
+ /**
+ * Grid columns as [Alias => Column name] pairs
+ *
+ * @var array
+ */
+ protected $gridcols = [];
+
+ /**
+ * Create a new pivot table
+ *
+ * @param Query $query The query to fetch as pivot table
+ * @param string $xAxisColumn X-axis pivot column
+ * @param string $yAxisColumn Y-axis pivot column
+ * @param array $gridcols Grid columns
+ */
+ public function __construct(Query $query, string $xAxisColumn, string $yAxisColumn, array $gridcols)
+ {
+ foreach ($query->getOrderBy() as $sort) {
+ $this->order[$sort[0]] = $sort[1];
+ }
+
+ $this->baseQuery = $query->columns($gridcols)->resetOrderBy();
+ $this->xAxisColumn = $xAxisColumn;
+ $this->yAxisColumn = $yAxisColumn;
+ $this->gridcols = $gridcols;
+ }
+
+ /**
+ * Set the filter to apply on the query for the x-axis
+ *
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function setXAxisFilter(Filter\Rule $filter = null): self
+ {
+ $this->xAxisFilter = $filter;
+ return $this;
+ }
+
+ /**
+ * Set the filter to apply on the query for the y-axis
+ *
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function setYAxisFilter(Filter\Rule $filter = null): self
+ {
+ $this->yAxisFilter = $filter;
+ return $this;
+ }
+
+ /**
+ * Get the x-axis header
+ *
+ * Defaults to {@link $xAxisColumn} in case no x-axis header has been set using {@link setXAxisHeader()}
+ *
+ * @return string
+ */
+ public function getXAxisHeader(): string
+ {
+ if ($this->xAxisHeader === null && $this->xAxisColumn === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->xAxisHeader !== null ? $this->xAxisHeader : $this->xAxisColumn;
+ }
+
+ /**
+ * Set the x-axis header
+ *
+ * @param string $xAxisHeader
+ *
+ * @return $this
+ */
+ public function setXAxisHeader(string $xAxisHeader): self
+ {
+ $this->xAxisHeader = $xAxisHeader;
+ return $this;
+ }
+
+ /**
+ * Get the y-axis header
+ *
+ * Defaults to {@link $yAxisColumn} in case no x-axis header has been set using {@link setYAxisHeader()}
+ *
+ * @return string
+ */
+ public function getYAxisHeader(): string
+ {
+ if ($this->yAxisHeader === null && $this->yAxisColumn === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->yAxisHeader !== null ? $this->yAxisHeader : $this->yAxisColumn;
+ }
+
+ /**
+ * Set the y-axis header
+ *
+ * @param string $yAxisHeader
+ *
+ * @return $this
+ */
+ public function setYAxisHeader(string $yAxisHeader): self
+ {
+ $this->yAxisHeader = $yAxisHeader;
+ return $this;
+ }
+
+ /**
+ * Return the value for the given request parameter
+ *
+ * @param string $axis The axis for which to return the parameter ('x' or 'y')
+ * @param string $param The parameter name to return
+ * @param int $default The default value to return
+ *
+ * @return int
+ */
+ protected function getPaginationParameter(string $axis, string $param, int $default = null): int
+ {
+ /** @var Web $app */
+ $app = Icinga::app();
+
+ $value = $app->getRequest()->getParam($param, '');
+ if (strpos($value, ',') > 0) {
+ $parts = explode(',', $value, 2);
+ return intval($parts[$axis === 'x' ? 0 : 1]);
+ }
+
+ return $default !== null ? $default : 0;
+ }
+
+ /**
+ * Query horizontal (x) axis
+ *
+ * @return Query
+ */
+ protected function queryXAxis(): Query
+ {
+ if ($this->xAxisQuery === null) {
+ $this->xAxisQuery = clone $this->baseQuery;
+ $xAxisHeader = $this->getXAxisHeader();
+ $table = $this->xAxisQuery->getModel()->getTableName();
+ $xCol = explode('.', $this->gridcols[$this->xAxisColumn]);
+ $columns = [
+ $this->xAxisColumn => $this->gridcols[$this->xAxisColumn],
+ $xAxisHeader => $this->gridcols[$xAxisHeader]
+ ];
+
+ // TODO: This shouldn't be required. Refactor this once ipl\Orm\Query has support for group by rules!
+ if ($xCol[0] !== $table) {
+ $groupCols = array_unique([
+ $this->xAxisColumn => $table . '_' . $this->gridcols[$this->xAxisColumn],
+ $xAxisHeader => $table . '_' . $this->gridcols[$xAxisHeader]
+ ]);
+ } else {
+ $groupCols = $columns;
+ }
+
+ $this->xAxisQuery->getSelectBase()->groupBy($groupCols);
+
+ if (count($columns) !== 2) {
+ $columns[] = $this->gridcols[$xAxisHeader];
+ }
+
+ $this->xAxisQuery->columns($columns);
+
+ if ($this->xAxisFilter !== null) {
+ $this->xAxisQuery->filter($this->xAxisFilter);
+ }
+
+ $this->xAxisQuery->orderBy(
+ $this->gridcols[$xAxisHeader],
+ isset($this->order[$this->gridcols[$xAxisHeader]]) ?
+ $this->order[$this->gridcols[$xAxisHeader]] : self::SORT_ASC
+ );
+ }
+
+ return $this->xAxisQuery;
+ }
+
+ /**
+ * Query vertical (y) axis
+ *
+ * @return Query
+ */
+ protected function queryYAxis(): Query
+ {
+ if ($this->yAxisQuery === null) {
+ $this->yAxisQuery = clone $this->baseQuery;
+ $yAxisHeader = $this->getYAxisHeader();
+ $table = $this->yAxisQuery->getModel()->getTableName();
+ $columns = [
+ $this->yAxisColumn => $this->gridcols[$this->yAxisColumn],
+ $yAxisHeader => $this->gridcols[$yAxisHeader]
+ ];
+ $yCol = explode('.', $this->gridcols[$this->yAxisColumn]);
+
+ // TODO: This shouldn't be required. Refactor this once ipl\Orm\Query has support for group by rules!
+ if ($yCol[0] !== $table) {
+ $groupCols = array_unique([
+ $this->yAxisColumn => $table . '_' . $this->gridcols[$this->yAxisColumn],
+ $yAxisHeader => $table . '_' . $this->gridcols[$yAxisHeader]
+ ]);
+ } else {
+ $groupCols = $columns;
+ }
+
+ $this->yAxisQuery->getSelectBase()->groupBy($groupCols);
+
+ if (count($columns) !== 2) {
+ $columns[] = $this->gridcols[$yAxisHeader];
+ }
+
+ $this->yAxisQuery->columns($columns);
+
+ if ($this->yAxisFilter !== null) {
+ $this->yAxisQuery->filter($this->yAxisFilter);
+ }
+
+ $this->yAxisQuery->orderBy(
+ $this->gridcols[$yAxisHeader],
+ isset($this->order[$this->gridcols[$yAxisHeader]]) ?
+ $this->order[$this->gridcols[$yAxisHeader]] : self::SORT_ASC
+ );
+ }
+
+ return $this->yAxisQuery;
+ }
+
+ /**
+ * Return a pagination adapter for the x-axis query
+ *
+ * $limit and $page are taken from the current request if not given.
+ *
+ * @param int $limit The maximum amount of entries to fetch
+ * @param int $page The page to set as current one
+ *
+ * @return Paginatable
+ */
+ public function paginateXAxis(int $limit = null, int $page = null): Paginatable
+ {
+ if ($limit === null || $page === null) {
+ if ($limit === null) {
+ $limit = $this->getPaginationParameter('x', 'limit', 20);
+ }
+
+ if ($page === null) {
+ $page = $this->getPaginationParameter('x', 'page', 1);
+ }
+ }
+
+ $query = $this->queryXAxis();
+
+ if ($limit !== 0) {
+ $query->limit($limit);
+ $query->offset($page > 0 ? ($page - 1) * $limit : 0);
+ }
+
+ return $query;
+ }
+
+ /**
+ * Return a Paginatable for the y-axis query
+ *
+ * $limit and $page are taken from the current request if not given.
+ *
+ * @param int $limit The maximum amount of entries to fetch
+ * @param int $page The page to set as current one
+ *
+ * @return Paginatable
+ */
+ public function paginateYAxis(int $limit = null, int $page = null): Paginatable
+ {
+ if ($limit === null || $page === null) {
+ if ($limit === null) {
+ $limit = $this->getPaginationParameter('y', 'limit', 20);
+ }
+
+ if ($page === null) {
+ $page = $this->getPaginationParameter('y', 'page', 1);
+ }
+ }
+ $query = $this->queryYAxis();
+
+ if ($limit !== 0) {
+ $query->limit($limit);
+ $query->offset($page > 0 ? ($page - 1) * $limit : 0);
+ }
+
+ return $query;
+ }
+
+ /**
+ * Return the pivot table as an array of pivot data and pivot header
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ if (
+ ($this->xAxisFilter === null && $this->yAxisFilter === null)
+ || ($this->xAxisFilter !== null && $this->yAxisFilter !== null)
+ ) {
+ $xAxis = $this->queryXAxis()->getDb()->fetchPairs($this->queryXAxis()->assembleSelect());
+ $xAxisKeys = array_keys($xAxis);
+ $yAxis = $this->queryYAxis()->getDb()->fetchPairs($this->queryYAxis()->assembleSelect());
+ $yAxisKeys = array_keys($yAxis);
+ } else {
+ if ($this->xAxisFilter !== null) {
+ $xAxis = $this->queryXAxis()->getDb()->fetchPairs($this->queryXAxis()->assembleSelect());
+ $xAxisKeys = array_keys($xAxis);
+ $yQuery = $this->queryYAxis();
+ $yQuery->filter(Filter::equal($this->gridcols[$this->xAxisColumn], $xAxisKeys));
+ $yAxis = $this->queryYAxis()->getDb()->fetchPairs($this->queryYAxis()->assembleSelect());
+ $yAxisKeys = array_keys($yAxis);
+ } else { // $this->yAxisFilter !== null
+ $yAxis = $this->queryYAxis()->getDb()->fetchPairs($this->queryYAxis()->assembleSelect());
+ $yAxisKeys = array_keys($yAxis);
+ $xQuery = $this->queryXAxis();
+ $xQuery->filter(Filter::equal($this->gridcols[$this->yAxisColumn], $yAxisKeys));
+ $xAxis = $this->queryXAxis()->getDb()->fetchPairs($this->queryXAxis()->assembleSelect());
+ $xAxisKeys = array_keys($yAxis);
+ }
+ }
+
+ $pivotData = [];
+ $pivotHeader = [
+ 'cols' => $xAxis,
+ 'rows' => $yAxis
+ ];
+
+ if (! empty($xAxis) && ! empty($yAxis)) {
+ $this->baseQuery->filter(Filter::equal($this->gridcols[$this->xAxisColumn], $xAxisKeys));
+ $this->baseQuery->filter(Filter::equal($this->gridcols[$this->yAxisColumn], $yAxisKeys));
+ foreach ($yAxisKeys as $yAxisKey) {
+ foreach ($xAxisKeys as $xAxisKey) {
+ $pivotData[$yAxisKey][$xAxisKey] = null;
+ }
+ }
+
+ foreach ($this->baseQuery as $row) {
+ $pivotData[$row->{$this->yAxisColumn}][$row->{$this->xAxisColumn}] = $row;
+ }
+ }
+
+ return [$pivotData, $pivotHeader];
+ }
+}
diff --git a/library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php b/library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php
new file mode 100644
index 0000000..9f869a4
--- /dev/null
+++ b/library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php
@@ -0,0 +1,83 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\ActionsHook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+use Icinga\Module\Icingadb\Hook\HostActionsHook;
+use Icinga\Module\Icingadb\Hook\ServiceActionsHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use InvalidArgumentException;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+use ipl\Web\Widget\Link;
+
+use function ipl\Stdlib\get_php_type;
+
+abstract class ObjectActionsHook
+{
+ use HookUtils;
+
+ /**
+ * Load all actions for the given object
+ *
+ * @param Host|Service $object
+ *
+ * @return HtmlElement
+ *
+ * @throws InvalidArgumentException If the given model is not supported
+ */
+ final public static function loadActions(Model $object): HtmlElement
+ {
+ switch (true) {
+ case $object instanceof Host:
+ $hookName = 'Icingadb\\HostActions';
+ break;
+ case $object instanceof Service:
+ $hookName = 'Icingadb\\ServiceActions';
+ break;
+ default:
+ throw new InvalidArgumentException(
+ sprintf('%s is not a supported object type', get_php_type($object))
+ );
+ }
+
+ $list = new HtmlElement('ul', Attributes::create(['class' => 'object-detail-actions']));
+
+ /** @var HostActionsHook|ServiceActionsHook $hook */
+ foreach (Hook::all($hookName) as $hook) {
+ try {
+ foreach ($hook->getActionsForObject($object) as $link) {
+ if (! $link instanceof Link) {
+ continue;
+ }
+
+ // It may be ValidHtml, but modules shouldn't be able to break our views.
+ // That's why it needs to be rendered instantly, as any error will then
+ // be caught here.
+ $renderedLink = (string) $link;
+ $moduleName = $hook->getModule()->getName();
+
+ $list->addHtml(new HtmlElement('li', Attributes::create([
+ 'class' => 'icinga-module module-' . $moduleName,
+ 'data-icinga-module' => $moduleName
+ ]), HtmlString::create($renderedLink)));
+ }
+ } catch (Exception $e) {
+ Logger::error("Failed to load object actions: %s\n%s", $e, $e->getTraceAsString());
+ $list->addHtml(new HtmlElement('li', null, Text::create(IcingaException::describe($e))));
+ }
+ }
+
+ return $list;
+ }
+}
diff --git a/library/Icingadb/Hook/Common/HookUtils.php b/library/Icingadb/Hook/Common/HookUtils.php
new file mode 100644
index 0000000..8778849
--- /dev/null
+++ b/library/Icingadb/Hook/Common/HookUtils.php
@@ -0,0 +1,39 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\Common;
+
+use Icinga\Application\ClassLoader;
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+
+trait HookUtils
+{
+ final public function __construct()
+ {
+ $this->init();
+ }
+
+ /**
+ * Initialize this hook
+ *
+ * Override this in your concrete implementation for any initialization at construction time.
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Get the module this hook belongs to
+ *
+ * @return Module
+ */
+ final public function getModule(): Module
+ {
+ $moduleName = ClassLoader::extractModuleName(static::class);
+
+ return Icinga::app()->getModuleManager()
+ ->getModule($moduleName);
+ }
+}
diff --git a/library/Icingadb/Hook/Common/TotalSlaReportUtils.php b/library/Icingadb/Hook/Common/TotalSlaReportUtils.php
new file mode 100644
index 0000000..1006056
--- /dev/null
+++ b/library/Icingadb/Hook/Common/TotalSlaReportUtils.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\Common;
+
+use Icinga\Module\Icingadb\ProvidedHook\Reporting\HostSlaReport;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Html\Html;
+use ipl\Web\Widget\EmptyState;
+
+use function ipl\I18n\t;
+
+trait TotalSlaReportUtils
+{
+ public function getHtml(Timerange $timerange, array $config = null)
+ {
+ $data = $this->getData($timerange, $config);
+ $count = $data->count();
+
+ if (! $count) {
+ return new EmptyState(t('No data found.'));
+ }
+
+ $threshold = (float) ($config['threshold'] ?? static::DEFAULT_THRESHOLD);
+
+ $tableRows = [];
+ $precision = $config['sla_precision'] ?? static::DEFAULT_REPORT_PRECISION;
+
+ // We only have one average
+ $average = $data->getAverages()[0];
+
+ if ($average < $threshold) {
+ $slaClass = 'nok';
+ } else {
+ $slaClass = 'ok';
+ }
+
+ $total = $this instanceof HostSlaReport
+ ? sprintf(t('Total (%d Hosts)'), $count)
+ : sprintf(t('Total (%d Services)'), $count);
+
+ $tableRows[] = Html::tag('tr', null, [
+ Html::tag('td', ['colspan' => count($data->getDimensions())], $total),
+ Html::tag('td', ['class' => "sla-column $slaClass"], round($average, $precision))
+ ]);
+
+ $table = Html::tag(
+ 'table',
+ ['class' => 'common-table sla-table'],
+ [Html::tag('tbody', null, $tableRows)]
+ );
+
+ return $table;
+ }
+}
diff --git a/library/Icingadb/Hook/CustomVarRendererHook.php b/library/Icingadb/Hook/CustomVarRendererHook.php
new file mode 100644
index 0000000..796de11
--- /dev/null
+++ b/library/Icingadb/Hook/CustomVarRendererHook.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Closure;
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+use ipl\Orm\Model;
+
+abstract class CustomVarRendererHook
+{
+ use HookUtils;
+
+ /**
+ * Prefetch the data the hook needs to render custom variables
+ *
+ * @param Model $object The object for which they'll be rendered
+ *
+ * @return bool Return true if the hook can render variables for the given object, false otherwise
+ */
+ abstract public function prefetchForObject(Model $object): bool;
+
+ /**
+ * Render the given variable name
+ *
+ * @param string $key
+ *
+ * @return ?mixed
+ */
+ abstract public function renderCustomVarKey(string $key);
+
+ /**
+ * Render the given variable value
+ *
+ * @param string $key
+ * @param mixed $value
+ *
+ * @return ?mixed
+ */
+ abstract public function renderCustomVarValue(string $key, $value);
+
+ /**
+ * Return a group name for the given variable name
+ *
+ * @param string $key
+ *
+ * @return ?string
+ */
+ abstract public function identifyCustomVarGroup(string $key): ?string;
+
+ /**
+ * Prepare available hooks to render custom variables of the given object
+ *
+ * @param Model $object
+ *
+ * @return Closure A callback ($key, $value) which returns an array [$newKey, $newValue, $group]
+ */
+ final public static function prepareForObject(Model $object): Closure
+ {
+ $hooks = [];
+ foreach (Hook::all('Icingadb/CustomVarRenderer') as $hook) {
+ /** @var self $hook */
+ try {
+ if ($hook->prefetchForObject($object)) {
+ $hooks[] = $hook;
+ }
+ } catch (Exception $e) {
+ Logger::error('Failed to load hook %s:', get_class($hook), $e);
+ }
+ }
+
+ return function (string $key, $value) use ($hooks) {
+ $newKey = $key;
+ $newValue = $value;
+ $group = null;
+ foreach ($hooks as $hook) {
+ /** @var self $hook */
+
+ try {
+ $renderedKey = $hook->renderCustomVarKey($key);
+ $renderedValue = $hook->renderCustomVarValue($key, $value);
+ $group = $hook->identifyCustomVarGroup($key);
+ } catch (Exception $e) {
+ Logger::error('Failed to use hook %s:', get_class($hook), $e);
+ continue;
+ }
+
+ if ($renderedKey !== null || $renderedValue !== null) {
+ $newKey = $renderedKey ?? $key;
+ $newValue = $renderedValue ?? $value;
+ break;
+ }
+ }
+
+ return [$newKey, $newValue, $group];
+ };
+ }
+}
diff --git a/library/Icingadb/Hook/EventDetailExtensionHook.php b/library/Icingadb/Hook/EventDetailExtensionHook.php
new file mode 100644
index 0000000..c348a0c
--- /dev/null
+++ b/library/Icingadb/Hook/EventDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\History;
+use ipl\Html\ValidHtml;
+
+abstract class EventDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given event
+ *
+ * @param History $event
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(History $event): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php
new file mode 100644
index 0000000..dfefdcd
--- /dev/null
+++ b/library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php
@@ -0,0 +1,146 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\ExtensionHook;
+
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+
+abstract class BaseExtensionHook
+{
+ use HookUtils;
+
+ /** @var int Used as default return value for {@see BaseExtensionHook::getLocation()} */
+ const IDENTIFY_LOCATION_BY_SECTION = -1;
+
+ /** @var string Output section, right at the top */
+ const OUTPUT_SECTION = 'output';
+
+ /** @var string Graph section, below output */
+ const GRAPH_SECTION = 'graph';
+
+ /** @var string Detail section, below graphs */
+ const DETAIL_SECTION = 'detail';
+
+ /** @var string Action section, below action and note urls */
+ const ACTION_SECTION = 'action';
+
+ /** @var string Problem section, below comments and downtimes */
+ const PROBLEM_SECTION = 'problem';
+
+ /** @var string Related section, below groups and notification recipients */
+ const RELATED_SECTION = 'related';
+
+ /** @var string State section, below check statistics and performance data */
+ const STATE_SECTION = 'state';
+
+ /** @var string Config section, below custom variables and feature toggles */
+ const CONFIG_SECTION = 'config';
+
+ /**
+ * Base locations for all known sections
+ *
+ * @var array<string, int>
+ */
+ const BASE_LOCATIONS = [
+ self::OUTPUT_SECTION => 1000,
+ self::GRAPH_SECTION => 1100,
+ self::DETAIL_SECTION => 1200,
+ self::ACTION_SECTION => 1300,
+ self::PROBLEM_SECTION => 1400,
+ self::RELATED_SECTION => 1500,
+ self::STATE_SECTION => 1600,
+ self::CONFIG_SECTION => 1700
+ ];
+
+ /** @var int This hook's location */
+ private $location = self::IDENTIFY_LOCATION_BY_SECTION;
+
+ /** @var string This hook's section */
+ private $section = self::DETAIL_SECTION;
+
+ /**
+ * Set this hook's location
+ *
+ * Note that setting the location explicitly may override other widgets using the same location. But beware that
+ * this applies to this hook's widget as well.
+ *
+ * Also, while the sections are guaranteed to always refer to the same general location, this guarantee is lost
+ * when setting a location explicitly. The core and base locations may change at any time and any explicitly set
+ * location will **not** adjust accordingly.
+ *
+ * @param int $location
+ *
+ * @return void
+ */
+ final public function setLocation(int $location)
+ {
+ $this->location = $location;
+ }
+
+ /**
+ * Get this hook's location
+ *
+ * @return int
+ */
+ final public function getLocation(): int
+ {
+ return $this->location;
+ }
+
+ /**
+ * Set this hook's section
+ *
+ * Sections are used to place widgets loosely in a general location. Using e.g. the `state` section this hook's
+ * widget will always appear after the check statistics and performance data widgets.
+ *
+ * @param string $section
+ *
+ * @return void
+ */
+ final public function setSection(string $section)
+ {
+ $this->section = $section;
+ }
+
+ /**
+ * Get this hook's section
+ *
+ * @return string
+ */
+ final public function getSection(): string
+ {
+ return $this->section;
+ }
+
+ /**
+ * Union both arrays and sort the result by key
+ *
+ * @param array $coreElements
+ * @param array $extensions
+ *
+ * @return array
+ */
+ final public static function injectExtensions(array $coreElements, array $extensions): array
+ {
+ $extensions += $coreElements;
+
+ uksort($extensions, function ($a, $b) {
+ if ($a < 1000 && $b >= 1000) {
+ $b -= 1000;
+ if (abs($a - $b) < 10 && abs($a % 100 - $b % 100) < 10) {
+ return -1;
+ }
+ } elseif ($b < 1000 && $a >= 1000) {
+ $a -= 1000;
+ if (abs($a - $b) < 10 && abs($a % 100 - $b % 100) < 10) {
+ return 1;
+ }
+ }
+
+ return $a < $b ? -1 : ($a > $b ? 1 : 0);
+ });
+
+ return $extensions;
+ }
+}
diff --git a/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
new file mode 100644
index 0000000..7f880e8
--- /dev/null
+++ b/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
@@ -0,0 +1,121 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\ExtensionHook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Hook\EventDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\HostDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\ServiceDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\UserDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\UsergroupDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use InvalidArgumentException;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Model;
+
+use function ipl\Stdlib\get_php_type;
+
+abstract class ObjectDetailExtensionHook extends BaseExtensionHook
+{
+ /**
+ * Load all extensions for the given object
+ *
+ * @param Host|Service|User|Usergroup|History $object
+ *
+ * @return array<int, ValidHtml>
+ *
+ * @throws InvalidArgumentException If the given model is not supported
+ */
+ final public static function loadExtensions(Model $object): array
+ {
+ switch (true) {
+ case $object instanceof Host:
+ $hookName = 'Icingadb\\HostDetailExtension';
+ break;
+ case $object instanceof Service:
+ $hookName = 'Icingadb\\ServiceDetailExtension';
+ break;
+ case $object instanceof User:
+ $hookName = 'Icingadb\\UserDetailExtension';
+ break;
+ case $object instanceof Usergroup:
+ $hookName = 'Icingadb\\UsergroupDetailExtension';
+ break;
+ case $object instanceof History:
+ $hookName = 'Icingadb\\EventDetailExtension';
+ break;
+ default:
+ throw new InvalidArgumentException(
+ sprintf('%s is not a supported object type', get_php_type($object))
+ );
+ }
+
+ $extensions = [];
+ $lastUsedLocations = [];
+
+ /**
+ * @var $hook HostDetailExtensionHook
+ * @var $hook ServiceDetailExtensionHook
+ * @var $hook UserDetailExtensionHook
+ * @var $hook UsergroupDetailExtensionHook
+ * @var $hook EventDetailExtensionHook
+ */
+ foreach (Hook::all($hookName) as $hook) {
+ $location = $hook->getLocation();
+ if ($location < 0) {
+ $location = null;
+ }
+
+ if ($location === null) {
+ $section = $hook->getSection();
+ if (! isset(self::BASE_LOCATIONS[$section])) {
+ Logger::error('Detail extension %s is using an invalid section: %s', get_class($hook), $section);
+ $section = self::DETAIL_SECTION;
+ }
+
+ if (isset($lastUsedLocations[$section])) {
+ $location = ++$lastUsedLocations[$section];
+ } else {
+ $location = self::BASE_LOCATIONS[$section];
+ $lastUsedLocations[$section] = $location;
+ }
+ }
+
+ try {
+ // It may be ValidHtml, but modules shouldn't be able to break our views.
+ // That's why it needs to be rendered instantly, as any error will then
+ // be caught here.
+ $extension = (string) $hook->getHtmlForObject(clone $object);
+
+ $moduleName = $hook->getModule()->getName();
+
+ $extensions[$location] = new HtmlElement(
+ 'div',
+ Attributes::create([
+ 'class' => 'icinga-module module-' . $moduleName,
+ 'data-icinga-module' => $moduleName
+ ]),
+ HtmlString::create($extension)
+ );
+ } catch (Exception $e) {
+ Logger::error("Failed to load detail extension: %s\n%s", $e, $e->getTraceAsString());
+ $extensions[$location] = Text::create(IcingaException::describe($e));
+ }
+ }
+
+ return $extensions;
+ }
+}
diff --git a/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php
new file mode 100644
index 0000000..cc49667
--- /dev/null
+++ b/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php
@@ -0,0 +1,101 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\ExtensionHook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Hook\HostsDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\ServicesDetailExtensionHook;
+use InvalidArgumentException;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+use ipl\Stdlib\BaseFilter;
+use ipl\Stdlib\Filter;
+
+abstract class ObjectsDetailExtensionHook extends BaseExtensionHook
+{
+ use BaseFilter;
+
+ /**
+ * Load all extensions for the given objects
+ *
+ * @param string $objectType
+ * @param Query $query
+ * @param Filter\Rule $baseFilter
+ *
+ * @return array<int, ValidHtml>
+ *
+ * @throws InvalidArgumentException If the given object type is not supported
+ */
+ final public static function loadExtensions(string $objectType, Query $query, Filter\Rule $baseFilter): array
+ {
+ switch ($objectType) {
+ case 'host':
+ $hookName = 'Icingadb\\HostsDetailExtension';
+ break;
+ case 'service':
+ $hookName = 'Icingadb\\ServicesDetailExtension';
+ break;
+ default:
+ throw new InvalidArgumentException(
+ sprintf('%s is not a supported object type', $objectType)
+ );
+ }
+
+ $extensions = [];
+ $lastUsedLocations = [];
+ /** @var HostsDetailExtensionHook|ServicesDetailExtensionHook $hook */
+ foreach (Hook::all($hookName) as $hook) {
+ $location = $hook->getLocation();
+ if ($location < 0) {
+ $location = null;
+ }
+
+ if ($location === null) {
+ $section = $hook->getSection();
+ if (! isset(self::BASE_LOCATIONS[$section])) {
+ Logger::error('Detail extension %s is using an invalid section: %s', get_class($hook), $section);
+ $section = self::DETAIL_SECTION;
+ }
+
+ if (isset($lastUsedLocations[$section])) {
+ $location = ++$lastUsedLocations[$section];
+ } else {
+ $location = self::BASE_LOCATIONS[$section];
+ $lastUsedLocations[$section] = $location;
+ }
+ }
+
+ try {
+ // It may be ValidHtml, but modules shouldn't be able to break our views.
+ // That's why it needs to be rendered instantly, as any error will then
+ // be caught here.
+ $extension = (string) $hook->setBaseFilter($baseFilter)->getHtmlForObjects(clone $query);
+
+ $moduleName = $hook->getModule()->getName();
+
+ $extensions[$location] = new HtmlElement(
+ 'div',
+ Attributes::create([
+ 'class' => 'icinga-module module-' . $moduleName,
+ 'data-icinga-module' => $moduleName
+ ]),
+ HtmlString::create($extension)
+ );
+ } catch (Exception $e) {
+ Logger::error("Failed to load details extension: %s\n%s", $e, $e->getTraceAsString());
+ $extensions[$location] = Text::create(IcingaException::describe($e));
+ }
+ }
+
+ return $extensions;
+ }
+}
diff --git a/library/Icingadb/Hook/HostActionsHook.php b/library/Icingadb/Hook/HostActionsHook.php
new file mode 100644
index 0000000..73c58f4
--- /dev/null
+++ b/library/Icingadb/Hook/HostActionsHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Web\Widget\Link;
+
+abstract class HostActionsHook extends ObjectActionsHook
+{
+ /**
+ * Assemble and return a list of HTML anchors for the given host
+ *
+ * @param Host $host
+ *
+ * @return Link[]
+ */
+ abstract public function getActionsForObject(Host $host): array;
+}
diff --git a/library/Icingadb/Hook/HostDetailExtensionHook.php b/library/Icingadb/Hook/HostDetailExtensionHook.php
new file mode 100644
index 0000000..a6e9ab0
--- /dev/null
+++ b/library/Icingadb/Hook/HostDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\ValidHtml;
+
+abstract class HostDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given host
+ *
+ * @param Host $host
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(Host $host): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/HostsDetailExtensionHook.php b/library/Icingadb/Hook/HostsDetailExtensionHook.php
new file mode 100644
index 0000000..79c091e
--- /dev/null
+++ b/library/Icingadb/Hook/HostsDetailExtensionHook.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+
+abstract class HostsDetailExtensionHook extends ObjectsDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given hosts
+ *
+ * The given query is already pre-filtered with the user's custom filter and restrictions. The base filter does
+ * only contain the user's custom filter, use this for e.g. subsidiary links.
+ *
+ * The query is also limited by default, use `$hosts->limit(null)` to clear that. But beware that this may yield
+ * a huge result set in case of a bulk selection.
+ *
+ * @param Query<Host> $hosts
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObjects(Query $hosts): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/IcingadbSupportHook.php b/library/Icingadb/Hook/IcingadbSupportHook.php
new file mode 100644
index 0000000..96cdd19
--- /dev/null
+++ b/library/Icingadb/Hook/IcingadbSupportHook.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+use Icinga\Web\Session;
+
+abstract class IcingadbSupportHook
+{
+ use HookUtils;
+
+ /** @var string key name of preference */
+ const PREFERENCE_NAME = 'icingadb.as_backend';
+
+ /**
+ * Return whether your module supports IcingaDB or not
+ *
+ * @return bool
+ */
+ public function supportsIcingaDb(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Whether icingadb is set as the preferred backend in preferences
+ *
+ * @return bool Return true if icingadb is set as backend, false otherwise
+ */
+ final public static function isIcingaDbSetAsPreferredBackend(): bool
+ {
+ return (bool) Session::getSession()
+ ->getNamespace('icingadb')
+ ->get(self::PREFERENCE_NAME, false);
+ }
+
+ /**
+ * Whether to use icingadb as the backend
+ *
+ * @return bool Returns true if monitoring module is accessible or icingadb is selected as backend, false otherwise.
+ */
+ final public static function useIcingaDbAsBackend(): bool
+ {
+ return ! Icinga::app()->getModuleManager()->hasEnabled('monitoring')
+ || ! Auth::getInstance()->hasPermission('module/monitoring')
+ || self::isIcingaDbSetAsPreferredBackend();
+ }
+}
diff --git a/library/Icingadb/Hook/PluginOutputHook.php b/library/Icingadb/Hook/PluginOutputHook.php
new file mode 100644
index 0000000..7c744ee
--- /dev/null
+++ b/library/Icingadb/Hook/PluginOutputHook.php
@@ -0,0 +1,63 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+
+abstract class PluginOutputHook
+{
+ use HookUtils;
+
+ /**
+ * Return whether the given command is supported or not
+ *
+ * @param string $commandName
+ *
+ * @return bool
+ */
+ abstract public function isSupportedCommand(string $commandName): bool;
+
+ /**
+ * Process the given plugin output based on the specified check command
+ *
+ * Try to process the output as efficient and fast as possible.
+ * Especially list view performance may suffer otherwise.
+ *
+ * @param string $output A host's or service's output
+ * @param string $commandName The name of the checkcommand that produced the output
+ * @param bool $enrichOutput Whether macros or other markup should be processed
+ *
+ * @return string
+ */
+ abstract public function render(string $output, string $commandName, bool $enrichOutput): string;
+
+ /**
+ * Let all hooks process the given plugin output based on the specified check command
+ *
+ * @param string $output
+ * @param string $commandName
+ * @param bool $enrichOutput
+ *
+ * @return string
+ */
+ final public static function processOutput(string $output, string $commandName, bool $enrichOutput): string
+ {
+ foreach (Hook::all('Icingadb\\PluginOutput') as $hook) {
+ /** @var self $hook */
+ try {
+ if ($hook->isSupportedCommand($commandName)) {
+ $output = $hook->render($output, $commandName, $enrichOutput);
+ }
+ } catch (Exception $e) {
+ Logger::error("Unable to process plugin output: %s\n%s", $e, $e->getTraceAsString());
+ }
+ }
+
+ return $output;
+ }
+}
diff --git a/library/Icingadb/Hook/ServiceActionsHook.php b/library/Icingadb/Hook/ServiceActionsHook.php
new file mode 100644
index 0000000..988cdb6
--- /dev/null
+++ b/library/Icingadb/Hook/ServiceActionsHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Web\Widget\Link;
+
+abstract class ServiceActionsHook extends ObjectActionsHook
+{
+ /**
+ * Assemble and return a list of HTML anchors for the given service
+ *
+ * @param Service $service
+ *
+ * @return Link[]
+ */
+ abstract public function getActionsForObject(Service $service): array;
+}
diff --git a/library/Icingadb/Hook/ServiceDetailExtensionHook.php b/library/Icingadb/Hook/ServiceDetailExtensionHook.php
new file mode 100644
index 0000000..5344620
--- /dev/null
+++ b/library/Icingadb/Hook/ServiceDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\ValidHtml;
+
+abstract class ServiceDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given service
+ *
+ * @param Service $service
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(Service $service): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/ServicesDetailExtensionHook.php b/library/Icingadb/Hook/ServicesDetailExtensionHook.php
new file mode 100644
index 0000000..35ba8d3
--- /dev/null
+++ b/library/Icingadb/Hook/ServicesDetailExtensionHook.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+
+abstract class ServicesDetailExtensionHook extends ObjectsDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given services
+ *
+ * The given query is already pre-filtered with the user's custom filter and restrictions. The base filter does
+ * only contain the user's custom filter, use this for e.g. subsidiary links.
+ *
+ * The query is also limited by default, use `$hosts->limit(null)` to clear that. But beware that this may yield
+ * a huge result set in case of a bulk selection.
+ *
+ * @param Query<Service> $services
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObjects(Query $services): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/TabHook.php b/library/Icingadb/Hook/TabHook.php
new file mode 100644
index 0000000..0c5b676
--- /dev/null
+++ b/library/Icingadb/Hook/TabHook.php
@@ -0,0 +1,82 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Model;
+
+abstract class TabHook
+{
+ use Auth;
+ use Database;
+ use HookUtils;
+
+ /**
+ * Get the tab's name
+ *
+ * The name is used to identify this hook later on. It must be unique.
+ * Multiple words in the name should be separated by dashes. (-)
+ *
+ * @return string
+ */
+ abstract public function getName(): string;
+
+ /**
+ * Get the tab's label
+ *
+ * The label is shown on the tab and in the browser's title.
+ *
+ * @return string
+ */
+ abstract public function getLabel(): string;
+
+ /**
+ * Get tab content for the given object
+ *
+ * @param Model $object
+ *
+ * @return ValidHtml[]
+ */
+ abstract public function getContent(Model $object): array;
+
+ /**
+ * Get tab controls for the given object
+ *
+ * @param Model $object
+ *
+ * @return ValidHtml[]
+ */
+ public function getControls(Model $object): array
+ {
+ return [];
+ }
+
+ /**
+ * Get tab footer for the given object
+ *
+ * @param Model $object
+ *
+ * @return ValidHtml[]
+ */
+ public function getFooter(Model $object): array
+ {
+ return [];
+ }
+
+ /**
+ * Get whether this tab should be shown
+ *
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public function shouldBeShown(Model $object): bool
+ {
+ return true;
+ }
+}
diff --git a/library/Icingadb/Hook/TabHook/HookActions.php b/library/Icingadb/Hook/TabHook/HookActions.php
new file mode 100644
index 0000000..d2801a5
--- /dev/null
+++ b/library/Icingadb/Hook/TabHook/HookActions.php
@@ -0,0 +1,148 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\TabHook;
+
+use Exception;
+use Generator;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Hook\TabHook;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Model;
+use ipl\Stdlib\Str;
+
+/**
+ * Trait HookActions
+ */
+trait HookActions
+{
+ /** @var Model The object to load tabs for */
+ protected $objectToLoadTabsFor;
+
+ /** @var TabHook[] Loaded tab hooks */
+ protected $tabHooks;
+
+ /**
+ * Get default control elements
+ *
+ * @return ValidHtml[]
+ */
+ abstract protected function getDefaultTabControls(): array;
+
+ public function __call($methodName, $args)
+ {
+ if (substr($methodName, -6) === 'Action') {
+ $hookName = substr($methodName, 0, -6);
+
+ $hooks = $this->loadTabHooks();
+ if (isset($hooks[$hookName])) {
+ $this->showTabHook($hooks[$hookName]);
+ return;
+ }
+ }
+
+ parent::__call($methodName, $args);
+ }
+
+ /**
+ * Register the object for which to load additional tabs
+ *
+ * @param Model $object
+ *
+ * @return void
+ */
+ protected function loadTabsForObject(Model $object)
+ {
+ $this->objectToLoadTabsFor = $object;
+ }
+
+ /**
+ * Load tab hooks
+ *
+ * @return array<string, TabHook>
+ */
+ protected function loadTabHooks(): array
+ {
+ if ($this->objectToLoadTabsFor === null) {
+ return [];
+ } elseif ($this->tabHooks !== null) {
+ return $this->tabHooks;
+ }
+
+ $this->tabHooks = [];
+ foreach (Hook::all('Icingadb\\Tab') as $hook) {
+ /** @var TabHook $hook */
+ try {
+ if ($hook->shouldBeShown($this->objectToLoadTabsFor)) {
+ $this->tabHooks[Str::camel($hook->getName())] = $hook;
+ }
+ } catch (Exception $e) {
+ Logger::error("Failed to load tab hook: %s\n%s", $e, $e->getTraceAsString());
+ }
+ }
+
+ return $this->tabHooks;
+ }
+
+ /**
+ * Load additional tabs
+ *
+ * @return Generator<string, array{label: string, url: string}>
+ */
+ protected function loadAdditionalTabs(): Generator
+ {
+ foreach ($this->loadTabHooks() as $hook) {
+ yield $hook->getName() => [
+ 'label' => $hook->getLabel(),
+ 'url' => 'icingadb/' . $this->getRequest()->getControllerName() . '/' . $hook->getName()
+ ];
+ }
+ }
+
+ /**
+ * Render the given tab hook
+ *
+ * @param TabHook $hook
+ *
+ * @return void
+ */
+ protected function showTabHook(TabHook $hook)
+ {
+ $moduleName = $hook->getModule()->getName();
+
+ foreach ($hook->getControls($this->objectToLoadTabsFor) as $control) {
+ $this->addControl($control);
+ }
+
+ if (! empty($this->controls->getContent())) {
+ $this->controls->addAttributes([
+ 'class' => ['icinga-module', 'module-' . $moduleName],
+ 'data-icinga-module' => $moduleName
+ ]);
+ } else {
+ foreach ($this->getDefaultTabControls() as $control) {
+ $this->addControl($control);
+ }
+ }
+
+ foreach ($hook->getContent($this->objectToLoadTabsFor) as $content) {
+ $this->addContent($content);
+ }
+
+ $this->content->addAttributes([
+ 'class' => ['icinga-module', 'module-' . $moduleName],
+ 'data-icinga-module' => $moduleName
+ ]);
+
+ foreach ($hook->getFooter($this->objectToLoadTabsFor) as $footer) {
+ $this->addFooter($footer);
+ }
+
+ $this->footer->addAttributes([
+ 'class' => ['icinga-module', 'module-' . $moduleName],
+ 'data-icinga-module' => $moduleName
+ ]);
+ }
+}
diff --git a/library/Icingadb/Hook/UserDetailExtensionHook.php b/library/Icingadb/Hook/UserDetailExtensionHook.php
new file mode 100644
index 0000000..bb1bf7e
--- /dev/null
+++ b/library/Icingadb/Hook/UserDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\User;
+use ipl\Html\ValidHtml;
+
+abstract class UserDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given user
+ *
+ * @param User $user
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(User $user): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/UsergroupDetailExtensionHook.php b/library/Icingadb/Hook/UsergroupDetailExtensionHook.php
new file mode 100644
index 0000000..da2264d
--- /dev/null
+++ b/library/Icingadb/Hook/UsergroupDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use ipl\Html\ValidHtml;
+
+abstract class UsergroupDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given usergroup
+ *
+ * @param Usergroup $usergroup
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(Usergroup $usergroup): ValidHtml;
+}
diff --git a/library/Icingadb/Model/AcknowledgementHistory.php b/library/Icingadb/Model/AcknowledgementHistory.php
new file mode 100644
index 0000000..549d2ff
--- /dev/null
+++ b/library/Icingadb/Model/AcknowledgementHistory.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `acknowledgement_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this, the query
+ * needs a `acknowledgement_history.service_id IS NULL OR acknowledgement_history_service.id IS NOT NULL` where.
+ */
+class AcknowledgementHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'acknowledgement_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'set_time',
+ 'clear_time',
+ 'author',
+ 'cleared_by',
+ 'comment',
+ 'expire_time',
+ 'is_sticky',
+ 'is_persistent'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'set_time' => t('Acknowledgement Set Time'),
+ 'clear_time' => t('Acknowledgement Clear Time'),
+ 'author' => t('Acknowledgement Author'),
+ 'cleared_by' => t('Acknowledgement Cleared By'),
+ 'comment' => t('Acknowledgement Comment'),
+ 'expire_time' => t('Acknowledgement Expire Time'),
+ 'is_sticky' => t('Acknowledgement Is Sticky'),
+ 'is_persistent' => t('Acknowledgement Is Persistent')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_sticky',
+ 'is_persistent'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'set_time',
+ 'clear_time',
+ 'expire_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id',
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('acknowledgement_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/ActionUrl.php b/library/Icingadb/Model/ActionUrl.php
new file mode 100644
index 0000000..e0b092e
--- /dev/null
+++ b/library/Icingadb/Model/ActionUrl.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ActionAndNoteUrl;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class ActionUrl extends Model
+{
+ public function getTableName()
+ {
+ return 'action_url';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'action_url',
+ 'environment_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'action_url' => t('Action Url'),
+ 'environment_id' => t('Environment Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ActionAndNoteUrl(['action_url']));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->hasMany('host', Host::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('action_url_id');
+ $relations->hasMany('service', Service::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('action_url_id');
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/ActionAndNoteUrl.php b/library/Icingadb/Model/Behavior/ActionAndNoteUrl.php
new file mode 100644
index 0000000..e8f6799
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/ActionAndNoteUrl.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+
+class ActionAndNoteUrl extends PropertyBehavior
+{
+ public function fromDb($value, $key, $_)
+ {
+ if (empty($value)) {
+ return [];
+ }
+
+ $links = [];
+ if (strpos($value, "' ") === false) {
+ $links[] = $value;
+ } else {
+ foreach (explode("' ", $value) as $url) {
+ $url = strpos($url, "'") === 0 ? substr($url, 1) : $url;
+ $url = strpos($url, "'") === strlen($url) - 1 ? substr($url, 0, strlen($url) - 1) : $url;
+ $links[] = $url;
+ }
+ }
+
+ return $links;
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if (empty($value) || ! is_array($value)) {
+ return $value;
+ }
+
+ if (count($value) === 1) {
+ return $value[0];
+ }
+
+ $links = '';
+ foreach ($value as $url) {
+ if (! empty($links)) {
+ $links .= ' ';
+ }
+
+ $links .= "'$url'";
+ }
+
+ return $links;
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/Bitmask.php b/library/Icingadb/Model/Behavior/Bitmask.php
new file mode 100644
index 0000000..f8d91f6
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/Bitmask.php
@@ -0,0 +1,83 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+use ipl\Orm\Contract\RewriteFilterBehavior;
+use ipl\Stdlib\Filter\Condition;
+
+/**
+ * Class Bitmask
+ *
+ * @method void __construct(array $properties) Pass property names as keys and their bitmap ([value => bit]) as value
+ */
+class Bitmask extends PropertyBehavior implements RewriteFilterBehavior
+{
+ public function fromDb($bits, $key, $context)
+ {
+ $values = [];
+ foreach ($context as $value => $bit) {
+ if ($bits & $bit) {
+ $values[] = $value;
+ }
+ }
+
+ return $values;
+ }
+
+ public function toDb($value, $key, $context)
+ {
+ if (! is_array($value)) {
+ if (is_int($value) || ctype_digit($value)) {
+ return $value;
+ }
+
+ return isset($context[$value]) ? $context[$value] : -1;
+ }
+
+ $bits = [];
+ $allBits = 0;
+ foreach ($value as $v) {
+ if (isset($context[$v])) {
+ $bits[] = $context[$v];
+ $allBits |= $context[$v];
+ } elseif (is_int($v) || ctype_digit($v)) {
+ $bits[] = $v;
+ $allBits |= $v;
+ }
+ }
+
+ $bits[] = $allBits;
+ return $bits;
+ }
+
+ public function rewriteCondition(Condition $condition, $relation = null)
+ {
+ $column = $condition->metaData()->get('columnName');
+ if (! isset($this->properties[$column])) {
+ return;
+ }
+
+ $values = $condition->getValue();
+ if (! is_array($values)) {
+ if (is_int($values) || ctype_digit($values)) {
+ return;
+ }
+
+ $values = [$values];
+ }
+
+ $bits = 0;
+ foreach ($values as $value) {
+ if (isset($this->properties[$column][$value])) {
+ $bits |= $this->properties[$column][$value];
+ } elseif (is_int($value) || ctype_digit($value)) {
+ $bits |= $value;
+ }
+ }
+
+ $condition->setColumn(sprintf('%s & %s', $condition->getColumn(), $bits));
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/BoolCast.php b/library/Icingadb/Model/Behavior/BoolCast.php
new file mode 100644
index 0000000..8ab01ae
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/BoolCast.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+
+class BoolCast extends PropertyBehavior
+{
+ public function fromDb($value, $key, $_)
+ {
+ switch ((string) $value) {
+ case 'y':
+ return true;
+ case 'n':
+ return false;
+ default:
+ return $value;
+ }
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if (is_string($value)) {
+ return $value;
+ }
+
+ return $value ? 'y' : 'n';
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/FlattenedObjectVars.php b/library/Icingadb/Model/Behavior/FlattenedObjectVars.php
new file mode 100644
index 0000000..b1c308a
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/FlattenedObjectVars.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use ipl\Orm\AliasedExpression;
+use ipl\Orm\ColumnDefinition;
+use ipl\Orm\Contract\QueryAwareBehavior;
+use ipl\Orm\Contract\RewriteColumnBehavior;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+
+class FlattenedObjectVars implements RewriteColumnBehavior, QueryAwareBehavior
+{
+ use Auth;
+
+ /** @var Query */
+ protected $query;
+
+ public function setQuery(Query $query)
+ {
+ $this->query = $query;
+
+ return $this;
+ }
+
+ public function rewriteCondition(Filter\Condition $condition, $relation = null)
+ {
+ $column = $condition->metaData()->get('columnName');
+ if ($column !== null) {
+ $relation = substr($relation, 0, -5) . 'customvar_flat.';
+ $nameFilter = Filter::like($relation . 'flatname', $column);
+ $class = get_class($condition);
+ $valueFilter = new $class($relation . 'flatvalue', $condition->getValue());
+
+ return Filter::all($nameFilter, $valueFilter);
+ }
+ }
+
+ public function rewriteColumn($column, $relation = null)
+ {
+ $subQuery = $this->query->createSubQuery(new CustomvarFlat(), $relation)
+ ->limit(1)
+ ->columns('flatvalue')
+ ->filter(Filter::equal('flatname', $column));
+
+ $this->applyRestrictions($subQuery);
+
+ $alias = $this->query->getDb()->quoteIdentifier([str_replace('.', '_', $relation) . "_$column"]);
+
+ list($select, $values) = $this->query->getDb()->getQueryBuilder()->assembleSelect($subQuery->assembleSelect());
+ return new AliasedExpression($alias, "($select)", null, ...$values);
+ }
+
+ public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void
+ {
+ $parts = explode('.', substr($relation, 0, -5));
+ $objectType = array_pop($parts);
+
+ $name = $def->getName();
+ if (substr($name, -3) === '[*]') {
+ // The suggestions also hide this from the label, so should this
+ $name = substr($name, 0, -3);
+ }
+
+ // Programmatically translated since the full definition is available in class ObjectSuggestions
+ $def->setLabel(sprintf(t(ucfirst($objectType) . ' %s', '..<customvar-name>'), $name));
+ }
+
+ public function isSelectableColumn(string $name): bool
+ {
+ return true;
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/ReRoute.php b/library/Icingadb/Model/Behavior/ReRoute.php
new file mode 100644
index 0000000..d054f00
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/ReRoute.php
@@ -0,0 +1,83 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use ipl\Orm\Contract\RewriteFilterBehavior;
+use ipl\Orm\Contract\RewritePathBehavior;
+use ipl\Stdlib\Filter;
+
+class ReRoute implements RewriteFilterBehavior, RewritePathBehavior
+{
+ protected $routes;
+
+ /**
+ * Tables with mixed object type entries for which servicegroup filters need to be resolved in multiple steps
+ *
+ * @var string[]
+ */
+ const MIXED_TYPE_RELATIONS = ['downtime', 'comment', 'history', 'notification_history'];
+
+ public function __construct(array $routes)
+ {
+ $this->routes = $routes;
+ }
+
+ public function getRoutes(): array
+ {
+ return $this->routes;
+ }
+
+ public function rewriteCondition(Filter\Condition $condition, $relation = null)
+ {
+ $remainingPath = $condition->metaData()->get('columnName', '');
+ if (strpos($remainingPath, '.') === false) {
+ return;
+ }
+
+ if (($path = $this->rewritePath($remainingPath, $relation)) !== null) {
+ $class = get_class($condition);
+ $filter = new $class($relation . $path, $condition->getValue());
+ if ($condition->metaData()->has('forceOptimization')) {
+ $filter->metaData()->set(
+ 'forceOptimization',
+ $condition->metaData()->get('forceOptimization')
+ );
+ }
+
+ if (
+ in_array(substr($relation, 0, -1), self::MIXED_TYPE_RELATIONS)
+ && substr($remainingPath, 0, 13) === 'servicegroup.'
+ ) {
+ $applyAll = Filter::all();
+ $applyAll->add(Filter::equal($relation . 'object_type', 'host'));
+
+ $orgFilter = clone $filter;
+ $orgFilter->setColumn($relation . 'host.' . $path);
+
+ $applyAll->add($orgFilter);
+
+ $filter = Filter::any($filter, $applyAll);
+ }
+
+ return $filter;
+ }
+ }
+
+ public function rewritePath(string $path, ?string $relation = null): ?string
+ {
+ $dot = strpos($path, '.');
+ if ($dot !== false) {
+ $routeName = substr($path, 0, $dot);
+ } else {
+ $routeName = $path;
+ }
+
+ if (isset($this->routes[$routeName])) {
+ return $this->routes[$routeName] . ($dot !== false ? substr($path, $dot) : '');
+ }
+
+ return null;
+ }
+}
diff --git a/library/Icingadb/Model/Checkcommand.php b/library/Icingadb/Model/Checkcommand.php
new file mode 100644
index 0000000..400a24b
--- /dev/null
+++ b/library/Icingadb/Model/Checkcommand.php
@@ -0,0 +1,86 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Checkcommand extends Model
+{
+ public function getTableName()
+ {
+ return 'checkcommand';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'command',
+ 'timeout'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'zone_id' => t('Zone Id'),
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Checkcommand Name Checksum'),
+ 'properties_checksum' => t('Checkcommand Properties Checksum'),
+ 'name' => t('Checkcommand Name'),
+ 'name_ci' => t('Checkcommand Name (CI)'),
+ 'command' => t('Checkcommand'),
+ 'timeout' => t('Checkcommand Timeout')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(CheckcommandCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(CheckcommandCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(CheckcommandCustomvar::class);
+
+ $relations->hasMany('argument', CheckcommandArgument::class);
+ $relations->hasMany('envvar', CheckcommandEnvvar::class);
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('service', Service::class);
+ }
+}
diff --git a/library/Icingadb/Model/CheckcommandArgument.php b/library/Icingadb/Model/CheckcommandArgument.php
new file mode 100644
index 0000000..3fb4ad5
--- /dev/null
+++ b/library/Icingadb/Model/CheckcommandArgument.php
@@ -0,0 +1,75 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class CheckcommandArgument extends Model
+{
+ public function getTableName()
+ {
+ return 'checkcommand_argument';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'checkcommand_id',
+ 'argument_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'argument_value',
+ 'argument_order',
+ 'description',
+ 'argument_key_override',
+ 'repeat_key',
+ 'required',
+ 'set_if',
+ 'skip_key'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'checkcommand_id' => t('Checkcommand Id'),
+ 'argument_key' => t('Checkcommand Argument Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Checkcommand Argument Properties Checksum'),
+ 'argument_value' => t('Checkcommand Argument Value'),
+ 'argument_order' => t('Checkcommand Argument Position'),
+ 'description' => t('Checkcommand Argument Description'),
+ 'argument_key_override' => t('Checkcommand Argument Actual Name'),
+ 'repeat_key' => t('Checkcommand Argument Repeated'),
+ 'required' => t('Checkcommand Argument Required'),
+ 'set_if' => t('Checkcommand Argument Condition'),
+ 'skip_key' => t('Checkcommand Argument Without Name')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'checkcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/CheckcommandCustomvar.php b/library/Icingadb/Model/CheckcommandCustomvar.php
new file mode 100644
index 0000000..0a834b5
--- /dev/null
+++ b/library/Icingadb/Model/CheckcommandCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class CheckcommandCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'checkcommand_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'checkcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'checkcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/CheckcommandEnvvar.php b/library/Icingadb/Model/CheckcommandEnvvar.php
new file mode 100644
index 0000000..517a45f
--- /dev/null
+++ b/library/Icingadb/Model/CheckcommandEnvvar.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class CheckcommandEnvvar extends Model
+{
+ public function getTableName()
+ {
+ return 'checkcommand_envvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'checkcommand_id',
+ 'envvar_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'envvar_value'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'checkcommand_id' => t('Checkcommand Id'),
+ 'envvar_key' => t('Checkcommand Envvar Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Checkcommand Properties Checksum'),
+ 'envvar_value' => t('Checkcommand Envvar Value')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'checkcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/Comment.php b/library/Icingadb/Model/Comment.php
new file mode 100644
index 0000000..bcdd5e0
--- /dev/null
+++ b/library/Icingadb/Model/Comment.php
@@ -0,0 +1,114 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Comment extends Model
+{
+ public function getTableName()
+ {
+ return 'comment';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'author',
+ 'text',
+ 'entry_type',
+ 'entry_time',
+ 'is_persistent',
+ 'is_sticky',
+ 'expire_time',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'name_checksum' => t('Comment Name Checksum'),
+ 'properties_checksum' => t('Comment Properties Checksum'),
+ 'name' => t('Comment Name'),
+ 'author' => t('Comment Author'),
+ 'text' => t('Comment Text'),
+ 'entry_type' => t('Comment Type'),
+ 'entry_time' => t('Comment Entry Time'),
+ 'is_persistent' => t('Comment Is Persistent'),
+ 'is_sticky' => t('Comment Is Sticky'),
+ 'expire_time' => t('Comment Expire Time'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['text'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'comment.entry_time desc';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_persistent',
+ 'is_sticky'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'entry_time',
+ 'expire_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'host_id',
+ 'service_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class)->setJoinType('LEFT');
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ $relations->belongsTo('zone', Zone::class);
+ }
+}
diff --git a/library/Icingadb/Model/CommentHistory.php b/library/Icingadb/Model/CommentHistory.php
new file mode 100644
index 0000000..5f03e68
--- /dev/null
+++ b/library/Icingadb/Model/CommentHistory.php
@@ -0,0 +1,107 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `comment_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this,
+ * the query needs a `comment_history.service_id IS NULL OR comment_history_service.id IS NOT NULL` where.
+ */
+class CommentHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'comment_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'comment_id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'entry_time',
+ 'author',
+ 'removed_by',
+ 'comment',
+ 'entry_type',
+ 'is_persistent',
+ 'is_sticky',
+ 'expire_time',
+ 'remove_time',
+ 'has_been_removed'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'entry_time' => t('Comment Entry Time'),
+ 'author' => t('Comment Author'),
+ 'removed_by' => t('Comment Removed By'),
+ 'comment' => t('Comment Text'),
+ 'entry_type' => t('Comment Entry Type'),
+ 'is_persistent' => t('Comment Is Persistent'),
+ 'is_sticky' => t('Comment Is Sticky'),
+ 'expire_time' => t('Comment Expire Time'),
+ 'remove_time' => t('Comment Remove Time'),
+ 'has_been_removed' => t('Comment Has Been Removed')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_persistent',
+ 'is_sticky',
+ 'has_been_removed'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'entry_time',
+ 'expire_time',
+ 'remove_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'comment_id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('comment_id')
+ ->setForeignKey('comment_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/Customvar.php b/library/Icingadb/Model/Customvar.php
new file mode 100644
index 0000000..e043229
--- /dev/null
+++ b/library/Icingadb/Model/Customvar.php
@@ -0,0 +1,72 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Customvar extends Model
+{
+ public function getTableName()
+ {
+ return 'customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'name',
+ 'value'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->belongsToMany('checkcommand', Checkcommand::class)
+ ->through(CheckcommandCustomvar::class);
+ $relations->belongsToMany('eventcommand', Eventcommand::class)
+ ->through(EventcommandCustomvar::class);
+ $relations->belongsToMany('host', Host::class)
+ ->through(HostCustomvar::class);
+ $relations->belongsToMany('hostgroup', Hostgroup::class)
+ ->through(HostgroupCustomvar::class);
+ $relations->belongsToMany('notification', Notification::class)
+ ->through(NotificationCustomvar::class);
+ $relations->belongsToMany('notificationcommand', Notificationcommand::class)
+ ->through(NotificationcommandCustomvar::class);
+ $relations->belongsToMany('service', Service::class)
+ ->through(ServiceCustomvar::class);
+ $relations->belongsToMany('servicegroup', Servicegroup::class)
+ ->through(ServicegroupCustomvar::class);
+ $relations->belongsToMany('timeperiod', Timeperiod::class)
+ ->through(TimeperiodCustomvar::class);
+ $relations->belongsToMany('user', User::class)
+ ->through(UserCustomvar::class);
+ $relations->belongsToMany('usergroup', Usergroup::class)
+ ->through(UsergroupCustomvar::class);
+
+ $relations->hasMany('customvar_flat', CustomvarFlat::class);
+ }
+}
diff --git a/library/Icingadb/Model/CustomvarFlat.php b/library/Icingadb/Model/CustomvarFlat.php
new file mode 100644
index 0000000..99d8aca
--- /dev/null
+++ b/library/Icingadb/Model/CustomvarFlat.php
@@ -0,0 +1,167 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\Relations;
+use Traversable;
+
+class CustomvarFlat extends Model
+{
+ public function getTableName()
+ {
+ return 'customvar_flat';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'customvar_id',
+ 'flatname_checksum',
+ 'flatname',
+ 'flatvalue'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'customvar_id',
+ 'flatname_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('customvar', Customvar::class);
+
+ $relations->belongsToMany('checkcommand', Checkcommand::class)
+ ->through(CheckcommandCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('eventcommand', Eventcommand::class)
+ ->through(EventcommandCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('host', Host::class)
+ ->through(HostCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('hostgroup', Hostgroup::class)
+ ->through(HostgroupCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('notification', Notification::class)
+ ->through(NotificationCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('notificationcommand', Notificationcommand::class)
+ ->through(NotificationcommandCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('service', Service::class)
+ ->through(ServiceCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('servicegroup', Servicegroup::class)
+ ->through(ServicegroupCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('timeperiod', Timeperiod::class)
+ ->through(TimeperiodCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('user', User::class)
+ ->through(UserCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('usergroup', Usergroup::class)
+ ->through(UsergroupCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ }
+
+ /**
+ * Restore flattened custom variables to their previous structure
+ *
+ * @param Traversable $flattenedVars
+ *
+ * @return array
+ */
+ public function unFlattenVars(Traversable $flattenedVars): array
+ {
+ $registerValue = function (&$data, $source, $path, $value) use (&$registerValue) {
+ $step = array_shift($path);
+
+ $isIndex = (bool) preg_match('/^\[(\d+)]$/', $step, $m);
+ if ($isIndex) {
+ $step = $m[1];
+ }
+
+ if ($source !== null) {
+ while (! isset($source[$step])) {
+ if ($isIndex) {
+ $step = sprintf('[%d]', $step);
+ $isIndex = false;
+ } else {
+ if (empty($path)) {
+ break;
+ }
+
+ $step = implode('.', [$step, array_shift($path)]);
+ }
+ }
+ }
+
+ if (! empty($path)) {
+ if (! isset($data[$step])) {
+ $data[$step] = [];
+ }
+
+ $registerValue($data[$step], $source[$step] ?? null, $path, $value);
+ } else {
+ // Since empty custom vars of type dictionaries and arrays have null values in customvar_flat table,
+ // we won't be able to render them as such. Therefore, we have to use the value of the `customvar`
+ // table if it's not null, otherwise the current value, which is a "null" string.
+ $data[$step] = $value === null && ($source[$step] ?? null) === [] ? $source[$step] : $value;
+ }
+ };
+
+ if ($flattenedVars instanceof Query) {
+ $flattenedVars->withColumns(['customvar.name', 'customvar.value']);
+ }
+
+ $vars = [];
+ foreach ($flattenedVars as $var) {
+ if (isset($var->customvar->name)) {
+ $var->customvar->value = json_decode($var->customvar->value, true);
+
+ $realName = $var->customvar->name;
+ $source = [$realName => $var->customvar->value];
+
+ $sourcePath = ltrim(substr($var->flatname, strlen($realName)), '.');
+ $path = array_merge(
+ [$realName],
+ $sourcePath
+ ? preg_split('/(?<=\w|])\.|(?<!^|\.)(?=\[)/', $sourcePath)
+ : []
+ );
+ } else {
+ $path = explode('.', $var->flatname);
+ $source = null;
+ }
+
+ $registerValue($vars, $source, $path, $var->flatvalue);
+
+ if (isset($var->customvar->name)) {
+ $var->customvar->name = null;
+ $var->customvar->value = null;
+ }
+ }
+
+ return $vars;
+ }
+}
diff --git a/library/Icingadb/Model/Downtime.php b/library/Icingadb/Model/Downtime.php
new file mode 100644
index 0000000..7f600e9
--- /dev/null
+++ b/library/Icingadb/Model/Downtime.php
@@ -0,0 +1,144 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Downtime extends Model
+{
+ public function getTableName()
+ {
+ return 'downtime';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'triggered_by_id',
+ 'parent_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'author',
+ 'comment',
+ 'entry_time',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'scheduled_duration',
+ 'is_flexible',
+ 'flexible_duration',
+ 'is_in_effect',
+ 'start_time',
+ 'end_time',
+ 'duration',
+ 'scheduled_by',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'triggered_by_id' => t('Triggered By Downtime Id'),
+ 'parent_id' => t('Parent Downtime Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'name_checksum' => t('Downtime Name Checksum'),
+ 'properties_checksum' => t('Downtime Properties Checksum'),
+ 'name' => t('Downtime Name'),
+ 'author' => t('Downtime Author'),
+ 'comment' => t('Downtime Comment'),
+ 'entry_time' => t('Downtime Entry Time'),
+ 'scheduled_start_time' => t('Downtime Scheduled Start'),
+ 'scheduled_end_time' => t('Downtime Scheduled End'),
+ 'scheduled_duration' => t('Downtime Scheduled Duration'),
+ 'is_flexible' => t('Downtime Is Flexible'),
+ 'flexible_duration' => t('Downtime Flexible Duration'),
+ 'is_in_effect' => t('Downtime Is In Effect'),
+ 'start_time' => t('Downtime Actual Start'),
+ 'end_time' => t('Downtime Actual End'),
+ 'duration' => t('Downtime Duration'),
+ 'scheduled_by' => t('Scheduled By Downtime'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['comment'];
+ }
+
+ public function getDefaultSort()
+ {
+ return ['downtime.is_in_effect desc', 'downtime.start_time desc'];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_flexible',
+ 'is_in_effect'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'entry_time',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'start_time',
+ 'end_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'triggered_by_id',
+ 'parent_id',
+ 'host_id',
+ 'service_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+
+ // As long as the rewriteCondition() expects only Filter\Condition as a first argument
+ // We have to add this reroute behavior after the binary because the filter condition might
+ // be transformed into a filter chain!
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('triggered_by', self::class)
+ ->setCandidateKey('triggered_by_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('parent', self::class)
+ ->setCandidateKey('parent_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class)->setJoinType('LEFT');
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ $relations->belongsTo('zone', Zone::class);
+ }
+}
diff --git a/library/Icingadb/Model/DowntimeHistory.php b/library/Icingadb/Model/DowntimeHistory.php
new file mode 100644
index 0000000..7ba992f
--- /dev/null
+++ b/library/Icingadb/Model/DowntimeHistory.php
@@ -0,0 +1,128 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `downtime_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this,
+ * the query needs a `downtime_history.service_id IS NULL OR downtime_history_service.id IS NOT NULL` where.
+ */
+class DowntimeHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'downtime_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'downtime_id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'triggered_by_id',
+ 'parent_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'entry_time',
+ 'author',
+ 'cancelled_by',
+ 'comment',
+ 'is_flexible',
+ 'flexible_duration',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'start_time',
+ 'end_time',
+ 'has_been_cancelled',
+ 'trigger_time',
+ 'cancel_time'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'triggered_by_id' => t('Triggered By Downtime Id'),
+ 'parent_id' => t('Parent Downtime Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'entry_time' => t('Downtime Entry Time'),
+ 'author' => t('Downtime Author'),
+ 'cancelled_by' => t('Downtime Cancelled By'),
+ 'comment' => t('Downtime Comment'),
+ 'is_flexible' => t('Downtime Is Flexible'),
+ 'flexible_duration' => t('Downtime Flexible Duration'),
+ 'scheduled_start_time' => t('Downtime Scheduled Start'),
+ 'scheduled_end_time' => t('Downtime Scheduled End'),
+ 'start_time' => t('Downtime Actual Start'),
+ 'end_time' => t('Downtime Actual End'),
+ 'has_been_cancelled' => t('Downtime Has Been Cancelled'),
+ 'trigger_time' => t('Downtime Trigger Time'),
+ 'cancel_time' => t('Downtime Cancel Time')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_flexible',
+ 'has_been_cancelled'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'entry_time',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'start_time',
+ 'end_time',
+ 'trigger_time',
+ 'cancel_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'downtime_id',
+ 'environment_id',
+ 'endpoint_id',
+ 'triggered_by_id',
+ 'parent_id',
+ 'host_id',
+ 'service_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('triggered_by', self::class)
+ ->setCandidateKey('triggered_by_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('parent', self::class)
+ ->setCandidateKey('parent_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('downtime_id')
+ ->setForeignKey('downtime_history_id');
+ $relations->belongsTo('host', Host::class)->setJoinType('LEFT');
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/Endpoint.php b/library/Icingadb/Model/Endpoint.php
new file mode 100644
index 0000000..257001b
--- /dev/null
+++ b/library/Icingadb/Model/Endpoint.php
@@ -0,0 +1,69 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Endpoint extends Model
+{
+ public function getTableName()
+ {
+ return 'endpoint';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Endpoint Name Checksum'),
+ 'properties_checksum' => t('Endpoint Properties Checksum'),
+ 'name' => t('Endpoint Name'),
+ 'name_ci' => t('Endpoint Name (CI)'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->hasMany('host', Host::class)
+ ->setForeignKey('command_endpoint_id');
+ $relations->hasMany('service', Service::class)
+ ->setForeignKey('command_endpoint_id');
+ }
+}
diff --git a/library/Icingadb/Model/Environment.php b/library/Icingadb/Model/Environment.php
new file mode 100644
index 0000000..919ca1c
--- /dev/null
+++ b/library/Icingadb/Model/Environment.php
@@ -0,0 +1,105 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Environment extends Model
+{
+ public function getTableName()
+ {
+ return 'environment';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'name'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'name' => t('Environment Name')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->hasMany('acknowledgement_history', AcknowledgementHistory::class);
+ $relations->hasMany('action_url', ActionUrl::class);
+ $relations->hasMany('checkcommand', Checkcommand::class);
+ $relations->hasMany('checkcommand_argument', CheckcommandArgument::class);
+ $relations->hasMany('checkcommand_customvar', CheckcommandCustomvar::class);
+ $relations->hasMany('checkcommand_envvar', CheckcommandEnvvar::class);
+ $relations->hasMany('comment', Comment::class);
+ $relations->hasMany('comment_history', CommentHistory::class);
+ $relations->hasMany('customvar', Customvar::class);
+ $relations->hasMany('customvar_flat', CustomvarFlat::class);
+ $relations->hasMany('downtime', Downtime::class);
+ $relations->hasMany('downtime_history', DowntimeHistory::class);
+ $relations->hasMany('endpoint', Endpoint::class);
+ $relations->hasMany('eventcommand', Eventcommand::class);
+ $relations->hasMany('eventcommand_argument', EventcommandArgument::class);
+ $relations->hasMany('eventcommand_customvar', EventcommandCustomvar::class);
+ $relations->hasMany('eventcommand_envvar', EventcommandEnvvar::class);
+ $relations->hasMany('flapping_history', FlappingHistory::class);
+ $relations->hasMany('history', History::class);
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('host_customvar', HostCustomvar::class);
+ $relations->hasMany('host_state', HostState::class);
+ $relations->hasMany('hostgroup', Hostgroup::class);
+ $relations->hasMany('hostgroup_customvar', HostgroupCustomvar::class);
+ $relations->hasMany('hostgroup_member', HostgroupMember::class);
+ $relations->hasMany('instance', Instance::class);
+ $relations->hasMany('icon_image', IconImage::class);
+ $relations->hasMany('notes_url', NotesUrl::class);
+ $relations->hasMany('notification', Notification::class);
+ $relations->hasMany('notification_customvar', NotificationCustomvar::class);
+ $relations->hasMany('notification_history', NotificationHistory::class);
+ //$relations->hasMany('notification_recipient', NotificationRecipient::class);
+ $relations->hasMany('notification_user', NotificationUser::class);
+ $relations->hasMany('notification_usergroup', NotificationUsergroup::class);
+ $relations->hasMany('notificationcommand', Notificationcommand::class);
+ $relations->hasMany('notificationcommand_argument', NotificationcommandArgument::class);
+ $relations->hasMany('notificationcommand_customvar', NotificationcommandCustomvar::class);
+ $relations->hasMany('notificationcommand_envvar', NotificationcommandEnvvar::class);
+ $relations->hasMany('service', Service::class);
+ $relations->hasMany('service_customvar', ServiceCustomvar::class);
+ $relations->hasMany('service_state', ServiceState::class);
+ $relations->hasMany('servicegroup', Servicegroup::class);
+ $relations->hasMany('servicegroup_customvar', ServicegroupCustomvar::class);
+ $relations->hasMany('servicegroup_member', ServicegroupMember::class);
+ $relations->hasMany('state_history', StateHistory::class);
+ $relations->hasMany('timeperiod', Timeperiod::class);
+ $relations->hasMany('timeperiod_customvar', TimeperiodCustomvar::class);
+ $relations->hasMany('timeperiod_override_exclude', TimeperiodOverrideExclude::class);
+ $relations->hasMany('timeperiod_override_include', TimeperiodOverrideInclude::class);
+ $relations->hasMany('timeperiod_range', TimeperiodRange::class);
+ $relations->hasMany('user', User::class);
+ $relations->hasMany('user_customvar', UserCustomvar::class);
+ //$relations->hasMany('user_notification_history', UserNotificationHistory::class);
+ $relations->hasMany('usergroup', Usergroup::class);
+ $relations->hasMany('usergroup_customvar', UsergroupCustomvar::class);
+ $relations->hasMany('usergroup_member', UsergroupMember::class);
+ $relations->hasMany('zone', Zone::class);
+ }
+}
diff --git a/library/Icingadb/Model/Eventcommand.php b/library/Icingadb/Model/Eventcommand.php
new file mode 100644
index 0000000..ad18e22
--- /dev/null
+++ b/library/Icingadb/Model/Eventcommand.php
@@ -0,0 +1,86 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Eventcommand extends Model
+{
+ public function getTableName()
+ {
+ return 'eventcommand';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'command',
+ 'timeout'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'zone_id' => t('Zone Id'),
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Eventcommand Name Checksum'),
+ 'properties_checksum' => t('Eventcommand Properties Checksum'),
+ 'name' => t('Eventcommand Name'),
+ 'name_ci' => t('Eventcommand Name (CI)'),
+ 'command' => t('Eventcommand'),
+ 'timeout' => t('Eventcommand Timeout')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(EventcommandCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(EventcommandCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(EventcommandCustomvar::class);
+
+ $relations->hasMany('argument', EventcommandArgument::class);
+ $relations->hasMany('envvar', EventcommandEnvvar::class);
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('service', Service::class);
+ }
+}
diff --git a/library/Icingadb/Model/EventcommandArgument.php b/library/Icingadb/Model/EventcommandArgument.php
new file mode 100644
index 0000000..485e5d3
--- /dev/null
+++ b/library/Icingadb/Model/EventcommandArgument.php
@@ -0,0 +1,75 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class EventcommandArgument extends Model
+{
+ public function getTableName()
+ {
+ return 'eventcommand_argument';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'eventcommand_id',
+ 'argument_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'argument_value',
+ 'argument_order',
+ 'description',
+ 'argument_key_override',
+ 'repeat_key',
+ 'required',
+ 'set_if',
+ 'skip_key'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'eventcommand_id' => t('Eventcommand Id'),
+ 'argument_key' => t('Eventcommand Argument Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Eventcommand Argument Properties Checksum'),
+ 'argument_value' => t('Eventcommand Argument Value'),
+ 'argument_order' => t('Eventcommand Argument Position'),
+ 'description' => t('Eventcommand Argument Description'),
+ 'argument_key_override' => t('Eventcommand Argument Actual Name'),
+ 'repeat_key' => t('Eventcommand Argument Repeated'),
+ 'required' => t('Eventcommand Argument Required'),
+ 'set_if' => t('Eventcommand Argument Condition'),
+ 'skip_key' => t('Eventcommand Argument Without Name')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'eventcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/EventcommandCustomvar.php b/library/Icingadb/Model/EventcommandCustomvar.php
new file mode 100644
index 0000000..3d1fa48
--- /dev/null
+++ b/library/Icingadb/Model/EventcommandCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class EventcommandCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'eventcommand_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'eventcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'eventcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/EventcommandEnvvar.php b/library/Icingadb/Model/EventcommandEnvvar.php
new file mode 100644
index 0000000..3883bef
--- /dev/null
+++ b/library/Icingadb/Model/EventcommandEnvvar.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class EventcommandEnvvar extends Model
+{
+ public function getTableName()
+ {
+ return 'eventcommand_envvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'eventcommand_id',
+ 'envvar_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'envvar_value'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'eventcommand_id' => t('Eventcommand Id'),
+ 'envvar_key' => t('Eventcommand Envvar Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Eventcommand Envvar Properties Checksum'),
+ 'envvar_value' => t('Eventcommand Envvar Value')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'eventcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/FlappingHistory.php b/library/Icingadb/Model/FlappingHistory.php
new file mode 100644
index 0000000..69711eb
--- /dev/null
+++ b/library/Icingadb/Model/FlappingHistory.php
@@ -0,0 +1,91 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `flapping_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this,
+ * the query needs a `flapping_history.service_id IS NULL OR flapping_history_service.id IS NOT NULL` where.
+ */
+class FlappingHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'flapping_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'start_time',
+ 'end_time',
+ 'percent_state_change_start',
+ 'percent_state_change_end',
+ 'flapping_threshold_low',
+ 'flapping_threshold_high'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'start_time' => t('Flapping Start Time'),
+ 'end_time' => t('Flapping End Time'),
+ 'percent_state_change_start' => t('Flapping Percent State Change Start'),
+ 'percent_state_change_end' => t('Flapping Percent State Change End'),
+ 'flapping_threshold_low' => t('Flapping Threshold Low'),
+ 'flapping_threshold_high' => t('Flapping Threshold High')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'start_time',
+ 'end_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('flapping_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/History.php b/library/Icingadb/Model/History.php
new file mode 100644
index 0000000..a34b3bd
--- /dev/null
+++ b/library/Icingadb/Model/History.php
@@ -0,0 +1,127 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid
+ * this, the query needs a `history.service_id IS NULL OR history_service.id IS NOT NULL` where.
+ */
+class History extends Model
+{
+ public function getTableName()
+ {
+ return 'history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'comment_history_id',
+ 'downtime_history_id',
+ 'flapping_history_id',
+ 'notification_history_id',
+ 'acknowledgement_history_id',
+ 'state_history_id',
+ 'event_type',
+ 'event_time'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'event_type' => t('Event Type'),
+ 'event_time' => t('Event Time')
+ ];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'history.event_time desc, history.event_type desc';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'event_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id',
+ 'comment_history_id',
+ 'downtime_history_id',
+ 'flapping_history_id',
+ 'notification_history_id',
+ 'acknowledgement_history_id',
+ 'state_history_id'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+
+ $relations->hasOne('comment', CommentHistory::class)
+ ->setCandidateKey('comment_history_id')
+ ->setForeignKey('comment_id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('downtime', DowntimeHistory::class)
+ ->setCandidateKey('downtime_history_id')
+ ->setForeignKey('downtime_id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('flapping', FlappingHistory::class)
+ ->setCandidateKey('flapping_history_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('notification', NotificationHistory::class)
+ ->setCandidateKey('notification_history_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('acknowledgement', AcknowledgementHistory::class)
+ ->setCandidateKey('acknowledgement_history_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('state', StateHistory::class)
+ ->setCandidateKey('state_history_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/Host.php b/library/Icingadb/Model/Host.php
new file mode 100644
index 0000000..e1296c8
--- /dev/null
+++ b/library/Icingadb/Model/Host.php
@@ -0,0 +1,234 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Defaults;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+use ipl\Orm\ResultSet;
+
+/**
+ * Host model.
+ */
+class Host extends Model
+{
+ use Auth;
+
+ public function getTableName()
+ {
+ return 'host';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'address',
+ 'address6',
+ 'address_bin',
+ 'address6_bin',
+ 'checkcommand_name',
+ 'checkcommand_id',
+ 'max_check_attempts',
+ 'check_timeperiod_name',
+ 'check_timeperiod_id',
+ 'check_timeout',
+ 'check_interval',
+ 'check_retry_interval',
+ 'active_checks_enabled',
+ 'passive_checks_enabled',
+ 'event_handler_enabled',
+ 'notifications_enabled',
+ 'flapping_enabled',
+ 'flapping_threshold_low',
+ 'flapping_threshold_high',
+ 'perfdata_enabled',
+ 'eventcommand_name',
+ 'eventcommand_id',
+ 'is_volatile',
+ 'action_url_id',
+ 'notes_url_id',
+ 'notes',
+ 'icon_image_id',
+ 'icon_image_alt',
+ 'zone_name',
+ 'zone_id',
+ 'command_endpoint_name',
+ 'command_endpoint_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Host Name Checksum'),
+ 'properties_checksum' => t('Host Properties Checksum'),
+ 'name' => t('Host Name'),
+ 'name_ci' => t('Host Name (CI)'),
+ 'display_name' => t('Host Display Name'),
+ 'address' => t('Host Address (IPv4)'),
+ 'address6' => t('Host Address (IPv6)'),
+ 'address_bin' => t('Host Address (IPv4, Binary)'),
+ 'address6_bin' => t('Host Address (IPv6, Binary)'),
+ 'checkcommand_name' => t('Checkcommand Name'),
+ 'checkcommand_id' => t('Checkcommand Id'),
+ 'max_check_attempts' => t('Host Max Check Attempts'),
+ 'check_timeperiod_name' => t('Check Timeperiod Name'),
+ 'check_timeperiod_id' => t('Check Timeperiod Id'),
+ 'check_timeout' => t('Host Check Timeout'),
+ 'check_interval' => t('Host Check Interval'),
+ 'check_retry_interval' => t('Host Check Retry Inverval'),
+ 'active_checks_enabled' => t('Host Active Checks Enabled'),
+ 'passive_checks_enabled' => t('Host Passive Checks Enabled'),
+ 'event_handler_enabled' => t('Host Event Handler Enabled'),
+ 'notifications_enabled' => t('Host Notifications Enabled'),
+ 'flapping_enabled' => t('Host Flapping Enabled'),
+ 'flapping_threshold_low' => t('Host Flapping Threshold Low'),
+ 'flapping_threshold_high' => t('Host Flapping Threshold High'),
+ 'perfdata_enabled' => t('Host Performance Data Enabled'),
+ 'eventcommand_name' => t('Eventcommand Name'),
+ 'eventcommand_id' => t('Eventcommand Id'),
+ 'is_volatile' => t('Host Is Volatile'),
+ 'action_url_id' => t('Action Url Id'),
+ 'notes_url_id' => t('Notes Url Id'),
+ 'notes' => t('Host Notes'),
+ 'icon_image_id' => t('Icon Image Id'),
+ 'icon_image_alt' => t('Icon Image Alt'),
+ 'zone_name' => t('Zone Name'),
+ 'zone_id' => t('Zone Id'),
+ 'command_endpoint_name' => t('Endpoint Name'),
+ 'command_endpoint_id' => t('Endpoint Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'host.display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'active_checks_enabled',
+ 'passive_checks_enabled',
+ 'event_handler_enabled',
+ 'notifications_enabled',
+ 'flapping_enabled',
+ 'is_volatile'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'servicegroup' => 'service.servicegroup',
+ 'user' => 'notification.user',
+ 'usergroup' => 'notification.usergroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'address_bin',
+ 'address6_bin',
+ 'checkcommand_id',
+ 'check_timeperiod_id',
+ 'eventcommand_id',
+ 'action_url_id',
+ 'notes_url_id',
+ 'icon_image_id',
+ 'zone_id',
+ 'command_endpoint_id'
+ ]));
+ }
+
+ public function createDefaults(Defaults $defaults)
+ {
+ $defaults->add('vars', function (self $subject) {
+ if (! $subject->customvar_flat instanceof ResultSet) {
+ $this->applyRestrictions($subject->customvar_flat);
+ }
+
+ $vars = [];
+ foreach ($subject->customvar_flat as $customVar) {
+ $vars[$customVar->flatname] = $customVar->flatvalue;
+ }
+
+ return $vars;
+ });
+
+ $defaults->add('customvars', function (self $subject) {
+ if (! $subject->customvar instanceof ResultSet) {
+ $this->applyRestrictions($subject->customvar);
+ }
+
+ $vars = [];
+ foreach ($subject->customvar as $customVar) {
+ $vars[$customVar->name] = json_decode($customVar->value, true);
+ }
+
+ return $vars;
+ });
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class)
+ ->setCandidateKey('check_timeperiod_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('action_url', ActionUrl::class)
+ ->setCandidateKey('action_url_id')
+ ->setForeignKey('id');
+ $relations->belongsTo('notes_url', NotesUrl::class)
+ ->setCandidateKey('notes_url_id')
+ ->setForeignKey('id');
+ $relations->belongsTo('icon_image', IconImage::class)
+ ->setCandidateKey('icon_image_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('zone', Zone::class);
+ $relations->belongsTo('endpoint', Endpoint::class)
+ ->setCandidateKey('command_endpoint_id');
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(HostCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(HostCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(HostCustomvar::class);
+ $relations->belongsToMany('hostgroup', Hostgroup::class)
+ ->through(HostgroupMember::class);
+
+ $relations->hasOne('state', HostState::class)->setJoinType('LEFT');
+ $relations->hasMany('comment', Comment::class)->setJoinType('LEFT');
+ $relations->hasMany('downtime', Downtime::class)->setJoinType('LEFT');
+ $relations->hasMany('history', History::class);
+ $relations->hasMany('notification', Notification::class)->setJoinType('LEFT');
+ $relations->hasMany('notification_history', NotificationHistory::class);
+ $relations->hasMany('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/HostCustomvar.php b/library/Icingadb/Model/HostCustomvar.php
new file mode 100644
index 0000000..9f7df26
--- /dev/null
+++ b/library/Icingadb/Model/HostCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class HostCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'host_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'host_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'host_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setForeignKey('customvar_id')
+ ->setCandidateKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/HostState.php b/library/Icingadb/Model/HostState.php
new file mode 100644
index 0000000..efa2752
--- /dev/null
+++ b/library/Icingadb/Model/HostState.php
@@ -0,0 +1,81 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Common\HostStates;
+use ipl\Orm\Relations;
+
+/**
+ * Host state model.
+ */
+class HostState extends State
+{
+ public function getTableName()
+ {
+ return 'host_state';
+ }
+
+ public function getKeyName()
+ {
+ return 'host_id';
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'state_type' => t('Host State Type'),
+ 'soft_state' => t('Host Soft State'),
+ 'hard_state' => t('Host Hard State'),
+ 'previous_soft_state' => t('Host Previous Soft State'),
+ 'previous_hard_state' => t('Host Previous Hard State'),
+ 'check_attempt' => t('Host Check Attempt No.'),
+ 'severity' => t('Host State Severity'),
+ 'output' => t('Host Output'),
+ 'long_output' => t('Host Long Output'),
+ 'performance_data' => t('Host Performance Data'),
+ 'normalized_performance_data' => t('Host Normalized Performance Data'),
+ 'check_commandline' => t('Host Check Commandline'),
+ 'is_problem' => t('Host Has Problem'),
+ 'is_handled' => t('Host Is Handled'),
+ 'is_reachable' => t('Host Is Reachable'),
+ 'is_flapping' => t('Host Is Flapping'),
+ 'is_overdue' => t('Host Check Is Overdue'),
+ 'is_acknowledged' => t('Host Is Acknowledged'),
+ 'acknowledgement_comment_id' => t('Acknowledgement Comment Id'),
+ 'in_downtime' => t('Host In Downtime'),
+ 'execution_time' => t('Host Check Execution Time'),
+ 'latency' => t('Host Check Latency'),
+ 'check_timeout' => t('Host Check Timeout'),
+ 'check_source' => t('Host Check Source'),
+ 'last_update' => t('Host Last Update'),
+ 'last_state_change' => t('Host Last State Change'),
+ 'next_check' => t('Host Next Check'),
+ 'next_update' => t('Host Next Update')
+ ];
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class);
+ $relations->hasOne('last_comment', LastHostComment::class)
+ ->setCandidateKey('last_comment_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ }
+
+
+ public function getStateText(): string
+ {
+ return HostStates::text($this->soft_state);
+ }
+
+
+ public function getStateTextTranslated(): string
+ {
+ return HostStates::text($this->soft_state);
+ }
+}
diff --git a/library/Icingadb/Model/Hostgroup.php b/library/Icingadb/Model/Hostgroup.php
new file mode 100644
index 0000000..97930fa
--- /dev/null
+++ b/library/Icingadb/Model/Hostgroup.php
@@ -0,0 +1,92 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Hostgroup extends Model
+{
+ public function getTableName()
+ {
+ return 'hostgroup';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Hostgroup Name Checksum'),
+ 'properties_checksum' => t('Hostgroup Properties Checksum'),
+ 'name' => t('Hostgroup Name'),
+ 'name_ci' => t('Hostgroup Name (CI)'),
+ 'display_name' => t('Hostgroup Display Name'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(HostgroupCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(HostgroupCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(HostgroupCustomvar::class);
+ $relations->belongsToMany('host', Host::class)
+ ->through(HostgroupMember::class);
+ $relations->belongsToMany('service', Service::class)
+ ->through(HostgroupMember::class);
+ }
+}
diff --git a/library/Icingadb/Model/HostgroupCustomvar.php b/library/Icingadb/Model/HostgroupCustomvar.php
new file mode 100644
index 0000000..41272d1
--- /dev/null
+++ b/library/Icingadb/Model/HostgroupCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class HostgroupCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'hostgroup_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'hostgroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'hostgroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('hostgroup', Hostgroup::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/HostgroupMember.php b/library/Icingadb/Model/HostgroupMember.php
new file mode 100644
index 0000000..3660e71
--- /dev/null
+++ b/library/Icingadb/Model/HostgroupMember.php
@@ -0,0 +1,53 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class HostgroupMember extends Model
+{
+ public function getTableName()
+ {
+ return 'hostgroup_member';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'host_id',
+ 'hostgroup_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'host_id',
+ 'hostgroup_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('hostgroup', Hostgroup::class);
+ $relations->belongsTo('host', Host::class);
+
+ $relations->hasMany('service', Service::class)
+ ->setForeignKey('host_id')
+ ->setCandidateKey('host_id');
+ }
+}
diff --git a/library/Icingadb/Model/Hostgroupsummary.php b/library/Icingadb/Model/Hostgroupsummary.php
new file mode 100644
index 0000000..a9295bb
--- /dev/null
+++ b/library/Icingadb/Model/Hostgroupsummary.php
@@ -0,0 +1,213 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Relations;
+use ipl\Orm\UnionModel;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+
+class Hostgroupsummary extends UnionModel
+{
+ public static function on(Connection $db)
+ {
+ $q = parent::on($db);
+
+ $q->on($q::ON_SELECT_ASSEMBLED, function (Select $select) use ($q) {
+ $model = $q->getModel();
+
+ $groupBy = $q->getResolver()->qualifyColumnsAndAliases((array) $model->getKeyName(), $model, false);
+
+ // For PostgreSQL, ALL non-aggregate SELECT columns must appear in the GROUP BY clause:
+ if ($q->getDb()->getAdapter() instanceof Pgsql) {
+ /**
+ * Ignore Expressions, i.e. aggregate functions {@see getColumns()},
+ * which do not need to be added to the GROUP BY.
+ */
+ $candidates = array_filter($select->getColumns(), 'is_string');
+ // Remove already considered columns for the GROUP BY, i.e. the primary key.
+ $candidates = array_diff_assoc($candidates, $groupBy);
+ $groupBy = array_merge($groupBy, $candidates);
+ }
+
+ $select->groupBy($groupBy);
+ });
+
+ return $q;
+ }
+
+ public function getTableName()
+ {
+ return 'hostgroup';
+ }
+
+ public function getKeyName()
+ {
+ return ['id' => 'hostgroup_id'];
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'display_name' => 'hostgroup_display_name',
+ 'hosts_down_handled' => new Expression(
+ 'SUM(CASE WHEN host_state = 1'
+ . ' AND (host_handled = \'y\' OR host_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_down_unhandled' => new Expression(
+ 'SUM(CASE WHEN host_state = 1'
+ . ' AND host_handled = \'n\' AND host_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_pending' => new Expression(
+ 'SUM(CASE WHEN host_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_total' => new Expression(
+ 'SUM(CASE WHEN host_id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_up' => new Expression(
+ 'SUM(CASE WHEN host_state = 0 THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_severity' => new Expression('MAX(host_severity)'),
+ 'name' => 'hostgroup_name',
+ 'services_critical_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_ok' => new Expression(
+ 'SUM(CASE WHEN service_state = 0 THEN 1 ELSE 0 END)'
+ ),
+ 'services_pending' => new Expression(
+ 'SUM(CASE WHEN service_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'services_total' => new Expression(
+ 'SUM(CASE WHEN service_id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ )
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'display_name';
+ }
+
+ public function getUnions()
+ {
+ $unions = [
+ [
+ Host::class,
+ [
+ 'hostgroup',
+ 'state'
+ ],
+ [
+ 'hostgroup_id' => 'hostgroup.id',
+ 'hostgroup_name' => 'hostgroup.name',
+ 'hostgroup_display_name' => 'hostgroup.display_name',
+ 'host_id' => 'host.id',
+ 'host_state' => 'state.soft_state',
+ 'host_handled' => 'state.is_handled',
+ 'host_reachable' => 'state.is_reachable',
+ 'host_severity' => 'state.severity',
+ 'service_id' => new Expression('NULL'),
+ 'service_state' => new Expression('NULL'),
+ 'service_handled' => new Expression('NULL'),
+ 'service_reachable' => new Expression('NULL')
+ ]
+ ],
+ [
+ Service::class,
+ [
+ 'hostgroup',
+ 'state'
+ ],
+ [
+ 'hostgroup_id' => 'hostgroup.id',
+ 'hostgroup_name' => 'hostgroup.name',
+ 'hostgroup_display_name' => 'hostgroup.display_name',
+ 'host_id' => new Expression('NULL'),
+ 'host_state' => new Expression('NULL'),
+ 'host_handled' => new Expression('NULL'),
+ 'host_reachable' => new Expression('NULL'),
+ 'host_severity' => new Expression('0'),
+ 'service_id' => 'service.id',
+ 'service_state' => 'state.soft_state',
+ 'service_handled' => 'state.is_handled',
+ 'service_reachable' => 'state.is_reachable'
+ ]
+ ],
+ [
+ Hostgroup::class,
+ [],
+ [
+ 'hostgroup_id' => 'hostgroup.id',
+ 'hostgroup_name' => 'hostgroup.name',
+ 'hostgroup_display_name' => 'hostgroup.display_name',
+ 'host_id' => new Expression('NULL'),
+ 'host_state' => new Expression('NULL'),
+ 'host_handled' => new Expression('NULL'),
+ 'host_reachable' => new Expression('NULL'),
+ 'host_severity' => new Expression('0'),
+ 'service_id' => new Expression('NULL'),
+ 'service_state' => new Expression('NULL'),
+ 'service_handled' => new Expression('NULL'),
+ 'service_reachable' => new Expression('NULL')
+ ]
+ ]
+ ];
+
+ return $unions;
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id'
+ ]));
+
+ // This is because there is no better way
+ (new Hostgroup())->createBehaviors($behaviors);
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ // This is because there is no better way
+ (new Hostgroup())->createRelations($relations);
+ }
+
+ public function getColumnDefinitions()
+ {
+ // This is because there is no better way
+ return (new Hostgroup())->getColumnDefinitions();
+ }
+}
diff --git a/library/Icingadb/Model/HoststateSummary.php b/library/Icingadb/Model/HoststateSummary.php
new file mode 100644
index 0000000..93268f3
--- /dev/null
+++ b/library/Icingadb/Model/HoststateSummary.php
@@ -0,0 +1,78 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+
+class HoststateSummary extends Host
+{
+ public function getSummaryColumns()
+ {
+ return [
+ 'hosts_acknowledged' => new Expression(
+ 'SUM(CASE WHEN host_state.is_acknowledged = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_active_checks_enabled' => new Expression(
+ 'SUM(CASE WHEN host.active_checks_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_passive_checks_enabled' => new Expression(
+ 'SUM(CASE WHEN host.passive_checks_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_down_handled' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 1'
+ . ' AND (host_state.is_handled = \'y\' OR host_state.is_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_down_unhandled' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 1'
+ . ' AND host_state.is_handled = \'n\' AND host_state.is_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_event_handler_enabled' => new Expression(
+ 'SUM(CASE WHEN host.event_handler_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_flapping_enabled' => new Expression(
+ 'SUM(CASE WHEN host.flapping_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_notifications_enabled' => new Expression(
+ 'SUM(CASE WHEN host.notifications_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_pending' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_problems_unacknowledged' => new Expression(
+ 'SUM(CASE WHEN host_state.is_problem = \'y\''
+ . ' AND host_state.is_acknowledged = \'n\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_total' => new Expression(
+ 'SUM(CASE WHEN host.id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_up' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 0 THEN 1 ELSE 0 END)'
+ )
+ ];
+ }
+
+ public static function on(Connection $db)
+ {
+ $q = parent::on($db);
+ $q->utilize('state');
+
+ /** @var static $m */
+ $m = $q->getModel();
+ $q->columns($m->getSummaryColumns());
+
+ return $q;
+ }
+
+ public function getColumns()
+ {
+ return array_merge(parent::getColumns(), $this->getSummaryColumns());
+ }
+
+ public function getDefaultSort()
+ {
+ return null;
+ }
+}
diff --git a/library/Icingadb/Model/IconImage.php b/library/Icingadb/Model/IconImage.php
new file mode 100644
index 0000000..212234a
--- /dev/null
+++ b/library/Icingadb/Model/IconImage.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class IconImage extends Model
+{
+ public function getTableName()
+ {
+ return 'icon_image';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'icon_image',
+ 'environment_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'icon_image' => t('Icon Image'),
+ 'environment_id' => t('Environment Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('service', Service::class);
+ }
+}
diff --git a/library/Icingadb/Model/Instance.php b/library/Icingadb/Model/Instance.php
new file mode 100644
index 0000000..73db565
--- /dev/null
+++ b/library/Icingadb/Model/Instance.php
@@ -0,0 +1,78 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Instance extends Model
+{
+ public function getTableName()
+ {
+ return 'icingadb_instance';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'heartbeat',
+ 'responsible',
+ 'icinga2_active_host_checks_enabled',
+ 'icinga2_active_service_checks_enabled',
+ 'icinga2_event_handlers_enabled',
+ 'icinga2_flap_detection_enabled',
+ 'icinga2_notifications_enabled',
+ 'icinga2_performance_data_enabled',
+ 'icinga2_start_time',
+ 'icinga2_version'
+ ];
+ }
+
+ public function getDefaultSort()
+ {
+ return ['responsible desc', 'heartbeat desc'];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'heartbeat',
+ 'icinga2_start_time'
+ ]));
+
+ $behaviors->add(new BoolCast([
+ 'responsible',
+ 'icinga2_active_host_checks_enabled',
+ 'icinga2_active_service_checks_enabled',
+ 'icinga2_event_handlers_enabled',
+ 'icinga2_flap_detection_enabled',
+ 'icinga2_notifications_enabled',
+ 'icinga2_performance_data_enabled'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('endpoint', Endpoint::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/LastHostComment.php b/library/Icingadb/Model/LastHostComment.php
new file mode 100644
index 0000000..621b204
--- /dev/null
+++ b/library/Icingadb/Model/LastHostComment.php
@@ -0,0 +1,19 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Relations;
+
+class LastHostComment extends Comment
+{
+ public function createRelations(Relations $relations): void
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+ $relations->belongsTo('state', HostState::class)
+ ->setForeignKey('last_comment_id')
+ ->setCandidateKey('id');
+ }
+}
diff --git a/library/Icingadb/Model/LastServiceComment.php b/library/Icingadb/Model/LastServiceComment.php
new file mode 100644
index 0000000..4d44f11
--- /dev/null
+++ b/library/Icingadb/Model/LastServiceComment.php
@@ -0,0 +1,19 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Relations;
+
+class LastServiceComment extends Comment
+{
+ public function createRelations(Relations $relations): void
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+ $relations->belongsTo('state', ServiceState::class)
+ ->setForeignKey('last_comment_id')
+ ->setCandidateKey('id');
+ }
+}
diff --git a/library/Icingadb/Model/NotesUrl.php b/library/Icingadb/Model/NotesUrl.php
new file mode 100644
index 0000000..5865c52
--- /dev/null
+++ b/library/Icingadb/Model/NotesUrl.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ActionAndNoteUrl;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotesUrl extends Model
+{
+ public function getTableName()
+ {
+ return 'notes_url';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notes_url',
+ 'environment_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'notes_url' => t('Notes Url'),
+ 'environment_id' => t('Environment Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ActionAndNoteUrl(['notes_url']));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->hasMany('host', Host::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('notes_url_id');
+ $relations->hasMany('service', Service::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('notes_url_id');
+ }
+}
diff --git a/library/Icingadb/Model/Notification.php b/library/Icingadb/Model/Notification.php
new file mode 100644
index 0000000..8d42301
--- /dev/null
+++ b/library/Icingadb/Model/Notification.php
@@ -0,0 +1,130 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\Bitmask;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Notification extends Model
+{
+ public function getTableName()
+ {
+ return 'notification';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'host_id',
+ 'service_id',
+ 'notificationcommand_id',
+ 'times_begin',
+ 'times_end',
+ 'notification_interval',
+ 'timeperiod_id',
+ 'states',
+ 'types',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Notification Name Checksum'),
+ 'properties_checksum' => t('Notification Properties Checksum'),
+ 'name' => t('Notification Name'),
+ 'name_ci' => t('Notification Name (CI)'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'notificationcommand_id' => t('Notificationcommand Id'),
+ 'times_begin' => t('Notification Escalate After'),
+ 'times_end' => t('Notification Escalate Until'),
+ 'notification_interval' => t('Notification Interval'),
+ 'timeperiod_id' => t('Timeperiod Id'),
+ 'states' => t('Notification State Filter'),
+ 'types' => t('Notification Type Filter'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Bitmask([
+ 'states' => [
+ 'ok' => 1,
+ 'warning' => 2,
+ 'critical' => 4,
+ 'unknown' => 8,
+ 'up' => 16,
+ 'down' => 32
+ ],
+ 'types' => [
+ 'downtime_start' => 1,
+ 'downtime_end' => 2,
+ 'downtime_removed' => 4,
+ 'custom' => 8,
+ 'ack' => 16,
+ 'problem' => 32,
+ 'recovery' => 64,
+ 'flapping_start' => 128,
+ 'flapping_end' => 256
+ ]
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'host_id',
+ 'service_id',
+ 'notificationcommand_id',
+ 'timeperiod_id',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class);
+ $relations->belongsTo('notificationcommand', Notificationcommand::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(NotificationCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(NotificationCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(NotificationCustomvar::class);
+ $relations->belongsToMany('user', User::class)
+ ->through('notification_recipient');
+ $relations->belongsToMany('usergroup', Usergroup::class)
+ ->through('notification_recipient');
+ }
+}
diff --git a/library/Icingadb/Model/NotificationCustomvar.php b/library/Icingadb/Model/NotificationCustomvar.php
new file mode 100644
index 0000000..620ae5c
--- /dev/null
+++ b/library/Icingadb/Model/NotificationCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'notification_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notification_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notification_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notification', Notification::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/NotificationHistory.php b/library/Icingadb/Model/NotificationHistory.php
new file mode 100644
index 0000000..c635dbb
--- /dev/null
+++ b/library/Icingadb/Model/NotificationHistory.php
@@ -0,0 +1,115 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `notification_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this, the
+ * query needs a `notification_history.service_id IS NULL OR notification_history_service.id IS NOT NULL` where.
+ */
+class NotificationHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'notification_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'notification_id',
+ 'type',
+ 'send_time',
+ 'state',
+ 'previous_hard_state',
+ 'author',
+ 'text',
+ 'users_notified'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'id' => t('History Id'),
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'notification_id' => t('Notification Id'),
+ 'type' => t('Notification Type'),
+ 'send_time' => t('Notification Sent On'),
+ 'state' => t('Hard State'),
+ 'previous_hard_state' => t('Previous Hard State'),
+ 'author' => t('Notification Author'),
+ 'text' => t('Notification Text'),
+ 'users_notified' => t('Users Notified')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['text'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'notification_history.send_time desc';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'send_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id',
+ 'notification_id'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('notification_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ $relations->belongsTo('notification', Notification::class)->setJoinType('LEFT');
+
+ $relations->belongsToMany('user', User::class)
+ ->through('user_notification_history');
+ }
+}
diff --git a/library/Icingadb/Model/NotificationUser.php b/library/Icingadb/Model/NotificationUser.php
new file mode 100644
index 0000000..ab23ad4
--- /dev/null
+++ b/library/Icingadb/Model/NotificationUser.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationUser extends Model
+{
+ public function getTableName()
+ {
+ return 'notification_user';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notification_id',
+ 'user_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notification_id',
+ 'user_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notification', Notification::class);
+ $relations->belongsTo('user', User::class);
+ }
+}
diff --git a/library/Icingadb/Model/NotificationUsergroup.php b/library/Icingadb/Model/NotificationUsergroup.php
new file mode 100644
index 0000000..bd60fae
--- /dev/null
+++ b/library/Icingadb/Model/NotificationUsergroup.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationUsergroup extends Model
+{
+ public function getTableName()
+ {
+ return 'notification_usergroup';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notification_id',
+ 'usergroup_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notification_id',
+ 'usergroup_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notification', Notification::class);
+ $relations->belongsTo('usergroup', Usergroup::class);
+ }
+}
diff --git a/library/Icingadb/Model/Notificationcommand.php b/library/Icingadb/Model/Notificationcommand.php
new file mode 100644
index 0000000..6ee2a21
--- /dev/null
+++ b/library/Icingadb/Model/Notificationcommand.php
@@ -0,0 +1,87 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Notificationcommand extends Model
+{
+ public function getTableName()
+ {
+ return 'notificationcommand';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'command',
+ 'timeout'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'zone_id' => t('Zone Id'),
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Notificationcommand Name Checksum'),
+ 'properties_checksum' => t('Notificationcommand Properties Checksum'),
+ 'name' => t('Notificationcommand Name'),
+ 'name_ci' => t('Notificationcommand Name (CI)'),
+ 'command' => t('Notificationcommand'),
+ 'timeout' => t('Notificationcommand Timeout')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'host' => 'notification.host',
+ 'hostgroup' => 'notification.host.hostgroup',
+ 'service' => 'notification.service',
+ 'servicegroup' => 'notification.service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(NotificationcommandCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(NotificationcommandCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(NotificationcommandCustomvar::class);
+
+ $relations->hasMany('notification', Notification::class);
+ $relations->hasMany('argument', NotificationcommandArgument::class);
+ $relations->hasMany('envvar', NotificationcommandEnvvar::class);
+ }
+}
diff --git a/library/Icingadb/Model/NotificationcommandArgument.php b/library/Icingadb/Model/NotificationcommandArgument.php
new file mode 100644
index 0000000..e855022
--- /dev/null
+++ b/library/Icingadb/Model/NotificationcommandArgument.php
@@ -0,0 +1,75 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationcommandArgument extends Model
+{
+ public function getTableName()
+ {
+ return 'notificationcommand_argument';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notificationcommand_id',
+ 'argument_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'argument_value',
+ 'argument_order',
+ 'description',
+ 'argument_key_override',
+ 'repeat_key',
+ 'required',
+ 'set_if',
+ 'skip_key'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'notificationcommand_id' => t('Notificationcommand Id'),
+ 'argument_key' => t('Notificationcommand Argument Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Notificationcommand Argument Properties Checksum'),
+ 'argument_value' => t('Notificationcommand Argument Value'),
+ 'argument_order' => t('Notificationcommand Argument Position'),
+ 'description' => t('Notificationcommand Argument Description'),
+ 'argument_key_override' => t('Notificationcommand Argument Actual Name'),
+ 'repeat_key' => t('Notificationcommand Argument Repeated'),
+ 'required' => t('Notificationcommand Argument Required'),
+ 'set_if' => t('Notificationcommand Argument Condition'),
+ 'skip_key' => t('Notificationcommand Argument Without Name')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notificationcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notificationcommand', Notificationcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/NotificationcommandCustomvar.php b/library/Icingadb/Model/NotificationcommandCustomvar.php
new file mode 100644
index 0000000..bd103f7
--- /dev/null
+++ b/library/Icingadb/Model/NotificationcommandCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationcommandCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'notificationcommand_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notificationcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notificationcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notificationcommand', Notificationcommand::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/NotificationcommandEnvvar.php b/library/Icingadb/Model/NotificationcommandEnvvar.php
new file mode 100644
index 0000000..09f77b0
--- /dev/null
+++ b/library/Icingadb/Model/NotificationcommandEnvvar.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationcommandEnvvar extends Model
+{
+ public function getTableName()
+ {
+ return 'notificationcommand_envvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notificationcommand_id',
+ 'envvar_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'envvar_value'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'notificationcommand_id' => t('Notificationcommand Id'),
+ 'envvar_key' => t('Notificationcommand Envvar Key'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Notificationcommand Envvar Properties Checksum'),
+ 'envvar_value' => t('Notificationcommand Envvar Value')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notificationcommand_id',
+ 'environment_id',
+ 'properties_checksum',
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notificationcommand', Notificationcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/Service.php b/library/Icingadb/Model/Service.php
new file mode 100644
index 0000000..c57b6ba
--- /dev/null
+++ b/library/Icingadb/Model/Service.php
@@ -0,0 +1,225 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Defaults;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+use ipl\Orm\ResultSet;
+
+class Service extends Model
+{
+ use Auth;
+
+ public function getTableName()
+ {
+ return 'service';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'host_id',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'checkcommand_name',
+ 'checkcommand_id',
+ 'max_check_attempts',
+ 'check_timeperiod_name',
+ 'check_timeperiod_id',
+ 'check_timeout',
+ 'check_interval',
+ 'check_retry_interval',
+ 'active_checks_enabled',
+ 'passive_checks_enabled',
+ 'event_handler_enabled',
+ 'notifications_enabled',
+ 'flapping_enabled',
+ 'flapping_threshold_low',
+ 'flapping_threshold_high',
+ 'perfdata_enabled',
+ 'eventcommand_name',
+ 'eventcommand_id',
+ 'is_volatile',
+ 'action_url_id',
+ 'notes_url_id',
+ 'notes',
+ 'icon_image_id',
+ 'icon_image_alt',
+ 'zone_name',
+ 'zone_id',
+ 'command_endpoint_name',
+ 'command_endpoint_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Service Name Checksum'),
+ 'properties_checksum' => t('Service Properties Checksum'),
+ 'host_id' => t('Host Id'),
+ 'name' => t('Service Name'),
+ 'name_ci' => t('Service Name (CI)'),
+ 'display_name' => t('Service Display Name'),
+ 'checkcommand_name' => t('Checkcommand Name'),
+ 'checkcommand_id' => t('Checkcommand Id'),
+ 'max_check_attempts' => t('Service Max Check Attempts'),
+ 'check_timeperiod_name' => t('Check Timeperiod Name'),
+ 'check_timeperiod_id' => t('Check Timeperiod Id'),
+ 'check_timeout' => t('Service Check Timeout'),
+ 'check_interval' => t('Service Check Interval'),
+ 'check_retry_interval' => t('Service Check Retry Inverval'),
+ 'active_checks_enabled' => t('Service Active Checks Enabled'),
+ 'passive_checks_enabled' => t('Service Passive Checks Enabled'),
+ 'event_handler_enabled' => t('Service Event Handler Enabled'),
+ 'notifications_enabled' => t('Service Notifications Enabled'),
+ 'flapping_enabled' => t('Service Flapping Enabled'),
+ 'flapping_threshold_low' => t('Service Flapping Threshold Low'),
+ 'flapping_threshold_high' => t('Service Flapping Threshold High'),
+ 'perfdata_enabled' => t('Service Performance Data Enabled'),
+ 'eventcommand_name' => t('Eventcommand Name'),
+ 'eventcommand_id' => t('Eventcommand Id'),
+ 'is_volatile' => t('Service Is Volatile'),
+ 'action_url_id' => t('Action Url Id'),
+ 'notes_url_id' => t('Notes Url Id'),
+ 'notes' => t('Service Notes'),
+ 'icon_image_id' => t('Icon Image Id'),
+ 'icon_image_alt' => t('Icon Image Alt'),
+ 'zone_name' => t('Zone Name'),
+ 'zone_id' => t('Zone Id'),
+ 'command_endpoint_name' => t('Endpoint Name'),
+ 'command_endpoint_id' => t('Endpoint Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'service.display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'active_checks_enabled',
+ 'passive_checks_enabled',
+ 'event_handler_enabled',
+ 'notifications_enabled',
+ 'flapping_enabled',
+ 'is_volatile'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'user' => 'notification.user',
+ 'usergroup' => 'notification.usergroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'host_id',
+ 'checkcommand_id',
+ 'check_timeperiod_id',
+ 'eventcommand_id',
+ 'action_url_id',
+ 'notes_url_id',
+ 'icon_image_id',
+ 'zone_id',
+ 'command_endpoint_id'
+ ]));
+ }
+
+ public function createDefaults(Defaults $defaults)
+ {
+ $defaults->add('vars', function (self $subject) {
+ if (! $subject->customvar_flat instanceof ResultSet) {
+ $this->applyRestrictions($subject->customvar_flat);
+ }
+
+ $vars = [];
+ foreach ($subject->customvar_flat as $customVar) {
+ $vars[$customVar->flatname] = $customVar->flatvalue;
+ }
+
+ return $vars;
+ });
+
+ $defaults->add('customvars', function (self $subject) {
+ if (! $subject->customvar instanceof ResultSet) {
+ $this->applyRestrictions($subject->customvar);
+ }
+
+ $vars = [];
+ foreach ($subject->customvar as $customVar) {
+ $vars[$customVar->name] = json_decode($customVar->value, true);
+ }
+
+ return $vars;
+ });
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class)->setJoinType('LEFT');
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class)
+ ->setCandidateKey('check_timeperiod_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ $relations->belongsTo('action_url', ActionUrl::class)
+ ->setCandidateKey('action_url_id')
+ ->setForeignKey('id');
+ $relations->belongsTo('notes_url', NotesUrl::class)
+ ->setCandidateKey('notes_url_id')
+ ->setForeignKey('id');
+ $relations->belongsTo('icon_image', IconImage::class)
+ ->setCandidateKey('icon_image_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('zone', Zone::class);
+ $relations->belongsTo('endpoint', Endpoint::class)
+ ->setCandidateKey('command_endpoint_id');
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(ServiceCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(ServiceCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(ServiceCustomvar::class);
+ $relations->belongsToMany('servicegroup', Servicegroup::class)
+ ->through(ServicegroupMember::class);
+ $relations->belongsToMany('hostgroup', Hostgroup::class)
+ ->through(HostgroupMember::class);
+
+ $relations->hasOne('state', ServiceState::class)->setJoinType('LEFT');
+ $relations->hasMany('comment', Comment::class)->setJoinType('LEFT');
+ $relations->hasMany('downtime', Downtime::class)->setJoinType('LEFT');
+ $relations->hasMany('history', History::class);
+ $relations->hasMany('notification', Notification::class)->setJoinType('LEFT');
+ $relations->hasMany('notification_history', NotificationHistory::class);
+ }
+}
diff --git a/library/Icingadb/Model/ServiceCustomvar.php b/library/Icingadb/Model/ServiceCustomvar.php
new file mode 100644
index 0000000..07ee84c
--- /dev/null
+++ b/library/Icingadb/Model/ServiceCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class ServiceCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'service_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'service_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'service_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('service', Service::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setForeignKey('customvar_id')
+ ->setCandidateKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/ServiceState.php b/library/Icingadb/Model/ServiceState.php
new file mode 100644
index 0000000..c5daa08
--- /dev/null
+++ b/library/Icingadb/Model/ServiceState.php
@@ -0,0 +1,76 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use ipl\Orm\Relations;
+
+class ServiceState extends State
+{
+ public function getTableName()
+ {
+ return 'service_state';
+ }
+
+ public function getKeyName()
+ {
+ return 'service_id';
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'state_type' => t('Service State Type'),
+ 'soft_state' => t('Service Soft State'),
+ 'hard_state' => t('Service Hard State'),
+ 'previous_soft_state' => t('Service Previous Soft State'),
+ 'previous_hard_state' => t('Service Previous Hard State'),
+ 'check_attempt' => t('Service Check Attempt No.'),
+ 'severity' => t('Service State Severity'),
+ 'output' => t('Service Output'),
+ 'long_output' => t('Service Long Output'),
+ 'performance_data' => t('Service Performance Data'),
+ 'normalized_performance_data' => t('Service Normalized Performance Data'),
+ 'check_commandline' => t('Service Check Commandline'),
+ 'is_problem' => t('Service Has Problem'),
+ 'is_handled' => t('Service Is Handled'),
+ 'is_reachable' => t('Service Is Reachable'),
+ 'is_flapping' => t('Service Is Flapping'),
+ 'is_overdue' => t('Service Check Is Overdue'),
+ 'is_acknowledged' => t('Service Is Acknowledged'),
+ 'acknowledgement_comment_id' => t('Acknowledgement Comment Id'),
+ 'in_downtime' => t('Service In Downtime'),
+ 'execution_time' => t('Service Check Execution Time'),
+ 'latency' => t('Service Check Latency'),
+ 'check_timeout' => t('Service Check Timeout'),
+ 'check_source' => t('Service Check Source'),
+ 'last_update' => t('Service Last Update'),
+ 'last_state_change' => t('Service Last State Change'),
+ 'next_check' => t('Service Next Check'),
+ 'next_update' => t('Service Next Update')
+ ];
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('service', Service::class);
+ $relations->hasOne('last_comment', LastServiceComment::class)
+ ->setCandidateKey('last_comment_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ }
+
+ public function getStateText(): string
+ {
+ return ServiceStates::text($this->soft_state);
+ }
+
+ public function getStateTextTranslated(): string
+ {
+ return ServiceStates::text($this->soft_state);
+ }
+}
diff --git a/library/Icingadb/Model/Servicegroup.php b/library/Icingadb/Model/Servicegroup.php
new file mode 100644
index 0000000..0da92fb
--- /dev/null
+++ b/library/Icingadb/Model/Servicegroup.php
@@ -0,0 +1,91 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Servicegroup extends Model
+{
+ public function getTableName()
+ {
+ return 'servicegroup';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Servicegroup Name Checksum'),
+ 'properties_checksum' => t('Servicegroup Properties Checksum'),
+ 'name' => t('Servicegroup Name'),
+ 'name_ci' => t('Servicegroup Name (CI)'),
+ 'display_name' => t('Servicegroup Display Name'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'host' => 'service.host',
+ 'hostgroup' => 'service.hostgroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(ServicegroupCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(ServicegroupCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(ServicegroupCustomvar::class);
+ $relations->belongsToMany('service', Service::class)
+ ->through(ServicegroupMember::class);
+ }
+}
diff --git a/library/Icingadb/Model/ServicegroupCustomvar.php b/library/Icingadb/Model/ServicegroupCustomvar.php
new file mode 100644
index 0000000..23e536b
--- /dev/null
+++ b/library/Icingadb/Model/ServicegroupCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class ServicegroupCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'servicegroup_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'servicegroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'servicegroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('servicegroup', Servicegroup::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/ServicegroupMember.php b/library/Icingadb/Model/ServicegroupMember.php
new file mode 100644
index 0000000..5da537a
--- /dev/null
+++ b/library/Icingadb/Model/ServicegroupMember.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class ServicegroupMember extends Model
+{
+ public function getTableName()
+ {
+ return 'servicegroup_member';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'service_id',
+ 'servicegroup_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'service_id',
+ 'servicegroup_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('servicegroup', Servicegroup::class);
+ $relations->belongsTo('service', Service::class);
+ }
+}
diff --git a/library/Icingadb/Model/ServicegroupSummary.php b/library/Icingadb/Model/ServicegroupSummary.php
new file mode 100644
index 0000000..89a0953
--- /dev/null
+++ b/library/Icingadb/Model/ServicegroupSummary.php
@@ -0,0 +1,167 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Relations;
+use ipl\Orm\UnionModel;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+
+class ServicegroupSummary extends UnionModel
+{
+ public static function on(Connection $db)
+ {
+ $q = parent::on($db);
+
+ $q->on($q::ON_SELECT_ASSEMBLED, function (Select $select) use ($q) {
+ $model = $q->getModel();
+
+ $groupBy = $q->getResolver()->qualifyColumnsAndAliases((array) $model->getKeyName(), $model, false);
+
+ // For PostgreSQL, ALL non-aggregate SELECT columns must appear in the GROUP BY clause:
+ if ($q->getDb()->getAdapter() instanceof Pgsql) {
+ /**
+ * Ignore Expressions, i.e. aggregate functions {@see getColumns()},
+ * which do not need to be added to the GROUP BY.
+ */
+ $candidates = array_filter($select->getColumns(), 'is_string');
+ // Remove already considered columns for the GROUP BY, i.e. the primary key.
+ $candidates = array_diff_assoc($candidates, $groupBy);
+ $groupBy = array_merge($groupBy, $candidates);
+ }
+
+ $select->groupBy($groupBy);
+ });
+
+ return $q;
+ }
+
+ public function getTableName()
+ {
+ return 'servicegroup';
+ }
+
+ public function getKeyName()
+ {
+ return ['id' => 'servicegroup_id'];
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'display_name' => 'servicegroup_display_name',
+ 'name' => 'servicegroup_name',
+ 'services_critical_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_ok' => new Expression(
+ 'SUM(CASE WHEN service_state = 0 THEN 1 ELSE 0 END)'
+ ),
+ 'services_pending' => new Expression(
+ 'SUM(CASE WHEN service_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'services_total' => new Expression(
+ 'SUM(CASE WHEN service_id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_severity' => new Expression('MAX(service_severity)')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'display_name';
+ }
+
+ public function getUnions()
+ {
+ $unions = [
+ [
+ Service::class,
+ [
+ 'servicegroup',
+ 'state'
+ ],
+ [
+ 'servicegroup_id' => 'servicegroup.id',
+ 'servicegroup_name' => 'servicegroup.name',
+ 'servicegroup_display_name' => 'servicegroup.display_name',
+ 'service_id' => 'service.id',
+ 'service_state' => 'state.soft_state',
+ 'service_handled' => 'state.is_handled',
+ 'service_reachable' => 'state.is_reachable',
+ 'service_severity' => 'state.severity'
+ ]
+ ],
+ [
+ Servicegroup::class,
+ [],
+ [
+ 'servicegroup_id' => 'servicegroup.id',
+ 'servicegroup_name' => 'servicegroup.name',
+ 'servicegroup_display_name' => 'servicegroup.display_name',
+ 'service_id' => new Expression('NULL'),
+ 'service_state' => new Expression('NULL'),
+ 'service_handled' => new Expression('NULL'),
+ 'service_reachable' => new Expression('NULL'),
+ 'service_severity' => new Expression('0')
+ ]
+ ]
+ ];
+
+ return $unions;
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id'
+ ]));
+
+ // This is because there is no better way
+ (new Servicegroup())->createBehaviors($behaviors);
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ // This is because there is no better way
+ (new Servicegroup())->createRelations($relations);
+ }
+
+ public function getColumnDefinitions()
+ {
+ // This is because there is no better way
+ return (new Servicegroup())->getColumnDefinitions();
+ }
+}
diff --git a/library/Icingadb/Model/ServicestateSummary.php b/library/Icingadb/Model/ServicestateSummary.php
new file mode 100644
index 0000000..b1364f7
--- /dev/null
+++ b/library/Icingadb/Model/ServicestateSummary.php
@@ -0,0 +1,99 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+
+class ServicestateSummary extends Service
+{
+ public function getSummaryColumns()
+ {
+ return [
+ 'services_acknowledged' => new Expression(
+ 'SUM(CASE WHEN service_state.is_acknowledged = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_active_checks_enabled' => new Expression(
+ 'SUM(CASE WHEN service.active_checks_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_passive_checks_enabled' => new Expression(
+ 'SUM(CASE WHEN service.passive_checks_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_handled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 2'
+ . ' AND (service_state.is_handled = \'y\' OR service_state.is_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 2'
+ . ' AND service_state.is_handled = \'n\' AND service_state.is_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_event_handler_enabled' => new Expression(
+ 'SUM(CASE WHEN service.event_handler_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_flapping_enabled' => new Expression(
+ 'SUM(CASE WHEN service.flapping_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_notifications_enabled' => new Expression(
+ 'SUM(CASE WHEN service.notifications_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_ok' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 0 THEN 1 ELSE 0 END)'
+ ),
+ 'services_pending' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'services_problems_unacknowledged' => new Expression(
+ 'SUM(CASE WHEN service_state.is_problem = \'y\''
+ . ' AND service_state.is_acknowledged = \'n\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_total' => new Expression(
+ 'SUM(CASE WHEN service.id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_handled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 3'
+ . ' AND (service_state.is_handled = \'y\' OR service_state.is_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 3'
+ . ' AND service_state.is_handled = \'n\' AND service_state.is_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_handled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 1'
+ . ' AND (service_state.is_handled = \'y\' OR service_state.is_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 1'
+ . ' AND service_state.is_handled = \'n\' AND service_state.is_reachable = \'y\' THEN 1 ELSE 0 END)'
+ )
+ ];
+ }
+
+ public static function on(Connection $db)
+ {
+ $q = parent::on($db);
+ $q->utilize('state');
+
+ /** @var static $m */
+ $m = $q->getModel();
+ $q->columns($m->getSummaryColumns());
+
+ return $q;
+ }
+
+ public function getColumns()
+ {
+ return array_merge(parent::getColumns(), $this->getSummaryColumns());
+ }
+
+ public function getDefaultSort()
+ {
+ return null;
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'host.name_ci'];
+ }
+}
diff --git a/library/Icingadb/Model/State.php b/library/Icingadb/Model/State.php
new file mode 100644
index 0000000..2d242a8
--- /dev/null
+++ b/library/Icingadb/Model/State.php
@@ -0,0 +1,177 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use DateTime;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Web\Widget\Icon;
+
+/**
+ * Base class for the {@link HostState} and {@link ServiceState} models providing common columns.
+ *
+ * @property string $environment_id The environment id
+ * @property string $state_type The state type (hard or soft)
+ * @property int $soft_state The current soft state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN)
+ * @property int $hard_state The current hard state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN)
+ * @property int $previous_soft_state The previous soft state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN)
+ * @property int $previous_hard_state The previous hard state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN)
+ * @property int $check_attempt The check attempt count
+ * @property int $severity The calculated severity
+ * @property ?string $output The check output
+ * @property ?string $long_output The long check output
+ * @property ?string $performance_data The performance data
+ * @property ?string $normalized_performance_data The normalized performance data (converted ms to s, GiB to byte etc.)
+ * @property ?string $check_commandline The executed check command
+ * @property bool $is_problem Whether in non-OK state
+ * @property bool $is_handled Whether the state is handled
+ * @property bool $is_reachable Whether the node is reachable
+ * @property bool $is_flapping Whether the state is flapping
+ * @property bool $is_overdue Whether the check is overdue
+ * @property bool|string $is_acknowledged Whether the state is acknowledged (bool), can also be `sticky` (string)
+ * @property ?string $acknowledgement_comment_id The id of acknowledgement comment
+ * @property ?string $last_comment_id The id of last comment
+ * @property bool $in_downtime Whether the node is in downtime
+ * @property ?int $execution_time The check execution time
+ * @property ?int $latency The check latency
+ * @property ?int $check_timeout The check timeout
+ * @property ?string $check_source The name of the node that executes the check
+ * @property ?string $scheduling_source The name of the node that schedules the check
+ * @property ?DateTime $last_update The time when the node was last updated
+ * @property ?DateTime $last_state_change The time when the node last got a status change
+ * @property ?DateTime $next_check The time when the node will execute the next check
+ * @property ?DateTime $next_update The time when the next check of the node is expected to end
+ */
+abstract class State extends Model
+{
+ /**
+ * Get the state as the textual representation
+ *
+ * @return string
+ */
+ abstract public function getStateText(): string;
+
+ /**
+ * Get the state as the translated textual representation
+ *
+ * @return string
+ */
+ abstract public function getStateTextTranslated(): string;
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'state_type',
+ 'soft_state',
+ 'hard_state',
+ 'previous_soft_state',
+ 'previous_hard_state',
+ 'check_attempt',
+ 'severity',
+ 'output',
+ 'long_output',
+ 'performance_data',
+ 'normalized_performance_data',
+ 'check_commandline',
+ 'is_problem',
+ 'is_handled',
+ 'is_reachable',
+ 'is_flapping',
+ 'is_overdue',
+ 'is_acknowledged',
+ 'acknowledgement_comment_id',
+ 'last_comment_id',
+ 'in_downtime',
+ 'execution_time',
+ 'latency',
+ 'check_timeout',
+ 'check_source',
+ 'scheduling_source',
+ 'last_update',
+ 'last_state_change',
+ 'next_check',
+ 'next_update'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_problem',
+ 'is_handled',
+ 'is_reachable',
+ 'is_flapping',
+ 'is_overdue',
+ 'is_acknowledged',
+ 'in_downtime'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'last_update',
+ 'last_state_change',
+ 'next_check',
+ 'next_update'
+ ]));
+
+ $behaviors->add(new Binary([
+ $this->getKeyName(),
+ 'environment_id',
+ 'acknowledgement_comment_id',
+ 'last_comment_id'
+ ]));
+ }
+
+ /**
+ * Get the state icon
+ *
+ * @return Icon|null
+ */
+ public function getIcon(): ?Icon
+ {
+ $icon = null;
+ switch (true) {
+ case $this->is_acknowledged:
+ $icon = new Icon(Icons::IS_ACKNOWLEDGED);
+
+ break;
+ case $this->in_downtime:
+ $icon = new Icon(
+ Icons::IN_DOWNTIME,
+ ['title' => sprintf(
+ '%s (%s)',
+ strtoupper($this->getStateTextTranslated()),
+ $this->is_handled ? t('handled by Downtime') : t('in Downtime')
+ )]
+ );
+
+ break;
+ case $this->is_flapping:
+ $icon = new Icon(Icons::IS_FLAPPING);
+
+ break;
+ case ! $this->is_reachable:
+ $icon = new Icon(Icons::HOST_DOWN, [
+ 'title' => sprintf(
+ '%s (%s)',
+ strtoupper($this->getStateTextTranslated()),
+ t('is unreachable')
+ )
+ ]);
+
+ break;
+ case $this->is_handled:
+ $icon = new Icon(Icons::HOST_DOWN);
+
+ break;
+ }
+
+ return $icon;
+ }
+}
diff --git a/library/Icingadb/Model/StateHistory.php b/library/Icingadb/Model/StateHistory.php
new file mode 100644
index 0000000..9d80cb2
--- /dev/null
+++ b/library/Icingadb/Model/StateHistory.php
@@ -0,0 +1,101 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `state_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this,
+ * the query needs a `state_history.service_id IS NULL OR state_history_service.id IS NOT NULL` where.
+ */
+class StateHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'state_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'event_time',
+ 'state_type',
+ 'soft_state',
+ 'hard_state',
+ 'check_attempt',
+ 'previous_soft_state',
+ 'previous_hard_state',
+ 'output',
+ 'long_output',
+ 'max_check_attempts',
+ 'check_source',
+ 'scheduling_source'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'event_time' => t('Event Time'),
+ 'state_type' => t('Event State Type'),
+ 'soft_state' => t('Event Soft State'),
+ 'hard_state' => t('Event Hard State'),
+ 'check_attempt' => t('Event Check Attempt No.'),
+ 'previous_soft_state' => t('Event Previous Soft State'),
+ 'previous_hard_state' => t('Event Previous Hard State'),
+ 'output' => t('Event Output'),
+ 'long_output' => t('Event Long Output'),
+ 'max_check_attempts' => t('Event Max Check Attempts'),
+ 'check_source' => t('Event Check Source')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'event_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('state_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/Timeperiod.php b/library/Icingadb/Model/Timeperiod.php
new file mode 100644
index 0000000..26dd722
--- /dev/null
+++ b/library/Icingadb/Model/Timeperiod.php
@@ -0,0 +1,91 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Timeperiod extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'prefer_includes',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Timeperiod Name Checksum'),
+ 'properties_checksum' => t('Timeperiod Properties Checksum'),
+ 'name' => t('Timeperiod Name'),
+ 'name_ci' => t('Timeperiod Name (CI)'),
+ 'display_name' => t('Timeperiod Display Name'),
+ 'prefer_includes' => t('Timeperiod Prefer Includes'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(TimeperiodCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(TimeperiodCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(TimeperiodCustomvar::class);
+
+ // TODO: Decide how to establish the override relations
+
+ $relations->hasMany('range', TimeperiodRange::class);
+ $relations->hasMany('host', Host::class)
+ ->setForeignKey('check_timeperiod_id');
+ $relations->hasMany('Notification', Notification::class);
+ $relations->hasMany('service', Service::class)
+ ->setForeignKey('check_timeperiod_id');
+ $relations->hasMany('user', User::class);
+ }
+}
diff --git a/library/Icingadb/Model/TimeperiodCustomvar.php b/library/Icingadb/Model/TimeperiodCustomvar.php
new file mode 100644
index 0000000..614a312
--- /dev/null
+++ b/library/Icingadb/Model/TimeperiodCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class TimeperiodCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'timeperiod_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'timeperiod_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/TimeperiodOverrideExclude.php b/library/Icingadb/Model/TimeperiodOverrideExclude.php
new file mode 100644
index 0000000..c33df77
--- /dev/null
+++ b/library/Icingadb/Model/TimeperiodOverrideExclude.php
@@ -0,0 +1,51 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class TimeperiodOverrideExclude extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod_override_exclude';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'timeperiod_id',
+ 'override_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'timeperiod_id',
+ 'override_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ // TODO: `timeperiod` cannot be used again, find a better name
+ $relations->belongsTo('timeperiod', Timeperiod::class)
+ ->setCandidateKey('override_id');
+ }
+}
diff --git a/library/Icingadb/Model/TimeperiodOverrideInclude.php b/library/Icingadb/Model/TimeperiodOverrideInclude.php
new file mode 100644
index 0000000..5418596
--- /dev/null
+++ b/library/Icingadb/Model/TimeperiodOverrideInclude.php
@@ -0,0 +1,51 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class TimeperiodOverrideInclude extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod_override_include';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'timeperiod_id',
+ 'override_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'timeperiod_id',
+ 'override_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ // TODO: `timeperiod` cannot be used again, find a better name
+ $relations->belongsTo('timeperiod', Timeperiod::class)
+ ->setCandidateKey('override_id');
+ }
+}
diff --git a/library/Icingadb/Model/TimeperiodRange.php b/library/Icingadb/Model/TimeperiodRange.php
new file mode 100644
index 0000000..62e87f8
--- /dev/null
+++ b/library/Icingadb/Model/TimeperiodRange.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class TimeperiodRange extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod_range';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'timeperiod_id',
+ 'range_key',
+ 'environment_id',
+ 'range_value'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'timeperiod_id' => t('Timeperiod Id'),
+ 'range_key' => t('Timeperiod Range Date(s)/Day'),
+ 'environment_id' => t('Environment Id'),
+ 'range_value' => t('Timeperiod Range Time')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'timeperiod_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ }
+}
diff --git a/library/Icingadb/Model/User.php b/library/Icingadb/Model/User.php
new file mode 100644
index 0000000..91d0d71
--- /dev/null
+++ b/library/Icingadb/Model/User.php
@@ -0,0 +1,134 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\Bitmask;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class User extends Model
+{
+ public function getTableName()
+ {
+ return 'user';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'email',
+ 'pager',
+ 'notifications_enabled',
+ 'timeperiod_id',
+ 'states',
+ 'types',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('User Name Checksum'),
+ 'properties_checksum' => t('User Properties Checksum'),
+ 'name' => t('User Name'),
+ 'name_ci' => t('User Name (CI)'),
+ 'display_name' => t('User Display Name'),
+ 'email' => t('User Email'),
+ 'pager' => t('User Pager'),
+ 'notifications_enabled' => t('User Receives Notifications'),
+ 'timeperiod_id' => t('Timeperiod Id'),
+ 'states' => t('Notification State Filter'),
+ 'types' => t('Notification Type Filter'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'user.display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'host' => 'notification.host',
+ 'service' => 'notification.service',
+ 'hostgroup' => 'notification.host.hostgroup',
+ 'servicegroup' => 'notification.service.servicegroup'
+ ]));
+
+ $behaviors->add(new Bitmask([
+ 'states' => [
+ 'ok' => 1,
+ 'warning' => 2,
+ 'critical' => 4,
+ 'unknown' => 8,
+ 'up' => 16,
+ 'down' => 32
+ ],
+ 'types' => [
+ 'downtime_start' => 1,
+ 'downtime_end' => 2,
+ 'downtime_removed' => 4,
+ 'custom' => 8,
+ 'ack' => 16,
+ 'problem' => 32,
+ 'recovery' => 64,
+ 'flapping_start' => 128,
+ 'flapping_end' => 256
+ ]
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'timeperiod_id',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(UserCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(UserCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(UserCustomvar::class);
+ $relations->belongsToMany('notification', Notification::class)
+ ->through('notification_recipient');
+ $relations->belongsToMany('notification_history', NotificationHistory::class)
+ ->through('user_notification_history');
+ $relations->belongsToMany('usergroup', Usergroup::class)
+ ->through(UsergroupMember::class);
+ }
+}
diff --git a/library/Icingadb/Model/UserCustomvar.php b/library/Icingadb/Model/UserCustomvar.php
new file mode 100644
index 0000000..a702b68
--- /dev/null
+++ b/library/Icingadb/Model/UserCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class UserCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'user_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'user_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'user_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('user', User::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/Usergroup.php b/library/Icingadb/Model/Usergroup.php
new file mode 100644
index 0000000..34b0647
--- /dev/null
+++ b/library/Icingadb/Model/Usergroup.php
@@ -0,0 +1,95 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Usergroup extends Model
+{
+ public function getTableName()
+ {
+ return 'usergroup';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Usergroup Name Checksum'),
+ 'properties_checksum' => t('Usergroup Properties Checksum'),
+ 'name' => t('Usergroup Name'),
+ 'name_ci' => t('Usergroup Name (CI)'),
+ 'display_name' => t('Usergroup Display Name'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'usergroup.display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'host' => 'notification.host',
+ 'service' => 'notification.service',
+ 'hostgroup' => 'notification.host.hostgroup',
+ 'servicegroup' => 'notification.service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(UsergroupCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(UsergroupCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(UsergroupCustomvar::class);
+ $relations->belongsToMany('user', User::class)
+ ->through(UsergroupMember::class);
+ $relations->belongsToMany('notification', Notification::class)
+ ->through('notification_recipient');
+ }
+}
diff --git a/library/Icingadb/Model/UsergroupCustomvar.php b/library/Icingadb/Model/UsergroupCustomvar.php
new file mode 100644
index 0000000..ab97273
--- /dev/null
+++ b/library/Icingadb/Model/UsergroupCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class UsergroupCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'usergroup_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'usergroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'usergroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('usergroup', Usergroup::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/UsergroupMember.php b/library/Icingadb/Model/UsergroupMember.php
new file mode 100644
index 0000000..7c61d67
--- /dev/null
+++ b/library/Icingadb/Model/UsergroupMember.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class UsergroupMember extends Model
+{
+ public function getTableName()
+ {
+ return 'usergroup_member';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'user_id',
+ 'usergroup_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'user_id',
+ 'usergroup_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('usergroup', Usergroup::class);
+ $relations->belongsTo('user', User::class);
+ }
+}
diff --git a/library/Icingadb/Model/Vars.php b/library/Icingadb/Model/Vars.php
new file mode 100644
index 0000000..304d526
--- /dev/null
+++ b/library/Icingadb/Model/Vars.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\FlattenedObjectVars;
+use ipl\Orm\Behaviors;
+use ipl\Sql\Connection;
+
+class Vars extends CustomvarFlat
+{
+ /**
+ * @internal Don't use. This model acts only as relation target and is not supposed to be directly used as query
+ * target. Use {@see CustomvarFlat} instead.
+ */
+ public static function on(Connection $_)
+ {
+ throw new \LogicException('Documentation says: DO NOT USE. Can\'t you read?');
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ parent::createBehaviors($behaviors);
+
+ $behaviors->add(new FlattenedObjectVars());
+ }
+}
diff --git a/library/Icingadb/Model/Zone.php b/library/Icingadb/Model/Zone.php
new file mode 100644
index 0000000..aaf3bbf
--- /dev/null
+++ b/library/Icingadb/Model/Zone.php
@@ -0,0 +1,82 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Zone extends Model
+{
+ public function getTableName()
+ {
+ return 'zone';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'is_global',
+ 'parent_id',
+ 'depth'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Zone Name Checksum'),
+ 'properties_checksum' => t('Zone Properties Checksum'),
+ 'name' => t('Zone Name'),
+ 'name_ci' => t('Zone Name (CI)'),
+ 'is_global' => t('Zone Is Global'),
+ 'parent_id' => t('Parent Zone Id'),
+ 'depth' => t('Zone Depth')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'parent_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->hasMany('comment', Comment::class);
+ $relations->hasMany('downtime', Downtime::class);
+ $relations->hasMany('endpoint', Endpoint::class);
+ $relations->hasMany('eventcommand', Eventcommand::class);
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('hostgroup', Hostgroup::class);
+ $relations->hasMany('notification', Notification::class);
+ $relations->hasMany('service', Service::class);
+ $relations->hasMany('servicegroup', Servicegroup::class);
+ $relations->hasMany('timeperiod', Timeperiod::class);
+ $relations->hasMany('user', User::class);
+ $relations->hasMany('usergroup', Usergroup::class);
+
+ // TODO: Decide how to establish recursive relations
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/ApplicationState.php b/library/Icingadb/ProvidedHook/ApplicationState.php
new file mode 100644
index 0000000..8c7b008
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/ApplicationState.php
@@ -0,0 +1,111 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Exception;
+use Icinga\Application\Hook\ApplicationStateHook;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\IcingaRedis;
+use Icinga\Module\Icingadb\Model\Instance;
+use Icinga\Web\Session;
+use ipl\Stdlib\Filter;
+
+class ApplicationState extends ApplicationStateHook
+{
+ use Database;
+
+ public function collectMessages()
+ {
+ try {
+ $lastIcingaHeartbeat = IcingaRedis::getLastIcingaHeartbeat();
+ } catch (Exception $e) {
+ $downSince = Session::getSession()->getNamespace('icingadb')->get('redis.down-since');
+
+ if ($downSince === null) {
+ $downSince = time();
+ Session::getSession()->getNamespace('icingadb')->set('redis.down-since', $downSince);
+ }
+
+ $this->addError(
+ 'icingadb/redis-down',
+ $downSince,
+ sprintf(t("Can't connect to Icinga Redis: %s"), $e->getMessage())
+ );
+
+ return;
+ }
+
+ $instance = Instance::on($this->getDb())
+ ->with(['endpoint'])
+ ->filter(Filter::equal('responsible', true))
+ ->orderBy('heartbeat', 'desc')
+ ->first();
+
+ if ($instance === null) {
+ $noInstanceSince = Session::getSession()
+ ->getNamespace('icingadb')->get('icingadb.no-instance-since');
+
+ if ($noInstanceSince === null) {
+ $noInstanceSince = time();
+ Session::getSession()
+ ->getNamespace('icingadb')->set('icingadb.no-instance-since', $noInstanceSince);
+ }
+
+ $this->addError(
+ 'icingadb/no-instance',
+ $noInstanceSince,
+ t(
+ 'It seems that Icinga DB is not running.'
+ . ' Make sure Icinga DB is running and writing into the database.'
+ )
+ );
+
+ return;
+ } else {
+ Session::getSession()->getNamespace('icingadb')->delete('db.no-instance-since');
+ }
+
+ $outdatedDbHeartbeat = $instance->heartbeat->getTimestamp() < time() - 60;
+
+ if ($lastIcingaHeartbeat === null) {
+ $missingSince = Session::getSession()
+ ->getNamespace('icingadb')->get('redis.heartbeat-missing-since');
+
+ if ($missingSince === null) {
+ $missingSince = time();
+ Session::getSession()
+ ->getNamespace('icingadb')->set('redis.heartbeat-missing-since', $missingSince);
+ }
+
+ $lastIcingaHeartbeat = $missingSince;
+ } else {
+ Session::getSession()->getNamespace('icingadb')->delete('redis.heartbeat-missing-since');
+ }
+
+ switch (true) {
+ case $outdatedDbHeartbeat && $instance->heartbeat->getTimestamp() > $lastIcingaHeartbeat:
+ $this->addError(
+ 'icingadb/redis-outdated',
+ $lastIcingaHeartbeat,
+ t('Icinga Redis is outdated. Make sure Icinga 2 is running and connected to Redis.')
+ );
+
+ break;
+ case $outdatedDbHeartbeat:
+ $this->addError(
+ 'icingadb/icingadb-down',
+ $instance->heartbeat->getTimestamp(),
+ t(
+ 'It seems that Icinga DB is not running.'
+ . ' Make sure Icinga DB is running and writing into the database.'
+ )
+ );
+
+ break;
+ }
+
+ Session::getSession()->getNamespace('icingadb')->delete('redis.down-since');
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/CreateHostSlaReport.php b/library/Icingadb/ProvidedHook/CreateHostSlaReport.php
new file mode 100644
index 0000000..83ed911
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/CreateHostSlaReport.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\HostActionsHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+class CreateHostSlaReport extends HostActionsHook
+{
+ use Translation;
+
+ public function getActionsForObject(Host $host): array
+ {
+ if (! Auth::getInstance()->hasPermission('reporting/reports')) {
+ return [];
+ }
+
+ $filter = QueryString::render(Filter::equal('host.name', $host->name));
+
+ return [
+ new Link(
+ $this->translate('Create Host SLA Report'),
+ Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'host']),
+ [
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ )
+ ];
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/CreateHostsSlaReport.php b/library/Icingadb/ProvidedHook/CreateHostsSlaReport.php
new file mode 100644
index 0000000..6da9fca
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/CreateHostsSlaReport.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\HostsDetailExtensionHook;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\ValidHtml;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+class CreateHostsSlaReport extends HostsDetailExtensionHook
+{
+ use Translation;
+
+ public function getHtmlForObjects(Query $hosts): ValidHtml
+ {
+ if (Auth::getInstance()->hasPermission('reporting/reports')) {
+ $filter = QueryString::render($this->getBaseFilter());
+
+ return (new HtmlDocument())
+ ->addHtml(Html::tag('h2', $this->translate('Reporting')))
+ ->addHtml(new Link(
+ $this->translate('Create Host SLA Report'),
+ Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'host']),
+ [
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ ));
+ }
+
+ return new HtmlDocument();
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/CreateServiceSlaReport.php b/library/Icingadb/ProvidedHook/CreateServiceSlaReport.php
new file mode 100644
index 0000000..eeab603
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/CreateServiceSlaReport.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\ServiceActionsHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+class CreateServiceSlaReport extends ServiceActionsHook
+{
+ use Translation;
+
+ public function getActionsForObject(Service $service): array
+ {
+ if (! Auth::getInstance()->hasPermission('reporting/reports')) {
+ return [];
+ }
+
+ $filter = QueryString::render(Filter::all(
+ Filter::equal('service.name', $service->name),
+ Filter::equal('host.name', $service->host->name)
+ ));
+
+ return [
+ new Link(
+ $this->translate('Create Service SLA Report'),
+ Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'service']),
+ [
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ )
+ ];
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/CreateServicesSlaReport.php b/library/Icingadb/ProvidedHook/CreateServicesSlaReport.php
new file mode 100644
index 0000000..a65b54e
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/CreateServicesSlaReport.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\ServicesDetailExtensionHook;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\ValidHtml;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+class CreateServicesSlaReport extends ServicesDetailExtensionHook
+{
+ use Translation;
+
+ public function getHtmlForObjects(Query $services): ValidHtml
+ {
+ if (Auth::getInstance()->hasPermission('reporting/reports')) {
+ $filter = QueryString::render($this->getBaseFilter());
+ return (new HtmlDocument())
+ ->addHtml(Html::tag('h2', $this->translate('Reporting')))
+ ->addHtml(new Link(
+ $this->translate('Create Service SLA Report'),
+ Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'service']),
+ [
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ ));
+ }
+
+ return new HtmlDocument();
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/IcingaHealth.php b/library/Icingadb/ProvidedHook/IcingaHealth.php
new file mode 100644
index 0000000..54e22c7
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/IcingaHealth.php
@@ -0,0 +1,115 @@
+<?php
+
+// Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Application\Hook\HealthHook;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\Instance;
+use ipl\Web\Url;
+
+class IcingaHealth extends HealthHook
+{
+ use Database;
+
+ /** @var Instance */
+ protected $instance;
+
+ public function getName(): string
+ {
+ return 'Icinga DB';
+ }
+
+ public function getUrl(): Url
+ {
+ return Url::fromPath('icingadb/health');
+ }
+
+ public function checkHealth()
+ {
+ $instance = $this->getInstance();
+
+ if ($instance === null) {
+ $this->setState(self::STATE_UNKNOWN);
+ $this->setMessage(t(
+ 'Icinga DB is not running or not writing into the database'
+ . ' (make sure the icinga feature "icingadb" is enabled)'
+ ));
+ } elseif ($instance->heartbeat->getTimestamp() < time() - 60) {
+ $this->setState(self::STATE_CRITICAL);
+ $this->setMessage(t(
+ 'Icinga DB is not running or not writing into the database'
+ . ' (make sure the icinga feature "icingadb" is enabled)'
+ ));
+ } else {
+ $this->setState(self::STATE_OK);
+ $this->setMessage(t('Icinga DB is running and writing into the database'));
+ $warningMessages = [];
+
+ if (! $instance->icinga2_active_host_checks_enabled) {
+ $this->setState(self::STATE_WARNING);
+ $warningMessages[] = t('Active host checks are disabled');
+ }
+
+ if (! $instance->icinga2_active_service_checks_enabled) {
+ $this->setState(self::STATE_WARNING);
+ $warningMessages[] = t('Active service checks are disabled');
+ }
+
+ if (! $instance->icinga2_notifications_enabled) {
+ $this->setState(self::STATE_WARNING);
+ $warningMessages[] = t('Notifications are disabled');
+ }
+
+ if ($this->getState() === self::STATE_WARNING) {
+ $this->setMessage(implode("; ", $warningMessages));
+ }
+ }
+
+ if ($instance !== null) {
+ $this->setMetrics([
+ 'heartbeat' => $instance->heartbeat->getTimestamp(),
+ 'responsible' => $instance->responsible,
+ 'icinga2_active_host_checks_enabled' => $instance->icinga2_active_host_checks_enabled,
+ 'icinga2_active_service_checks_enabled' => $instance->icinga2_active_service_checks_enabled,
+ 'icinga2_event_handlers_enabled' => $instance->icinga2_event_handlers_enabled,
+ 'icinga2_flap_detection_enabled' => $instance->icinga2_flap_detection_enabled,
+ 'icinga2_notifications_enabled' => $instance->icinga2_notifications_enabled,
+ 'icinga2_performance_data_enabled' => $instance->icinga2_performance_data_enabled,
+ 'icinga2_start_time' => $instance->icinga2_start_time->getTimestamp(),
+ 'icinga2_version' => $instance->icinga2_version,
+ 'endpoint' => ['name' => $instance->endpoint->name]
+ ]);
+ }
+ }
+
+ /**
+ * Get an Icinga DB instance
+ *
+ * @return ?Instance
+ */
+ protected function getInstance()
+ {
+ if ($this->instance === null) {
+ $this->instance = Instance::on($this->getDb())
+ ->with('endpoint')
+ ->columns([
+ 'heartbeat',
+ 'responsible',
+ 'icinga2_active_host_checks_enabled',
+ 'icinga2_active_service_checks_enabled',
+ 'icinga2_event_handlers_enabled',
+ 'icinga2_flap_detection_enabled',
+ 'icinga2_notifications_enabled',
+ 'icinga2_performance_data_enabled',
+ 'icinga2_start_time',
+ 'icinga2_version',
+ 'endpoint.name'
+ ])
+ ->first();
+ }
+
+ return $this->instance;
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/RedisHealth.php b/library/Icingadb/ProvidedHook/RedisHealth.php
new file mode 100644
index 0000000..1471aba
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/RedisHealth.php
@@ -0,0 +1,55 @@
+<?php
+
+// Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Exception;
+use Icinga\Application\Hook\HealthHook;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\IcingaRedis;
+use Icinga\Module\Icingadb\Model\Instance;
+
+class RedisHealth extends HealthHook
+{
+ use Database;
+
+ public function getName(): string
+ {
+ return 'Icinga Redis';
+ }
+
+ public function checkHealth()
+ {
+ try {
+ $lastIcingaHeartbeat = IcingaRedis::getLastIcingaHeartbeat();
+ if ($lastIcingaHeartbeat === null) {
+ $lastIcingaHeartbeat = time();
+ }
+
+ $instance = Instance::on($this->getDb())->columns('heartbeat')->first();
+
+ if ($instance === null) {
+ $this->setState(self::STATE_UNKNOWN);
+ $this->setMessage(t(
+ 'Can\'t check Icinga Redis: Icinga DB is not running or not writing into the database'
+ . ' (make sure the icinga feature "icingadb" is enabled)'
+ ));
+
+ return;
+ }
+
+ $outdatedDbHeartbeat = $instance->heartbeat->getTimestamp() < time() - 60;
+ if (! $outdatedDbHeartbeat || $instance->heartbeat->getTimestamp() <= $lastIcingaHeartbeat) {
+ $this->setState(self::STATE_OK);
+ $this->setMessage(t('Icinga Redis available and up to date.'));
+ } elseif ($instance->heartbeat->getTimestamp() > $lastIcingaHeartbeat) {
+ $this->setState(self::STATE_CRITICAL);
+ $this->setMessage(t('Icinga Redis outdated. Make sure Icinga 2 is running and connected to Redis.'));
+ }
+ } catch (Exception $e) {
+ $this->setState(self::STATE_CRITICAL);
+ $this->setMessage(sprintf(t("Can't connect to Icinga Redis: %s"), $e->getMessage()));
+ }
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php
new file mode 100644
index 0000000..d9c4f4f
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php
@@ -0,0 +1,68 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Reporting\ReportData;
+use Icinga\Module\Reporting\ReportRow;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter\Rule;
+
+use function ipl\I18n\t;
+
+class HostSlaReport extends SlaReport
+{
+ public function getName()
+ {
+ $name = t('Host SLA');
+ if (Icinga::app()->getModuleManager()->hasEnabled('idoreports')) {
+ $name .= ' (Icinga DB)';
+ }
+
+ return $name;
+ }
+
+ protected function createReportData()
+ {
+ return (new ReportData())
+ ->setDimensions([t('Hostname')])
+ ->setValues([t('SLA in %')]);
+ }
+
+ protected function createReportRow($row)
+ {
+ if ($row->sla === null) {
+ return null;
+ }
+
+ return (new ReportRow())
+ ->setDimensions([$row->display_name])
+ ->setValues([(float) $row->sla]);
+ }
+
+ protected function fetchSla(Timerange $timerange, Rule $filter = null)
+ {
+ $sla = Host::on($this->getDb())
+ ->columns([
+ 'display_name',
+ 'sla' => new Expression(sprintf(
+ "get_sla_ok_percent(%s, NULL, '%s', '%s')",
+ 'host.id',
+ $timerange->getStart()->format('Uv'),
+ $timerange->getEnd()->format('Uv')
+ ))
+ ]);
+
+ $this->applyRestrictions($sla);
+
+ if ($filter !== null) {
+ $sla->filter($filter);
+ }
+
+ return $sla;
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php
new file mode 100644
index 0000000..46a0684
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php
@@ -0,0 +1,72 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Reporting\ReportData;
+use Icinga\Module\Reporting\ReportRow;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter\Rule;
+
+use function ipl\I18n\t;
+
+class ServiceSlaReport extends SlaReport
+{
+ public function getName()
+ {
+ $name = t('Service SLA');
+ if (Icinga::app()->getModuleManager()->hasEnabled('idoreports')) {
+ $name .= ' (Icinga DB)';
+ }
+
+ return $name;
+ }
+
+ protected function createReportData()
+ {
+ return (new ReportData())
+ ->setDimensions([t('Hostname'), t('Service Name')])
+ ->setValues([t('SLA in %')]);
+ }
+
+ protected function createReportRow($row)
+ {
+ if ($row->sla === null) {
+ return null;
+ }
+
+ return (new ReportRow())
+ ->setDimensions([$row->host->display_name, $row->display_name])
+ ->setValues([(float) $row->sla]);
+ }
+
+ protected function fetchSla(Timerange $timerange, Rule $filter = null)
+ {
+ $sla = Service::on($this->getDb())
+ ->columns([
+ 'host.display_name',
+ 'display_name',
+ 'sla' => new Expression(sprintf(
+ "get_sla_ok_percent(%s, %s, '%s', '%s')",
+ 'service.host_id',
+ 'service.id',
+ $timerange->getStart()->format('Uv'),
+ $timerange->getEnd()->format('Uv')
+ ))
+ ]);
+
+ $sla->resetOrderBy()->orderBy('host.display_name')->orderBy('display_name');
+
+ $this->applyRestrictions($sla);
+
+ if ($filter !== null) {
+ $sla->filter($filter);
+ }
+
+ return $sla;
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/SlaReport.php b/library/Icingadb/ProvidedHook/Reporting/SlaReport.php
new file mode 100644
index 0000000..8dcc64e
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/SlaReport.php
@@ -0,0 +1,297 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use DateInterval;
+use DatePeriod;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Reporting\Hook\ReportHook;
+use Icinga\Module\Reporting\ReportData;
+use Icinga\Module\Reporting\ReportRow;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Html\Form;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter\Rule;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\EmptyState;
+
+use function ipl\I18n\t;
+
+/**
+ * Base class for host and service SLA reports
+ */
+abstract class SlaReport extends ReportHook
+{
+ use Auth;
+ use Database;
+
+ /** @var float If an SLA value is lower than the threshold, it is considered not ok */
+ const DEFAULT_THRESHOLD = 99.5;
+
+ /** @var int The amount of decimal places for the report result */
+ const DEFAULT_REPORT_PRECISION = 2;
+
+ /**
+ * Create and return a {@link ReportData} container
+ *
+ * @return ReportData Container initialized with the expected dimensions and value labels for the specific report
+ */
+ abstract protected function createReportData();
+
+ /**
+ * Create and return a {@link ReportRow}
+ *
+ * @param mixed $row Data for the row
+ *
+ * @return ReportRow|null Row with the dimensions and values for the specific report set according to the data
+ * expected in {@link createRepportData()} or null for no data
+ */
+ abstract protected function createReportRow($row);
+
+ /**
+ * Fetch SLA according to specified time range and filter
+ *
+ * @param Timerange $timerange
+ * @param Rule|null $filter
+ *
+ * @return iterable
+ */
+ abstract protected function fetchSla(Timerange $timerange, Rule $filter = null);
+
+ protected function fetchReportData(Timerange $timerange, array $config = null)
+ {
+ $rd = $this->createReportData();
+ $rows = [];
+
+ $filter = trim((string) $config['filter']) ?: '*';
+ $filter = $filter !== '*' ? QueryString::parse($filter) : null;
+
+ $interval = null;
+ $boundary = null;
+ $format = null;
+ if (isset($config['breakdown']) && $config['breakdown'] !== 'none') {
+ switch ($config['breakdown']) {
+ case 'hour':
+ $interval = new DateInterval('PT1H');
+ $format = 'H:i:s';
+ $boundary = '+1 hour';
+
+ break;
+ case 'day':
+ $interval = new DateInterval('P1D');
+ $format = 'Y-m-d';
+ $boundary = 'tomorrow midnight';
+
+ break;
+ case 'week':
+ $interval = new DateInterval('P1W');
+ $format = 'Y-\WW';
+ $boundary = 'monday next week midnight';
+
+ break;
+ case 'month':
+ $interval = new DateInterval('P1M');
+ $format = 'Y-m';
+ $boundary = 'first day of next month midnight';
+
+ break;
+ }
+
+ $dimensions = $rd->getDimensions();
+ $dimensions[] = ucfirst($config['breakdown']);
+ $rd->setDimensions($dimensions);
+
+ foreach ($this->yieldTimerange($timerange, $interval, $boundary) as list($start, $end)) {
+ foreach ($this->fetchSla(new Timerange($start, $end), $filter) as $row) {
+ $row = $this->createReportRow($row);
+
+ if ($row === null) {
+ continue;
+ }
+
+ $dimensions = $row->getDimensions();
+ $dimensions[] = $start->format($format);
+ $row->setDimensions($dimensions);
+
+ $rows[] = $row;
+ }
+ }
+ } else {
+ foreach ($this->fetchSla($timerange, $filter) as $row) {
+ $rows[] = $this->createReportRow($row);
+ }
+ }
+
+ $rd->setRows($rows);
+
+ return $rd;
+ }
+
+ /**
+ * Yield start and end times that recur at the specified interval over the given time range
+ *
+ * @param Timerange $timerange
+ * @param DateInterval $interval
+ * @param string|null $boundary English text datetime description for calculating bounds to get
+ * calendar days, weeks or months instead of relative times according to interval
+ *
+ * @return \Generator
+ */
+ protected function yieldTimerange(Timerange $timerange, DateInterval $interval, $boundary = null)
+ {
+ $start = clone $timerange->getStart();
+ $end = clone $timerange->getEnd();
+ $oneSecond = new DateInterval('PT1S');
+
+ if ($boundary !== null) {
+ $intermediate = (clone $start)->modify($boundary);
+ if ($intermediate < $end) {
+ yield [clone $start, $intermediate->sub($oneSecond)];
+
+ $start->modify($boundary);
+ }
+ }
+
+ $period = new DatePeriod($start, $interval, $end, DatePeriod::EXCLUDE_START_DATE);
+
+ foreach ($period as $date) {
+ /** @var \DateTime $date */
+ yield [$start, (clone $date)->sub($oneSecond)];
+
+ $start = $date;
+ }
+
+ yield [$start, $end];
+ }
+
+ public function initConfigForm(Form $form)
+ {
+ $form->addElement('text', 'filter', [
+ 'label' => t('Filter')
+ ]);
+
+ $form->addElement('select', 'breakdown', [
+ 'label' => t('Breakdown'),
+ 'options' => [
+ 'none' => t('None', 'SLA Report Breakdown'),
+ 'hour' => t('Hour'),
+ 'day' => t('Day'),
+ 'week' => t('Week'),
+ 'month' => t('Month')
+ ]
+ ]);
+
+ $form->addElement('number', 'threshold', [
+ 'label' => t('Threshold'),
+ 'placeholder' => static::DEFAULT_THRESHOLD,
+ 'step' => '0.01',
+ 'min' => '1',
+ 'max' => '100'
+ ]);
+
+ $form->addElement('number', 'sla_precision', [
+ 'label' => t('Amount Decimal Places'),
+ 'placeholder' => static::DEFAULT_REPORT_PRECISION,
+ 'min' => '1',
+ 'max' => '12'
+ ]);
+
+ $form->addElement('checkbox', 'export_total', [
+ 'label' => t('Export Total Averages'),
+ 'description' => t('Export total averages to CSV and JSON'),
+ // Instead of y/n, 0/1 can be implicitly cast to bool which is done where the config is actually used.
+ 'checkedValue' => '1',
+ 'uncheckedValue' => '0'
+ ]);
+ }
+
+ public function getData(Timerange $timerange, array $config = null)
+ {
+ return $this->fetchReportData($timerange, $config);
+ }
+
+ public function getHtml(Timerange $timerange, array $config = null)
+ {
+ $data = $this->getData($timerange, $config);
+
+ if (! count($data)) {
+ return new EmptyState(t('No data found.'));
+ }
+
+ $threshold = isset($config['threshold']) ? (float) $config['threshold'] : static::DEFAULT_THRESHOLD;
+
+ $tableHeaderCells = [];
+
+ foreach ($data->getDimensions() as $dimension) {
+ $tableHeaderCells[] = Html::tag('th', null, $dimension);
+ }
+
+ foreach ($data->getValues() as $value) {
+ $tableHeaderCells[] = Html::tag('th', null, $value);
+ }
+
+ $tableRows = [];
+ $precision = $config['sla_precision'] ?? static::DEFAULT_REPORT_PRECISION;
+
+ foreach ($data->getRows() as $row) {
+ $cells = [];
+
+ foreach ($row->getDimensions() as $dimension) {
+ $cells[] = Html::tag('td', null, $dimension);
+ }
+
+ // We only have one metric
+ $sla = $row->getValues()[0];
+
+ if ($sla < $threshold) {
+ $slaClass = 'nok';
+ } else {
+ $slaClass = 'ok';
+ }
+
+ $cells[] = Html::tag('td', ['class' => "sla-column $slaClass"], round($sla, $precision));
+
+ $tableRows[] = Html::tag('tr', null, $cells);
+ }
+
+ // We only have one average
+ $average = $data->getAverages()[0];
+
+ if ($average < $threshold) {
+ $slaClass = 'nok';
+ } else {
+ $slaClass = 'ok';
+ }
+
+ $total = $this instanceof HostSlaReport
+ ? sprintf(t('Total (%d Hosts)'), $data->count())
+ : sprintf(t('Total (%d Services)'), $data->count());
+
+ $tableRows[] = Html::tag('tr', null, [
+ Html::tag('td', ['colspan' => count($data->getDimensions())], $total),
+ Html::tag('td', ['class' => "sla-column $slaClass"], round($average, $precision))
+ ]);
+
+ $table = Html::tag(
+ 'table',
+ ['class' => 'common-table sla-table'],
+ [
+ Html::tag(
+ 'thead',
+ null,
+ Html::tag(
+ 'tr',
+ null,
+ $tableHeaderCells
+ )
+ ),
+ Html::tag('tbody', null, $tableRows)
+ ]
+ );
+
+ return $table;
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php
new file mode 100644
index 0000000..b09ffb7
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php
@@ -0,0 +1,19 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use Icinga\Module\Icingadb\Hook\Common\TotalSlaReportUtils;
+
+use function ipl\I18n\t;
+
+class TotalHostSlaReport extends HostSlaReport
+{
+ use TotalSlaReportUtils;
+
+ public function getName()
+ {
+ return t('Total Host SLA');
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php
new file mode 100644
index 0000000..e5ebf57
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php
@@ -0,0 +1,19 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use Icinga\Module\Icingadb\Hook\Common\TotalSlaReportUtils;
+
+use function ipl\I18n\t;
+
+class TotalServiceSlaReport extends ServiceSlaReport
+{
+ use TotalSlaReportUtils;
+
+ public function getName()
+ {
+ return t('Total Service SLA');
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/X509/Sni.php b/library/Icingadb/ProvidedHook/X509/Sni.php
new file mode 100644
index 0000000..6f20a7d
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/X509/Sni.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\X509;
+
+use Generator;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\X509\Hook\SniHook;
+use ipl\Web\Filter\QueryString;
+
+class Sni extends SniHook
+{
+ use Auth;
+ use Database;
+
+ /**
+ * @inheritDoc
+ */
+ public function getHosts(Filter $filter = null): Generator
+ {
+ $this->getDb()->ping();
+
+ $queryHost = Host::on($this->getDb())
+ ->columns([
+ 'host_name' => 'name',
+ 'host_address' => 'address',
+ 'host_address6' => 'address6'
+ ]);
+
+ $this->applyRestrictions($queryHost);
+
+ if ($filter !== null) {
+ $queryString = $filter->toQueryString();
+ $filterCondition = QueryString::parse($queryString);
+ $queryHost->filter($filterCondition);
+ }
+
+ $hosts = $this->getDb()->select($queryHost->assembleSelect());
+
+ /** @var Host $host */
+ foreach ($hosts as $host) {
+ if (! empty($host->host_address)) {
+ yield $host->host_address => $host->host_name;
+ }
+
+ if (! empty($host->host_address6)) {
+ yield $host->host_address6 => $host->host_name;
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Redis/VolatileStateResults.php b/library/Icingadb/Redis/VolatileStateResults.php
new file mode 100644
index 0000000..9418398
--- /dev/null
+++ b/library/Icingadb/Redis/VolatileStateResults.php
@@ -0,0 +1,170 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Redis;
+
+use Icinga\Application\Benchmark;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\IcingaRedis;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Orm\Query;
+use ipl\Orm\Resolver;
+use ipl\Orm\ResultSet;
+use RuntimeException;
+
+class VolatileStateResults extends ResultSet
+{
+ use Auth;
+
+ /** @var Resolver */
+ private $resolver;
+
+ /** @var bool Whether Redis is unavailable */
+ private $redisUnavailable;
+
+ /** @var bool Whether Redis updates were applied */
+ private $updatesApplied = false;
+
+ public static function fromQuery(Query $query)
+ {
+ $self = parent::fromQuery($query);
+ $self->resolver = $query->getResolver();
+ $self->redisUnavailable = IcingaRedis::isUnavailable();
+
+ return $self;
+ }
+
+ /**
+ * Get whether Redis is unavailable
+ *
+ * @return bool
+ */
+ public function isRedisUnavailable(): bool
+ {
+ return $this->redisUnavailable;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ if (! $this->redisUnavailable && ! $this->updatesApplied && ! $this->isCacheDisabled) {
+ $this->rewind();
+ }
+
+ return parent::current();
+ }
+
+ public function next(): void
+ {
+ parent::next();
+
+ if (! $this->redisUnavailable && $this->isCacheDisabled && $this->valid()) {
+ $this->applyRedisUpdates([parent::current()]);
+ }
+ }
+
+ public function key(): int
+ {
+ if (! $this->redisUnavailable && ! $this->updatesApplied && ! $this->isCacheDisabled) {
+ $this->rewind();
+ }
+
+ return parent::key();
+ }
+
+ public function rewind(): void
+ {
+ if (! $this->redisUnavailable && ! $this->updatesApplied && ! $this->isCacheDisabled) {
+ $this->updatesApplied = true;
+ $this->advance();
+
+ Benchmark::measure('Applying Redis updates');
+ $this->applyRedisUpdates($this);
+ Benchmark::measure('Redis updates applied');
+ }
+
+ parent::rewind();
+ }
+
+ /**
+ * Apply redis state details to the given results
+ *
+ * @param self|array<int, mixed> $rows
+ *
+ * @return void
+ */
+ protected function applyRedisUpdates($rows)
+ {
+ $type = null;
+ $behaviors = null;
+
+ $keys = [];
+ $hostStateKeys = [];
+
+ $showSourceGranted = $this->getAuth()->hasPermission('icingadb/object/show-source');
+
+ $states = [];
+ $hostStates = [];
+ foreach ($rows as $row) {
+ if ($type === null) {
+ $behaviors = $this->resolver->getBehaviors($row->state);
+
+ switch (true) {
+ case $row instanceof Host:
+ $type = 'host';
+ break;
+ case $row instanceof Service:
+ $type = 'service';
+ break;
+ default:
+ throw new RuntimeException('Volatile states can only be fetched for hosts and services');
+ }
+ }
+
+ $states[bin2hex($row->id)] = $row->state;
+ if (empty($keys)) {
+ $keys = $row->state->getColumns();
+ if (! $showSourceGranted) {
+ $keys = array_diff($keys, ['check_commandline']);
+ }
+ }
+
+ if ($type === 'service' && $row->host instanceof Host) {
+ $hostStates[bin2hex($row->host->id)] = $row->host->state;
+ if (empty($hostStateKeys)) {
+ $hostStateKeys = $row->host->state->getColumns();
+ }
+ }
+ }
+
+ if (empty($states)) {
+ return;
+ }
+
+ if ($type === 'service') {
+ $results = IcingaRedis::fetchServiceState(array_keys($states), $keys);
+ } else {
+ $results = IcingaRedis::fetchHostState(array_keys($states), $keys);
+ }
+
+ foreach ($results as $id => $data) {
+ foreach ($data as $key => $value) {
+ $data[$key] = $behaviors->retrieveProperty($value, $key);
+ }
+
+ $states[$id]->setProperties($data);
+ }
+
+ if ($type === 'service' && ! empty($hostStates)) {
+ foreach (IcingaRedis::fetchHostState(array_keys($hostStates), $hostStateKeys) as $id => $data) {
+ foreach ($data as $key => $value) {
+ $data[$key] = $behaviors->retrieveProperty($value, $key);
+ }
+
+ $hostStates[$id]->setProperties($data);
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Setup/ApiTransportPage.php b/library/Icingadb/Setup/ApiTransportPage.php
new file mode 100644
index 0000000..e727e99
--- /dev/null
+++ b/library/Icingadb/Setup/ApiTransportPage.php
@@ -0,0 +1,128 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransportException;
+use Icinga\Web\Form;
+
+class ApiTransportPage extends Form
+{
+ public function init()
+ {
+ $this->setName('setup_icingadb_api_transport');
+ $this->setTitle(t('Icinga 2 API'));
+ $this->addDescription(t(
+ 'Please fill out the connection details to the Icinga 2 API.'
+ ));
+ $this->setValidatePartial(true);
+ }
+
+ public function createElements(array $formData)
+ {
+ if (isset($formData['skip_validation']) && $formData['skip_validation']) {
+ // In case another error occured and the checkbox was displayed before
+ $this->addSkipValidationCheckbox();
+ } else {
+ $this->addElement('hidden', 'skip_validation', ['value' => 0]);
+ }
+
+ $this->addElement('hidden', 'transport', [
+ 'required' => true,
+ 'disabled' => true,
+ 'value' => 'api'
+ ]);
+ $this->addElement('hidden', 'name', [
+ 'required' => true,
+ 'disabled' => true,
+ 'value' => 'icinga2'
+ ]);
+ $this->addElement('text', 'host', [
+ 'required' => true,
+ 'label' => t('Host'),
+ 'description' => t('Hostname or address of the Icinga master')
+ ]);
+ $this->addElement('number', 'port', [
+ 'required' => true,
+ 'label' => t('Port'),
+ 'value' => 5665,
+ 'min' => 1,
+ 'max' => 65536
+ ]);
+ $this->addElement('text', 'username', [
+ 'required' => true,
+ 'label' => t('API Username'),
+ 'description' => t('User to authenticate with using HTTP Basic Auth')
+ ]);
+ $this->addElement('password', 'password', [
+ 'required' => true,
+ 'renderPassword' => true,
+ 'label' => t('API Password'),
+ 'autocomplete' => 'new-password'
+ ]);
+ }
+
+ public function isValid($formData)
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (! isset($formData['skip_validation']) || !$formData['skip_validation']) {
+ if (! $this->validateConfiguration()) {
+ $this->addSkipValidationCheckbox();
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function isValidPartial(array $formData)
+ {
+ if (isset($formData['backend_validation']) && parent::isValid($formData)) {
+ if (! $this->validateConfiguration()) {
+ return false;
+ }
+
+ $this->info(t('The configuration has been successfully validated.'));
+ } elseif (! isset($formData['backend_validation'])) {
+ // This is usually done by isValid(Partial), but as we're not calling any of these...
+ $this->populate($formData);
+ }
+
+ return true;
+ }
+
+ protected function validateConfiguration(): bool
+ {
+ try {
+ CommandTransport::createTransport(new ConfigObject($this->getValues()))->probe();
+ } catch (CommandTransportException $e) {
+ $this->error(sprintf(
+ t('Failed to successfully validate the configuration: %s'),
+ $e->getMessage()
+ ));
+
+ return false;
+ }
+
+ return true;
+ }
+
+ protected function addSkipValidationCheckbox()
+ {
+ $this->addElement(
+ 'checkbox',
+ 'skip_validation',
+ [
+ 'ignore' => true,
+ 'label' => t('Skip Validation'),
+ 'description' => t('Check this to not to validate the configuration')
+ ]
+ );
+ }
+}
diff --git a/library/Icingadb/Setup/ApiTransportStep.php b/library/Icingadb/Setup/ApiTransportStep.php
new file mode 100644
index 0000000..1e2e905
--- /dev/null
+++ b/library/Icingadb/Setup/ApiTransportStep.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Setup\Step;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+
+class ApiTransportStep extends Step
+{
+ /** @var array */
+ protected $data;
+
+ /** @var Exception */
+ protected $error;
+
+ public function __construct(array $data)
+ {
+ $this->data = $data;
+ }
+
+ public function apply()
+ {
+ $transportConfig = $this->data;
+ $transportName = $transportConfig['name'];
+ unset($transportConfig['name']);
+
+ try {
+ $config = Config::module('icingadb', 'commandtransports', true);
+ $config->setSection($transportName, $transportConfig);
+ $config->saveIni();
+ } catch (Exception $e) {
+ $this->error = $e;
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getSummary()
+ {
+ $description = new HtmlElement('p', null, Text::create(mt(
+ 'icingadb',
+ 'The Icinga 2 API will be accessed using the following connection details:'
+ )));
+
+ $apiOptions = new Table();
+ $apiOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Host'))),
+ $this->data['host']
+ ]));
+ $apiOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Port'))),
+ $this->data['port']
+ ]));
+ $apiOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Username'))),
+ $this->data['username']
+ ]));
+ $apiOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Password'))),
+ str_repeat('*', strlen($this->data['password']))
+ ]));
+
+ $topic = new HtmlElement('div', Attributes::create(['class' => 'topic']));
+ $topic->addHtml($description, $apiOptions);
+
+ $summary = new HtmlDocument();
+ $summary->addHtml(
+ new HtmlElement('h2', null, Text::create(mt('icingadb', 'Icinga 2 API'))),
+ $topic
+ );
+
+ return $summary->render();
+ }
+
+ public function getReport()
+ {
+ if ($this->error === null) {
+ return [sprintf(
+ mt('icingadb', 'Commandtransport configuration update successful: %s'),
+ Config::module('icingadb', 'commandtransports')->getConfigFile()
+ )];
+ } else {
+ return [
+ sprintf(
+ mt('icingadb', 'Commandtransport configuration update failed: %s'),
+ Config::module('icingadb', 'commandtransports')->getConfigFile()
+ ),
+ sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error))
+ ];
+ }
+ }
+}
diff --git a/library/Icingadb/Setup/DbResourcePage.php b/library/Icingadb/Setup/DbResourcePage.php
new file mode 100644
index 0000000..cc99dcc
--- /dev/null
+++ b/library/Icingadb/Setup/DbResourcePage.php
@@ -0,0 +1,145 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Forms\Config\ResourceConfigForm;
+use Icinga\Forms\Config\Resource\DbResourceForm;
+use Icinga\Web\Form;
+
+class DbResourcePage extends Form
+{
+ public function init()
+ {
+ $this->setName('setup_icingadb_resource');
+ $this->setTitle(t('Icinga DB Resource'));
+ $this->addDescription(t(
+ 'Please fill out the connection details below to access Icinga DB.'
+ ));
+ $this->setValidatePartial(true);
+ }
+
+ public function createElements(array $formData)
+ {
+ $this->addElement(
+ 'hidden',
+ 'type',
+ [
+ 'required' => true,
+ 'disabled' => true,
+ 'value' => 'db'
+ ]
+ );
+
+ if (isset($formData['skip_validation']) && $formData['skip_validation']) {
+ // In case another error occured and the checkbox was displayed before
+ $this->addSkipValidationCheckbox();
+ } else {
+ $this->addElement('hidden', 'skip_validation', ['value' => 0]);
+ }
+
+ $dbResourceForm = new DbResourceForm();
+ $this->addElements($dbResourceForm->createElements($formData)->getElements());
+ $this->getElement('name')->setValue('icingadb');
+ $this->getElement('db')->setMultiOptions([
+ 'mysql' => 'MySQL',
+ 'pgsql' => 'PostgreSQL'
+ ]);
+
+ $this->removeElement('name');
+ $this->addElement(
+ 'hidden',
+ 'name',
+ [
+ 'required' => true,
+ 'disabled' => true,
+ 'value' => 'icingadb'
+ ]
+ );
+
+ if (! isset($formData['db']) || $formData['db'] === 'mysql') {
+ $this->getElement('charset')->setValue('utf8mb4');
+ }
+ }
+
+ public function isValid($formData)
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (! isset($formData['skip_validation']) || !$formData['skip_validation']) {
+ if (! $this->validateConfiguration()) {
+ $this->addSkipValidationCheckbox();
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function isValidPartial(array $formData)
+ {
+ if (isset($formData['backend_validation']) && parent::isValid($formData)) {
+ if (! $this->validateConfiguration(true)) {
+ return false;
+ }
+
+ $this->info(t('The configuration has been successfully validated.'));
+ } elseif (! isset($formData['backend_validation'])) {
+ // This is usually done by isValid(Partial), but as we're not calling any of these...
+ $this->populate($formData);
+ }
+
+ return true;
+ }
+
+ protected function validateConfiguration(bool $showLog = false): bool
+ {
+ $inspection = ResourceConfigForm::inspectResource($this);
+ if ($inspection !== null) {
+ if ($showLog) {
+ $join = function ($e) use (&$join) {
+ return is_string($e) ? $e : join("\n", array_map($join, $e));
+ };
+ $this->addElement(
+ 'note',
+ 'inspection_output',
+ [
+ 'order' => 0,
+ 'value' => '<strong>' . t('Validation Log') . "</strong>\n\n"
+ . join("\n", array_map($join, $inspection->toArray())),
+ 'decorators' => [
+ 'ViewHelper',
+ ['HtmlTag', ['tag' => 'pre', 'class' => 'log-output']],
+ ]
+ ]
+ );
+ }
+
+ if ($inspection->hasError()) {
+ $this->error(sprintf(
+ t('Failed to successfully validate the configuration: %s'),
+ $inspection->getError()
+ ));
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ protected function addSkipValidationCheckbox()
+ {
+ $this->addElement(
+ 'checkbox',
+ 'skip_validation',
+ [
+ 'ignore' => true,
+ 'label' => t('Skip Validation'),
+ 'description' => t('Check this to not to validate the configuration')
+ ]
+ );
+ }
+}
diff --git a/library/Icingadb/Setup/DbResourceStep.php b/library/Icingadb/Setup/DbResourceStep.php
new file mode 100644
index 0000000..970d367
--- /dev/null
+++ b/library/Icingadb/Setup/DbResourceStep.php
@@ -0,0 +1,148 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Setup\Step;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+
+class DbResourceStep extends Step
+{
+ /** @var array */
+ protected $data;
+
+ /** @var Exception */
+ protected $error;
+
+ public function __construct(array $data)
+ {
+ $this->data = $data;
+ }
+
+ public function apply()
+ {
+ $resourceConfig = $this->data;
+ $resourceName = $resourceConfig['name'];
+ unset($resourceConfig['name']);
+
+ try {
+ $config = Config::app('resources', true);
+ $config->setSection($resourceName, $resourceConfig);
+ $config->saveIni();
+ } catch (Exception $e) {
+ $this->error = $e;
+ return false;
+ }
+
+ try {
+ $config = Config::module('icingadb', 'config', true);
+ $config->setSection('icingadb', ['resource' => $resourceName]);
+ $config->saveIni();
+ } catch (Exception $e) {
+ $this->error = $e;
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getSummary()
+ {
+ $description = new HtmlElement('p', null, Text::create(mt(
+ 'icingadb',
+ 'Icinga DB will be accessed using the following connection details:'
+ )));
+
+ $resourceOptions = new Table();
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Host'))),
+ $this->data['host']
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Port'))),
+ $this->data['port'] ?: ($this->data['db'] === 'mysql' ? 3306 : 5432)
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Database'))),
+ $this->data['dbname']
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Username'))),
+ $this->data['username']
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Password'))),
+ str_repeat('*', strlen($this->data['password']))
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Charset'))),
+ $this->data['charset']
+ ]));
+
+ if (isset($this->data['use_ssl']) && $this->data['use_ssl']) {
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL Do Not Verify Server Certificate'))),
+ isset($this->data['ssl_do_not_verify_server_cert']) && $this->data['ssl_do_not_verify_server_cert']
+ ? t('Yes')
+ : t('No')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL Key'))),
+ $this->data['ssl_key'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL Certificate'))),
+ $this->data['ssl_cert'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL CA'))),
+ $this->data['ssl_ca'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('The CA certificate file path'))),
+ $this->data['ssl_capath'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL CA Path'))),
+ $this->data['ssl_cipher'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ }
+
+ $topic = new HtmlElement('div', Attributes::create(['class' => 'topic']));
+ $topic->addHtml($description, $resourceOptions);
+
+ $summary = new HtmlDocument();
+ $summary->addHtml(
+ new HtmlElement('h2', null, Text::create(mt('icingadb', 'Icinga DB Resource'))),
+ $topic
+ );
+
+ return $summary->render();
+ }
+
+ public function getReport()
+ {
+ if ($this->error === null) {
+ return [sprintf(
+ mt('icingadb', 'Resource configuration update successful: %s'),
+ Config::resolvePath('resources.ini')
+ )];
+ } else {
+ return [
+ sprintf(
+ mt('icingadb', 'Resource configuration update failed: %s'),
+ Config::resolvePath('resources.ini')
+ ),
+ sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error))
+ ];
+ }
+ }
+}
diff --git a/library/Icingadb/Setup/IcingaDbWizard.php b/library/Icingadb/Setup/IcingaDbWizard.php
new file mode 100644
index 0000000..f99f240
--- /dev/null
+++ b/library/Icingadb/Setup/IcingaDbWizard.php
@@ -0,0 +1,89 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Setup\Forms\SummaryPage;
+use Icinga\Module\Setup\Requirement\PhpModuleRequirement;
+use Icinga\Module\Setup\Requirement\WebLibraryRequirement;
+use Icinga\Module\Setup\RequirementSet;
+use Icinga\Module\Setup\Setup;
+use Icinga\Module\Setup\SetupWizard;
+use Icinga\Web\Form;
+use Icinga\Web\Request;
+use Icinga\Web\Wizard;
+
+class IcingaDbWizard extends Wizard implements SetupWizard
+{
+ protected function init()
+ {
+ $this->addPage(new WelcomePage());
+ $this->addPage(new DbResourcePage());
+ $this->addPage(new RedisPage());
+ $this->addPage(new ApiTransportPage());
+ $this->addPage(new SummaryPage(['name' => 'setup_icingadb_summary']));
+ }
+
+ public function setupPage(Form $page, Request $request)
+ {
+ if ($page->getName() === 'setup_icingadb_summary') {
+ $page->setSummary($this->getSetup()->getSummary());
+ $page->setSubjectTitle('Icinga DB Web');
+ }
+ }
+
+ public function getSetup()
+ {
+ $pageData = $this->getPageData();
+ $setup = new Setup();
+
+ $setup->addStep(new DbResourceStep($pageData['setup_icingadb_resource']));
+ $setup->addStep(new RedisStep($pageData['setup_icingadb_redis']));
+ $setup->addStep(new ApiTransportStep($pageData['setup_icingadb_api_transport']));
+
+ return $setup;
+ }
+
+ public function getRequirements()
+ {
+ $set = new RequirementSet();
+
+ $requiredVersions = Icinga::app()->getModuleManager()->getModule('icingadb')->getRequiredLibraries();
+
+ $set->add(new WebLibraryRequirement([
+ 'condition' => ['icinga-php-library', '', $requiredVersions['icinga-php-library']],
+ 'alias' => 'Icinga PHP library',
+ 'description' => t('The Icinga PHP library (IPL) is required for Icinga DB Web')
+ ]));
+
+ $set->add(new WebLibraryRequirement([
+ 'condition' => ['icinga-php-thirdparty', '', $requiredVersions['icinga-php-thirdparty']],
+ 'alias' => 'Icinga PHP Thirdparty',
+ 'description' => t('The Icinga PHP Thirdparty library is required for Icinga DB Web')
+ ]));
+
+ $set->add(new PhpModuleRequirement([
+ 'condition' => 'libxml',
+ 'alias' => 'libxml',
+ 'description' => t('For check plugins that output HTML the libxml extension is required')
+ ]));
+
+ $set->add(new PhpModuleRequirement([
+ 'condition' => 'dom',
+ 'alias' => 'dom',
+ 'description' => t('For check plugins that output HTML the dom extension is required')
+ ]));
+
+ $set->add(new PhpModuleRequirement([
+ 'condition' => 'curl',
+ 'alias' => 'cURL',
+ 'description' => t(
+ 'To send external commands over Icinga 2\'s API, the cURL module for PHP is required.'
+ )
+ ]));
+
+ return $set;
+ }
+}
diff --git a/library/Icingadb/Setup/RedisPage.php b/library/Icingadb/Setup/RedisPage.php
new file mode 100644
index 0000000..3c0a741
--- /dev/null
+++ b/library/Icingadb/Setup/RedisPage.php
@@ -0,0 +1,68 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Module\Icingadb\Forms\RedisConfigForm;
+use Icinga\Web\Form;
+
+class RedisPage extends Form
+{
+ public function init()
+ {
+ $this->setName('setup_icingadb_redis');
+ $this->setTitle(t('Icinga DB Redis'));
+ $this->addDescription(t(
+ 'Please fill out the connection details to access the Icinga DB Redis.'
+ ));
+ $this->setValidatePartial(true);
+ }
+
+ public function createElements(array $formData)
+ {
+ $redisConfigForm = new RedisConfigForm();
+ $redisConfigForm->createElements($formData);
+ if (isset($formData['redis_tls']) && $formData['redis_tls']) {
+ $redisConfigForm->getElement('redis_ca_pem')->setIgnore(false);
+ $redisConfigForm->getElement('redis_cert_pem')->setIgnore(false);
+ $redisConfigForm->getElement('redis_key_pem')->setIgnore(false);
+ }
+
+ $this->addElements($redisConfigForm->getElements());
+ $this->addDisplayGroups($redisConfigForm->getDisplayGroups());
+ }
+
+ public function isValid($formData)
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (($el = $this->getElement('skip_validation')) === null || ! $el->isChecked()) {
+ if (! RedisConfigForm::checkRedis($this)) {
+ if ($el === null) {
+ RedisConfigForm::addSkipValidationCheckbox($this);
+ RedisConfigForm::addInsecureCheckboxIfTls($this);
+ }
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function isValidPartial(array $formData)
+ {
+ if (! parent::isValidPartial($formData)) {
+ return false;
+ }
+
+ if (isset($formData['backend_validation'])) {
+ return RedisConfigForm::checkRedis($this);
+ }
+
+ return true;
+ }
+}
diff --git a/library/Icingadb/Setup/RedisStep.php b/library/Icingadb/Setup/RedisStep.php
new file mode 100644
index 0000000..97e50e0
--- /dev/null
+++ b/library/Icingadb/Setup/RedisStep.php
@@ -0,0 +1,205 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotWritableError;
+use Icinga\File\Storage\LocalFileStorage;
+use Icinga\Module\Setup\Step;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+
+class RedisStep extends Step
+{
+ /** @var array */
+ protected $data;
+
+ /** @var Exception */
+ protected $error;
+
+ public function __construct(array $data)
+ {
+ $this->data = $data;
+ }
+
+ public function apply()
+ {
+ $moduleConfig = [
+ 'redis' => [
+ 'tls' => 0
+ ]
+ ];
+ $redisConfig = [
+ 'redis1' => [
+ 'host' => $this->data['redis1_host'],
+ 'port' => $this->data['redis1_port'] ?: null,
+ 'password' => $this->data['redis1_password'] ?: null
+ ]
+ ];
+ if (isset($this->data['redis2_host']) && $this->data['redis2_host']) {
+ $redisConfig['redis2'] = [
+ 'host' => $this->data['redis2_host'],
+ 'port' => $this->data['redis2_port'] ?: null,
+ 'password' => $this->data['redis2_password'] ?: null
+ ];
+ }
+
+ if (isset($this->data['redis_tls']) && $this->data['redis_tls']) {
+ $moduleConfig['redis']['tls'] = 1;
+ if (isset($this->data['redis_insecure']) && $this->data['redis_insecure']) {
+ $moduleConfig['redis']['insecure'] = 1;
+ }
+
+ $storage = new LocalFileStorage(Icinga::app()->getStorageDir(
+ join(DIRECTORY_SEPARATOR, ['modules', 'icingadb', 'redis'])
+ ));
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $textareaName = 'redis_' . $name . '_pem';
+ if (isset($this->data[$textareaName]) && $this->data[$textareaName]) {
+ $pem = $this->data[$textareaName];
+ $pemFile = md5($pem) . '-' . $name . '.pem';
+ if (! $storage->has($pemFile)) {
+ try {
+ $storage->create($pemFile, $pem);
+ } catch (NotWritableError $e) {
+ $this->error = $e;
+ return false;
+ }
+ }
+
+ $moduleConfig['redis'][$name] = $storage->resolvePath($pemFile);
+ }
+ }
+ }
+
+ try {
+ $config = Config::module('icingadb', 'config', true);
+ foreach ($moduleConfig as $section => $options) {
+ $config->setSection($section, $options);
+ }
+
+ $config->saveIni();
+
+ $config = Config::module('icingadb', 'redis', true);
+ foreach ($redisConfig as $section => $options) {
+ $config->setSection($section, $options);
+ }
+
+ $config->saveIni();
+ } catch (Exception $e) {
+ $this->error = $e;
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getSummary()
+ {
+ $topic = new HtmlElement('div', Attributes::create(['class' => 'topic']));
+ $topic->addHtml(new HtmlElement('p', null, Text::create(mt(
+ 'icingadb',
+ 'The Icinga DB Redis will be accessed using the following connection details:'
+ ))));
+
+ $primaryOptions = new Table();
+ $primaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Host'))),
+ $this->data['redis1_host']
+ ]));
+ $primaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Port'))),
+ $this->data['redis1_port'] ?: 6380
+ ]));
+ $primaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Password'))),
+ $this->data['redis1_password'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+
+ if (isset($this->data['redis2_host']) && $this->data['redis2_host']) {
+ $topic->addHtml(
+ new HtmlElement('h3', null, Text::create(mt('icingadb', 'Primary'))),
+ $primaryOptions
+ );
+
+ $secondaryOptions = new Table();
+ $secondaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Host'))),
+ $this->data['redis2_host']
+ ]));
+ $secondaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Port'))),
+ $this->data['redis2_port'] ?: 6380
+ ]));
+ $secondaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Password'))),
+ $this->data['redis2_password'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+
+ $topic->addHtml(
+ new HtmlElement('h3', null, Text::create(mt('icingadb', 'Secondary'))),
+ $secondaryOptions
+ );
+ } else {
+ $topic->addHtml($primaryOptions);
+ }
+
+ $tlsOptions = new Table();
+ $topic->addHtml($tlsOptions);
+ if (isset($this->data['redis_tls']) && $this->data['redis_tls']) {
+ if (isset($this->data['redis_cert_pem']) && $this->data['redis_cert_pem']) {
+ $tlsOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create('TLS')),
+ Text::create(
+ t('Icinga DB Web will authenticate against Redis with a client'
+ . ' certificate and private key over a secured connection')
+ )
+ ]));
+ } else {
+ $tlsOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create('TLS')),
+ Text::create(t('Icinga DB Web will use secured Redis connections'))
+ ]));
+ }
+ } else {
+ $tlsOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create('TLS')),
+ Text::create(t('No'))
+ ]));
+ }
+
+ $summary = new HtmlDocument();
+ $summary->addHtml(
+ new HtmlElement('h2', null, Text::create(mt('icingadb', 'Icinga DB Redis'))),
+ $topic
+ );
+
+ return $summary->render();
+ }
+
+ public function getReport()
+ {
+ if ($this->error === null) {
+ return [sprintf(
+ mt('icingadb', 'Module configuration update successful: %s'),
+ Config::module('icingab')->getConfigFile()
+ )];
+ } else {
+ return [
+ sprintf(
+ mt('icingadb', 'Module configuration update failed: %s'),
+ Config::module('icingab')->getConfigFile()
+ ),
+ sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error))
+ ];
+ }
+ }
+}
diff --git a/library/Icingadb/Setup/WelcomePage.php b/library/Icingadb/Setup/WelcomePage.php
new file mode 100644
index 0000000..9f97c7d
--- /dev/null
+++ b/library/Icingadb/Setup/WelcomePage.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Web\Form;
+
+class WelcomePage extends Form
+{
+ public function init()
+ {
+ $this->setName('setup_icingadb_welcome');
+ }
+
+ public function createElements(array $formData)
+ {
+ $this->addElement(
+ 'note',
+ 'welcome',
+ array(
+ 'value' => t(
+ 'Welcome to the configuration of Icinga DB Web!'
+ ),
+ 'decorators' => array(
+ 'ViewHelper',
+ array('HtmlTag', array('tag' => 'h2'))
+ )
+ )
+ );
+
+ $this->addElement(
+ 'note',
+ 'description_1',
+ array(
+ 'value' => '<p>' . t(
+ 'Icinga DB Web is the UI for Icinga DB and provides'
+ . ' a graphical interface to your monitoring environment.'
+ ) . '</p>',
+ 'decorators' => array('ViewHelper')
+ )
+ );
+
+ $this->addElement(
+ 'note',
+ 'description_2',
+ array(
+ 'value' => '<p>' . t(
+ 'The wizard will guide you through the configuration to'
+ . ' establish a connection with Icinga DB and Icinga 2.'
+ ) . '</p>',
+ 'decorators' => array('ViewHelper')
+ )
+ );
+ }
+}
diff --git a/library/Icingadb/Util/FeatureStatus.php b/library/Icingadb/Util/FeatureStatus.php
new file mode 100644
index 0000000..94bf6d4
--- /dev/null
+++ b/library/Icingadb/Util/FeatureStatus.php
@@ -0,0 +1,50 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use ArrayObject;
+use Icinga\Module\Icingadb\Command\Object\ToggleObjectFeatureCommand;
+
+class FeatureStatus extends ArrayObject
+{
+ public function __construct(string $type, $summary)
+ {
+ $prefix = "{$type}s";
+
+ $featureStatus = [
+ ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS =>
+ $this->getFeatureStatus('active_checks_enabled', $prefix, $summary),
+ ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS =>
+ $this->getFeatureStatus('passive_checks_enabled', $prefix, $summary),
+ ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS =>
+ $this->getFeatureStatus('notifications_enabled', $prefix, $summary),
+ ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER =>
+ $this->getFeatureStatus('event_handler_enabled', $prefix, $summary),
+ ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION =>
+ $this->getFeatureStatus('flapping_enabled', $prefix, $summary)
+ ];
+
+ parent::__construct($featureStatus, ArrayObject::ARRAY_AS_PROPS);
+ }
+
+ protected function getFeatureStatus(string $feature, string $prefix, $summary): int
+ {
+ $key = "{$prefix}_{$feature}";
+ $value = (int) $summary->$key;
+
+ if ($value === 0) {
+ return 0;
+ }
+
+ $totalKey = "{$prefix}_total";
+ $total = (int) $summary->$totalKey;
+
+ if ($value === $total) {
+ return 1;
+ }
+
+ return 2;
+ }
+}
diff --git a/library/Icingadb/Util/ObjectSuggestionsCursor.php b/library/Icingadb/Util/ObjectSuggestionsCursor.php
new file mode 100644
index 0000000..0013b35
--- /dev/null
+++ b/library/Icingadb/Util/ObjectSuggestionsCursor.php
@@ -0,0 +1,25 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use ipl\Sql\Cursor;
+use Iterator;
+
+class ObjectSuggestionsCursor extends Cursor
+{
+ public function getIterator(): \Traversable
+ {
+ foreach (parent::getIterator() as $key => $value) {
+ // TODO(lippserd): This is a quick and dirty fix for PostgreSQL binary datatypes for which PDO returns
+ // PHP resources that would cause exceptions since resources are not a valid type for attribute values.
+ // We need to do it this way as the suggestion implementation bypasses ORM behaviors here and there.
+ if (is_resource($value)) {
+ $value = stream_get_contents($value);
+ }
+
+ yield $key => $value;
+ }
+ }
+}
diff --git a/library/Icingadb/Util/PerfData.php b/library/Icingadb/Util/PerfData.php
new file mode 100644
index 0000000..cc33d16
--- /dev/null
+++ b/library/Icingadb/Util/PerfData.php
@@ -0,0 +1,703 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Web\Widget\Chart\InlinePie;
+use InvalidArgumentException;
+use LogicException;
+
+class PerfData
+{
+ const PERFDATA_OK = 'ok';
+ const PERFDATA_WARNING = 'warning';
+ const PERFDATA_CRITICAL = 'critical';
+
+ /**
+ * The performance data value being parsed
+ *
+ * @var string
+ */
+ protected $perfdataValue;
+
+ /**
+ * Unit of measurement (UOM)
+ *
+ * @var string
+ */
+ protected $unit;
+
+ /**
+ * The label
+ *
+ * @var string
+ */
+ protected $label;
+
+ /**
+ * The value
+ *
+ * @var ?float
+ */
+ protected $value;
+
+ /**
+ * The minimum value
+ *
+ * @var ?float
+ */
+ protected $minValue;
+
+ /**
+ * The maximum value
+ *
+ * @var ?float
+ */
+ protected $maxValue;
+
+ /**
+ * The WARNING threshold
+ *
+ * @var ThresholdRange
+ */
+ protected $warningThreshold;
+
+ /**
+ * The CRITICAL threshold
+ *
+ * @var ThresholdRange
+ */
+ protected $criticalThreshold;
+
+ /**
+ * The raw value
+ *
+ * @var ?string
+ */
+ protected $rawValue;
+
+ /**
+ * The raw minimum value
+ *
+ * @var ?string
+ */
+ protected $rawMinValue;
+
+ /**
+ * The raw maximum value
+ *
+ * @var ?string
+ */
+ protected $rawMaxValue;
+
+ /**
+ * Create a new PerfData object based on the given performance data label and value
+ *
+ * @param string $label The perfdata label
+ * @param string $value The perfdata value
+ */
+ public function __construct(string $label, string $value)
+ {
+ $this->perfdataValue = $value;
+ $this->label = $label;
+ $this->parse();
+
+ if ($this->unit === '%') {
+ if ($this->minValue === null) {
+ $this->minValue = 0.0;
+ }
+ if ($this->maxValue === null) {
+ $this->maxValue = 100.0;
+ }
+ }
+
+ $warn = $this->warningThreshold->getMax();
+ if ($warn !== null) {
+ $crit = $this->criticalThreshold->getMax();
+ if ($crit !== null && $warn > $crit) {
+ $this->warningThreshold->setInverted();
+ $this->criticalThreshold->setInverted();
+ }
+ }
+ }
+
+ /**
+ * Return a new PerfData object based on the given performance data key=value pair
+ *
+ * @param string $perfdata The key=value pair to parse
+ *
+ * @return PerfData
+ *
+ * @throws InvalidArgumentException In case the given performance data has no content or a invalid format
+ */
+ public static function fromString(string $perfdata): self
+ {
+ if (empty($perfdata)) {
+ throw new InvalidArgumentException('PerfData::fromString expects a string with content');
+ } elseif (strpos($perfdata, '=') === false) {
+ throw new InvalidArgumentException(
+ 'PerfData::fromString expects a key=value formatted string. Got "' . $perfdata . '" instead'
+ );
+ }
+
+ list($label, $value) = explode('=', $perfdata, 2);
+ return new static(trim($label), trim($value));
+ }
+
+ /**
+ * Return whether this performance data's value is a number
+ *
+ * @return bool True in case it's a number, otherwise False
+ */
+ public function isNumber(): bool
+ {
+ return $this->unit === null;
+ }
+
+ /**
+ * Return whether this performance data's value are seconds
+ *
+ * @return bool True in case it's seconds, otherwise False
+ */
+ public function isSeconds(): bool
+ {
+ return $this->unit === 's';
+ }
+
+ /**
+ * Return whether this performance data's value is a temperature
+ *
+ * @return bool True in case it's temperature, otherwise False
+ */
+ public function isTemperature(): bool
+ {
+ return in_array($this->unit, array('C', 'F', 'K'));
+ }
+
+ /**
+ * Return whether this performance data's value is in percentage
+ *
+ * @return bool True in case it's in percentage, otherwise False
+ */
+ public function isPercentage(): bool
+ {
+ return $this->unit === '%';
+ }
+
+ /**
+ * Get whether this perf data's value is in packets
+ *
+ * @return bool True in case it's in packets
+ */
+ public function isPackets(): bool
+ {
+ return $this->unit === 'packets';
+ }
+
+ /**
+ * Get whether this perf data's value is in lumen
+ *
+ * @return bool
+ */
+ public function isLumens(): bool
+ {
+ return $this->unit === 'lm';
+ }
+
+ /**
+ * Get whether this perf data's value is in decibel-milliwatts
+ *
+ * @return bool
+ */
+ public function isDecibelMilliWatts(): bool
+ {
+ return $this->unit === 'dBm';
+ }
+
+ /**
+ * Get whether this data's value is in bits
+ *
+ * @return bool
+ */
+ public function isBits(): bool
+ {
+ return $this->unit === 'b';
+ }
+
+ /**
+ * Return whether this performance data's value is in bytes
+ *
+ * @return bool True in case it's in bytes, otherwise False
+ */
+ public function isBytes(): bool
+ {
+ return $this->unit === 'B';
+ }
+
+ /**
+ * Get whether this data's value is in watt hours
+ *
+ * @return bool
+ */
+ public function isWattHours(): bool
+ {
+ return $this->unit === 'Wh';
+ }
+
+ /**
+ * Get whether this data's value is in watt
+ *
+ * @return bool
+ */
+ public function isWatts(): bool
+ {
+ return $this->unit === 'W';
+ }
+
+ /**
+ * Get whether this data's value is in ampere
+ *
+ * @return bool
+ */
+ public function isAmperes(): bool
+ {
+ return $this->unit === 'A';
+ }
+
+ /**
+ * Get whether this data's value is in ampere seconds
+ *
+ * @return bool
+ */
+ public function isAmpSeconds(): bool
+ {
+ return $this->unit === 'As';
+ }
+
+ /**
+ * Get whether this data's value is in volts
+ *
+ * @return bool
+ */
+ public function isVolts(): bool
+ {
+ return $this->unit === 'V';
+ }
+
+ /**
+ * Get whether this data's value is in ohm
+ *
+ * @return bool
+ */
+ public function isOhms(): bool
+ {
+ return $this->unit === 'O';
+ }
+
+ /**
+ * Get whether this data's value is in grams
+ *
+ * @return bool
+ */
+ public function isGrams(): bool
+ {
+ return $this->unit === 'g';
+ }
+
+ /**
+ * Get whether this data's value is in Litters
+ *
+ * @return bool
+ */
+ public function isLiters(): bool
+ {
+ return $this->unit === 'l';
+ }
+
+ /**
+ * Return whether this performance data's value is a counter
+ *
+ * @return bool True in case it's a counter, otherwise False
+ */
+ public function isCounter(): bool
+ {
+ return $this->unit === 'c';
+ }
+
+ /**
+ * Returns whether it is possible to display a visual representation
+ *
+ * @return bool True when the perfdata is visualizable
+ */
+ public function isVisualizable(): bool
+ {
+ return isset($this->minValue, $this->maxValue, $this->value) && $this->isValid();
+ }
+
+ /**
+ * Return this perfomance data's label
+ */
+ public function getLabel(): string
+ {
+ return $this->label;
+ }
+
+ /**
+ * Return the value or null if it is unknown (U)
+ *
+ * @return null|float
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Return the unit as a string
+ *
+ * @return ?string
+ */
+ public function getUnit()
+ {
+ return $this->unit;
+ }
+
+ /**
+ * Return the value as percentage (0-100)
+ *
+ * @return null|float
+ */
+ public function getPercentage()
+ {
+ if ($this->isPercentage()) {
+ return $this->value;
+ }
+
+ if ($this->maxValue !== null) {
+ $minValue = $this->minValue !== null ? $this->minValue : 0.0;
+ if ($this->maxValue == $minValue) {
+ return null;
+ }
+
+ if ($this->value > $minValue) {
+ return (($this->value - $minValue) / ($this->maxValue - $minValue)) * 100;
+ }
+ }
+ }
+
+ /**
+ * Return this performance data's warning treshold
+ *
+ * @return ThresholdRange
+ */
+ public function getWarningThreshold(): ThresholdRange
+ {
+ return $this->warningThreshold;
+ }
+
+ /**
+ * Return this performance data's critical treshold
+ *
+ * @return ThresholdRange
+ */
+ public function getCriticalThreshold(): ThresholdRange
+ {
+ return $this->criticalThreshold;
+ }
+
+ /**
+ * Return the minimum value or null if it is not available
+ *
+ * @return ?float
+ */
+ public function getMinimumValue()
+ {
+ return $this->minValue;
+ }
+
+ /**
+ * Return the maximum value or null if it is not available
+ *
+ * @return null|float
+ */
+ public function getMaximumValue()
+ {
+ return $this->maxValue;
+ }
+
+ /**
+ * Return this performance data as string
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->formatLabel();
+ }
+
+ /**
+ * Parse the current performance data value
+ *
+ * @todo Handle optional min/max if UOM == %
+ */
+ protected function parse()
+ {
+ $parts = explode(';', $this->perfdataValue);
+
+ $matches = array();
+ if (preg_match('@^(U|-?(?:\d+)?(?:\.\d+)?)([a-zA-TV-Z%°]{1,3})$@u', $parts[0], $matches)) {
+ $this->unit = $matches[2];
+ $value = $matches[1];
+ } else {
+ $value = $parts[0];
+ }
+
+ if (! is_numeric($value)) {
+ if ($value !== 'U') {
+ $this->rawValue = $parts[0];
+ }
+
+ $this->value = null;
+ } else {
+ $this->value = floatval($value);
+ }
+
+ switch (count($parts)) {
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 5:
+ if ($parts[4] !== '') {
+ if (is_numeric($parts[4])) {
+ $this->maxValue = floatval($parts[4]);
+ } else {
+ $this->rawMaxValue = $parts[4];
+ }
+ }
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 4:
+ if ($parts[3] !== '') {
+ if (is_numeric($parts[3])) {
+ $this->minValue = floatval($parts[3]);
+ } else {
+ $this->rawMinValue = $parts[3];
+ }
+ }
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 3:
+ $this->criticalThreshold = ThresholdRange::fromString(trim($parts[2]));
+ // Fallthrough
+ case 2:
+ $this->warningThreshold = ThresholdRange::fromString(trim($parts[1]));
+ }
+
+ if ($this->warningThreshold === null) {
+ $this->warningThreshold = new ThresholdRange();
+ }
+ if ($this->criticalThreshold === null) {
+ $this->criticalThreshold = new ThresholdRange();
+ }
+ }
+
+ protected function calculatePieChartData(): array
+ {
+ $rawValue = $this->getValue();
+ $minValue = $this->getMinimumValue() !== null ? $this->getMinimumValue() : 0;
+ $usedValue = ($rawValue - $minValue);
+
+ $green = $orange = $red = 0;
+
+ if ($this->criticalThreshold->contains($rawValue)) {
+ if ($this->warningThreshold->contains($rawValue)) {
+ $green = $usedValue;
+ } else {
+ $orange = $usedValue;
+ }
+ } else {
+ $red = $usedValue;
+ }
+
+ return array($green, $orange, $red, ($this->getMaximumValue() - $minValue) - $usedValue);
+ }
+
+
+ public function asInlinePie(): InlinePie
+ {
+ if (! $this->isVisualizable()) {
+ throw new LogicException('Cannot calculate piechart data for unvisualizable perfdata entry.');
+ }
+
+ $data = $this->calculatePieChartData();
+ $pieChart = new InlinePie($data, $this);
+ $pieChart->setColors(array('#44bb77', '#ffaa44', '#ff5566', '#ddccdd'));
+
+ return $pieChart;
+ }
+
+ /**
+ * Format the given value depending on the currently used unit
+ */
+ protected function format($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if ($value instanceof ThresholdRange) {
+ if (! $value->isValid()) {
+ return (string) $value;
+ }
+
+ if ($value->getMin()) {
+ return (string) $value;
+ }
+
+ $max = $value->getMax();
+ return $max === null ? '' : $this->format($max);
+ }
+
+ switch (true) {
+ case $this->isPercentage():
+ return (string) $value . '%';
+ case $this->isPackets():
+ return (string) $value . 'packets';
+ case $this->isLumens():
+ return (string) $value . 'lm';
+ case $this->isDecibelMilliWatts():
+ return (string) $value . 'dBm';
+ case $this->isCounter():
+ return (string) $value . 'c';
+ case $this->isTemperature():
+ return (string) $value . $this->unit;
+ case $this->isBits():
+ return PerfDataFormat::bits($value);
+ case $this->isBytes():
+ return PerfDataFormat::bytes($value);
+ case $this->isSeconds():
+ return PerfDataFormat::seconds($value);
+ case $this->isWatts():
+ return PerfDataFormat::watts($value);
+ case $this->isWattHours():
+ return PerfDataFormat::wattHours($value);
+ case $this->isAmperes():
+ return PerfDataFormat::amperes($value);
+ case $this->isAmpSeconds():
+ return PerfDataFormat::ampereSeconds($value);
+ case $this->isVolts():
+ return PerfDataFormat::volts($value);
+ case $this->isOhms():
+ return PerfDataFormat::ohms($value);
+ case $this->isGrams():
+ return PerfDataFormat::grams($value);
+ case $this->isLiters():
+ return PerfDataFormat::liters($value);
+ case ! is_numeric($value):
+ return $value;
+ default:
+ return number_format($value, 2) . ($this->unit !== null ? ' ' . $this->unit : '');
+ }
+ }
+
+ /**
+ * Format the title string that represents this perfdata set
+ *
+ * @param bool $html
+ *
+ * @return string
+ */
+ public function formatLabel(bool $html = false): string
+ {
+ return sprintf(
+ $html ? '<b>%s %s</b> (%s%%)' : '%s %s (%s%%)',
+ htmlspecialchars($this->getLabel()),
+ $this->format($this->value),
+ number_format($this->getPercentage() ?? 0, 2)
+ );
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'label' => $this->getLabel(),
+ 'value' => isset($this->value) ? $this->format($this->value) : $this->rawValue,
+ 'min' => (string) (
+ ! $this->isPercentage()
+ ? (isset($this->minValue) ? $this->format($this->minValue) : $this->rawMinValue)
+ : null
+ ),
+ 'max' => (string) (
+ ! $this->isPercentage()
+ ? (isset($this->maxValue) ? $this->format($this->maxValue) : $this->rawMaxValue)
+ : null
+ ),
+ 'warn' => $this->format($this->warningThreshold),
+ 'crit' => $this->format($this->criticalThreshold)
+ ];
+ }
+
+ /**
+ * Return the state indicated by this perfdata
+ *
+ * @return int
+ */
+ public function getState(): int
+ {
+ if (! is_numeric($this->value)) {
+ return ServiceStates::UNKNOWN;
+ }
+
+ if (! $this->criticalThreshold->contains($this->value)) {
+ return ServiceStates::CRITICAL;
+ }
+
+ if (! $this->warningThreshold->contains($this->value)) {
+ return ServiceStates::WARNING;
+ }
+
+ return ServiceStates::OK;
+ }
+
+ /**
+ * Return whether the state indicated by this perfdata is worse than
+ * the state indicated by the other perfdata
+ * CRITICAL > UNKNOWN > WARNING > OK
+ *
+ * @param PerfData $rhs the other perfdata
+ *
+ * @return bool
+ */
+ public function worseThan(PerfData $rhs): bool
+ {
+ if (($state = $this->getState()) === ($rhsState = $rhs->getState())) {
+ return $this->getPercentage() > $rhs->getPercentage();
+ }
+
+ if ($state === ServiceStates::CRITICAL) {
+ return true;
+ }
+
+ if ($state === ServiceStates::UNKNOWN) {
+ return $rhsState !== ServiceStates::CRITICAL;
+ }
+
+ if ($state === ServiceStates::WARNING) {
+ return $rhsState === ServiceStates::OK;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns whether the performance data can be evaluated
+ *
+ * @return bool
+ */
+ public function isValid(): bool
+ {
+ return ! isset($this->rawValue)
+ && ! isset($this->rawMinValue)
+ && ! isset($this->rawMaxValue)
+ && $this->criticalThreshold->isValid()
+ && $this->warningThreshold->isValid();
+ }
+}
diff --git a/library/Icingadb/Util/PerfDataFormat.php b/library/Icingadb/Util/PerfDataFormat.php
new file mode 100644
index 0000000..1caffff
--- /dev/null
+++ b/library/Icingadb/Util/PerfDataFormat.php
@@ -0,0 +1,171 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+class PerfDataFormat
+{
+ protected static $instance;
+
+ protected static $generalBase = 1000;
+
+ protected static $bitPrefix = ['b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb'];
+
+ protected static $bytePrefix = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+ protected static $wattHourPrefix = ['Wh', 'kWh', 'MWh', 'GWh', 'TWh', 'PWh', 'EWh', 'ZWh', 'YWh'];
+
+ protected static $wattPrefix = [-1 => 'mW', 'W', 'kW', 'MW', 'GW'];
+
+ protected static $amperePrefix = [-3 => 'nA', -2 => 'µA', -1 => 'mA', 'A', 'kA', 'MA', 'GA'];
+
+ protected static $ampSecondPrefix = [-2 => 'µAs', -1 => 'mAs', 'As', 'kAs', 'MAs', 'GAs'];
+
+ protected static $voltPrefix = [-2 => 'µV', -1 => 'mV', 'V', 'kV', 'MV', 'GV'];
+
+ protected static $ohmPrefix = ['Ω'];
+
+ protected static $gramPrefix = [
+ -5 => 'fg',
+ -4 => 'pg',
+ -3 => 'ng',
+ -2 => 'µg',
+ -1 => 'mg',
+ 'g',
+ 'kg',
+ 't',
+ 'ktǂ',
+ 'Mt',
+ 'Gt'
+ ];
+
+ protected static $literPrefix = [
+ -5 => 'fl',
+ -4 => 'pl',
+ -3 => 'nl',
+ -2 => 'µl',
+ -1 => 'ml',
+ 'l',
+ 'kl',
+ 'Ml',
+ 'Gl',
+ 'Tl',
+ 'Pl'
+ ];
+
+ protected static $secondPrefix = [-3 => 'ns', -2 => 'µs', -1 => 'ms', 's'];
+
+ public static function getInstance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new PerfDataFormat();
+ }
+
+ return self::$instance;
+ }
+
+ public static function bits($value): string
+ {
+ return self::formatForUnits($value, self::$bitPrefix, self::$generalBase);
+ }
+
+ public static function bytes($value): string
+ {
+ return self::formatForUnits($value, self::$bytePrefix, self::$generalBase);
+ }
+
+ public static function wattHours($value): string
+ {
+ return self::formatForUnits($value, self::$wattHourPrefix, self::$generalBase);
+ }
+
+ public static function watts($value): string
+ {
+ return self::formatForUnits($value, self::$wattPrefix, self::$generalBase);
+ }
+
+ public static function amperes($value): string
+ {
+ return self::formatForUnits($value, self::$amperePrefix, self::$generalBase);
+ }
+
+ public static function ampereSeconds($value): string
+ {
+ return self::formatForUnits($value, self::$ampSecondPrefix, self::$generalBase);
+ }
+
+ public static function volts($value): string
+ {
+ return self::formatForUnits($value, self::$voltPrefix, self::$generalBase);
+ }
+
+ public static function ohms($value): string
+ {
+ return self::formatForUnits($value, self::$ohmPrefix, self::$generalBase);
+ }
+
+ public static function grams($value): string
+ {
+ return self::formatForUnits($value, self::$gramPrefix, self::$generalBase);
+ }
+
+ public static function liters($value): string
+ {
+ return self::formatForUnits($value, self::$literPrefix, self::$generalBase);
+ }
+
+ public static function seconds($value): string
+ {
+ $value = (float) $value;
+ $absValue = abs($value);
+
+ if ($absValue < 60) {
+ return self::formatForUnits($value, self::$secondPrefix, self::$generalBase);
+ } elseif ($absValue < 3600) {
+ return sprintf('%0.2f m', $value / 60);
+ } elseif ($absValue < 86400) {
+ return sprintf('%0.2f h', $value / 3600);
+ }
+
+ return sprintf('%0.2f d', $value / 86400);
+ }
+
+ protected static function formatForUnits(float $value, array &$units, int $base): string
+ {
+ $sign = '';
+ if ($value < 0) {
+ $value = abs($value);
+ $sign = '-';
+ }
+
+ if ($value == 0) {
+ $pow = $result = 0;
+ } else {
+ $pow = floor(log($value, $base));
+
+ // Identify nearest unit if unknown
+ while (! isset($units[$pow])) {
+ if ($pow < 0) {
+ $pow++;
+ } else {
+ $pow--;
+ }
+ }
+
+ $result = $value / pow($base, $pow);
+ }
+
+ // 1034.23 looks better than 1.03, but 2.03 is fine:
+ if ($pow > 0 && $result < 2) {
+ $result = $value / pow($base, --$pow);
+ }
+
+ return sprintf(
+ '%s%0.2f %s',
+ $sign,
+ $result,
+ $units[$pow]
+ );
+ }
+}
diff --git a/library/Icingadb/Util/PerfDataSet.php b/library/Icingadb/Util/PerfDataSet.php
new file mode 100644
index 0000000..df31393
--- /dev/null
+++ b/library/Icingadb/Util/PerfDataSet.php
@@ -0,0 +1,172 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use ArrayIterator;
+use IteratorAggregate;
+
+class PerfDataSet implements IteratorAggregate
+{
+ /**
+ * The performance data being parsed
+ *
+ * @var string
+ */
+ protected $perfdataStr;
+
+ /**
+ * The current parsing position
+ *
+ * @var int
+ */
+ protected $parserPos = 0;
+
+ /**
+ * A list of PerfData objects
+ *
+ * @var array
+ */
+ protected $perfdata = array();
+
+ /**
+ * Create a new set of performance data
+ *
+ * @param string $perfdataStr A space separated list of label/value pairs
+ */
+ protected function __construct(string $perfdataStr)
+ {
+ if (($perfdataStr = trim($perfdataStr)) !== '') {
+ $this->perfdataStr = $perfdataStr;
+ $this->parse();
+ }
+ }
+
+ /**
+ * Return a iterator for this set of performance data
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): ArrayIterator
+ {
+ return new ArrayIterator($this->asArray());
+ }
+
+ /**
+ * Return a new set of performance data
+ *
+ * @param string $perfdataStr A space separated list of label/value pairs
+ *
+ * @return PerfDataSet
+ */
+ public static function fromString(string $perfdataStr): self
+ {
+ return new static($perfdataStr);
+ }
+
+ /**
+ * Return this set of performance data as array
+ *
+ * @return array
+ */
+ public function asArray(): array
+ {
+ return $this->perfdata;
+ }
+
+ /**
+ * Parse the current performance data
+ */
+ protected function parse()
+ {
+ while ($this->parserPos < strlen($this->perfdataStr)) {
+ $label = trim($this->readLabel());
+ $value = trim($this->readUntil(' '));
+
+ if ($label) {
+ $this->perfdata[] = new PerfData($label, $value);
+ }
+ }
+
+ uasort(
+ $this->perfdata,
+ function ($a, $b) {
+ if ($a->isVisualizable() && ! $b->isVisualizable()) {
+ return -1;
+ } elseif (! $a->isVisualizable() && $b->isVisualizable()) {
+ return 1;
+ } elseif (! $a->isVisualizable() && ! $b->isVisualizable()) {
+ return 0;
+ }
+
+ return $a->worseThan($b) ? -1 : ($b->worseThan($a) ? 1 : 0);
+ }
+ );
+ }
+
+ /**
+ * Return the next label found in the performance data
+ *
+ * @return string The label found
+ */
+ protected function readLabel(): string
+ {
+ $this->skipSpaces();
+ if (in_array($this->perfdataStr[$this->parserPos], array('"', "'"))) {
+ $quoteChar = $this->perfdataStr[$this->parserPos++];
+ $label = $this->readUntil($quoteChar, '=');
+ $this->parserPos++;
+
+ if ($this->perfdataStr[$this->parserPos] === '=') {
+ $this->parserPos++;
+ }
+ } else {
+ $label = $this->readUntil('=');
+ $this->parserPos++;
+ }
+
+ $this->skipSpaces();
+ return $label;
+ }
+
+ /**
+ * Return all characters between the current parser position and the given character
+ *
+ * @param string $stopChar The character on which to stop
+ * @param string $backtrackOn The character on which to backtrack
+ *
+ * @return string
+ */
+ protected function readUntil(string $stopChar, string $backtrackOn = null): string
+ {
+ $start = $this->parserPos;
+ $breakCharEncounteredAt = null;
+ $stringExhaustedAt = strlen($this->perfdataStr);
+ while ($this->parserPos < $stringExhaustedAt) {
+ if ($this->perfdataStr[$this->parserPos] === $stopChar) {
+ break;
+ } elseif ($breakCharEncounteredAt === null && $this->perfdataStr[$this->parserPos] === $backtrackOn) {
+ $breakCharEncounteredAt = $this->parserPos;
+ }
+
+ $this->parserPos++;
+ }
+
+ if ($breakCharEncounteredAt !== null && $this->parserPos === $stringExhaustedAt) {
+ $this->parserPos = $breakCharEncounteredAt;
+ }
+
+ return substr($this->perfdataStr, $start, $this->parserPos - $start);
+ }
+
+ /**
+ * Advance the parser position to the next non-whitespace character
+ */
+ protected function skipSpaces()
+ {
+ while ($this->parserPos < strlen($this->perfdataStr) && $this->perfdataStr[$this->parserPos] === ' ') {
+ $this->parserPos++;
+ }
+ }
+}
diff --git a/library/Icingadb/Util/PluginOutput.php b/library/Icingadb/Util/PluginOutput.php
new file mode 100644
index 0000000..71d08b1
--- /dev/null
+++ b/library/Icingadb/Util/PluginOutput.php
@@ -0,0 +1,260 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use DOMDocument;
+use DOMNode;
+use DOMText;
+use Icinga\Module\Icingadb\Hook\PluginOutputHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Web\Dom\DomNodeIterator;
+use Icinga\Web\Helper\HtmlPurifier;
+use InvalidArgumentException;
+use ipl\Html\HtmlString;
+use ipl\Orm\Model;
+use LogicException;
+use RecursiveIteratorIterator;
+
+class PluginOutput extends HtmlString
+{
+ /** @var string[] Patterns to be replaced in plain text plugin output */
+ const TEXT_PATTERNS = [
+ '~\\\t~',
+ '~\\\n~',
+ '~(\[|\()OK(\]|\))~',
+ '~(\[|\()WARNING(\]|\))~',
+ '~(\[|\()CRITICAL(\]|\))~',
+ '~(\[|\()UNKNOWN(\]|\))~',
+ '~(\[|\()UP(\]|\))~',
+ '~(\[|\()DOWN(\]|\))~',
+ '~\@{6,}~'
+ ];
+
+ /** @var string[] Replacements for {@see PluginOutput::TEXT_PATTERNS} */
+ const TEXT_REPLACEMENTS = [
+ "\t",
+ "\n",
+ '<span class="state-ball ball-size-m state-ok"></span>',
+ '<span class="state-ball ball-size-m state-warning"></span>',
+ '<span class="state-ball ball-size-m state-critical"></span>',
+ '<span class="state-ball ball-size-m state-unknown"></span>',
+ '<span class="state-ball ball-size-m state-up"></span>',
+ '<span class="state-ball ball-size-m state-down"></span>',
+ '@@@@@@'
+ ];
+
+ /** @var string[] Patterns to be replaced in html plugin output */
+ const HTML_PATTERNS = [
+ '~\\\t~',
+ '~\\\n~'
+ ];
+
+ /** @var string[] Replacements for {@see PluginOutput::HTML_PATTERNS} */
+ const HTML_REPLACEMENTS = [
+ "\t",
+ "\n"
+ ];
+
+ /** @var string Already rendered output */
+ protected $renderedOutput;
+
+ /** @var bool Whether the output contains HTML */
+ protected $isHtml;
+
+ /** @var bool Whether output will be enriched */
+ protected $enrichOutput = true;
+
+ /** @var string The name of the command that produced the output */
+ protected $commandName;
+
+ /**
+ * Get whether the output contains HTML
+ *
+ * Requires the output being already rendered.
+ *
+ * @return bool
+ *
+ * @throws LogicException In case the output hasn't been rendered yet
+ */
+ public function isHtml(): bool
+ {
+ if ($this->isHtml === null) {
+ if (empty($this->getContent())) {
+ // "Nothing" can't be HTML
+ return false;
+ }
+
+ throw new LogicException('Output not rendered yet');
+ }
+
+ return $this->isHtml;
+ }
+
+ /**
+ * Set whether the output should be enriched
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setEnrichOutput(bool $state = true): self
+ {
+ $this->enrichOutput = $state;
+
+ return $this;
+ }
+
+ /**
+ * Set name of the command that produced the output
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setCommandName(string $name): self
+ {
+ $this->commandName = $name;
+
+ return $this;
+ }
+
+ /**
+ * Render plugin output of the given object
+ *
+ * @param Host|Service $object
+ *
+ * @return static
+ *
+ * @throws InvalidArgumentException If $object is neither a host nor a service
+ */
+ public static function fromObject(Model $object): self
+ {
+ if (! $object instanceof Host && ! $object instanceof Service) {
+ throw new InvalidArgumentException(
+ sprintf('Object is not a host or service, got %s instead', get_class($object))
+ );
+ }
+
+ return (new static($object->state->output . "\n" . $object->state->long_output))
+ ->setCommandName($object->checkcommand_name);
+ }
+
+ public function render()
+ {
+ if ($this->renderedOutput !== null) {
+ return $this->renderedOutput;
+ }
+
+ $output = parent::render();
+ if (empty($output)) {
+ return '';
+ }
+
+ if ($this->commandName !== null) {
+ $output = PluginOutputHook::processOutput($output, $this->commandName, $this->enrichOutput);
+ }
+
+ if (preg_match('~<\w+(?>\s\w+=[^>]*)?>~', $output)) {
+ // HTML
+ $output = HtmlPurifier::process(preg_replace(
+ self::HTML_PATTERNS,
+ self::HTML_REPLACEMENTS,
+ $output
+ ));
+ $this->isHtml = true;
+ } else {
+ // Plaintext
+ $output = preg_replace(
+ self::TEXT_PATTERNS,
+ self::TEXT_REPLACEMENTS,
+ htmlspecialchars($output, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, null, false)
+ );
+ $this->isHtml = false;
+ }
+
+ $output = trim($output);
+
+ // Add zero-width space after commas which are not followed by a whitespace character
+ // in oder to help browsers to break words in plugin output
+ $output = preg_replace('/,(?=[^\s])/', ',&#8203;', $output);
+
+ if ($this->enrichOutput && $this->isHtml) {
+ $output = $this->processHtml($output);
+ }
+
+ $this->renderedOutput = $output;
+
+ return $output;
+ }
+
+ /**
+ * Replace color state information, if any
+ *
+ * @param string $html
+ *
+ * @todo Do we really need to create a DOM here? Or is a preg_replace like we do it for text also feasible?
+ * @return string
+ */
+ protected function processHtml(string $html): string
+ {
+ $pattern = '/[([](OK|WARNING|CRITICAL|UNKNOWN|UP|DOWN)[)\]]/';
+ $doc = new DOMDocument();
+ $doc->loadXML('<div>' . $html . '</div>', LIBXML_NOERROR | LIBXML_NOWARNING);
+ $dom = new RecursiveIteratorIterator(new DomNodeIterator($doc), RecursiveIteratorIterator::SELF_FIRST);
+
+ $nodesToRemove = [];
+ foreach ($dom as $node) {
+ /** @var DOMNode $node */
+ if ($node->nodeType !== XML_TEXT_NODE) {
+ continue;
+ }
+
+ $start = 0;
+ while (preg_match($pattern, $node->nodeValue, $match, PREG_OFFSET_CAPTURE, $start)) {
+ $offsetLeft = $match[0][1];
+ $matchLength = strlen($match[0][0]);
+ $leftLength = $offsetLeft - $start;
+
+ // if there is text before the match
+ if ($leftLength) {
+ // create node for leading text
+ $text = new DOMText(substr($node->nodeValue, $start, $leftLength));
+ $node->parentNode->insertBefore($text, $node);
+ }
+
+ // create the state ball for the match
+ $span = $doc->createElement('span');
+ $span->setAttribute(
+ 'class',
+ 'state-ball ball-size-m state-' . strtolower($match[1][0])
+ );
+ $node->parentNode->insertBefore($span, $node);
+
+ // start for next match
+ $start = $offsetLeft + $matchLength;
+ }
+
+ if ($start) {
+ // is there text left?
+ if (strlen($node->nodeValue) > $start) {
+ // create node for trailing text
+ $text = new DOMText(substr($node->nodeValue, $start));
+ $node->parentNode->insertBefore($text, $node);
+ }
+
+ // delete the old node later
+ $nodesToRemove[] = $node;
+ }
+ }
+
+ foreach ($nodesToRemove as $node) {
+ /** @var DOMNode $node */
+ $node->parentNode->removeChild($node);
+ }
+
+ return substr($doc->saveHTML(), 5, -7);
+ }
+}
diff --git a/library/Icingadb/Util/ThresholdRange.php b/library/Icingadb/Util/ThresholdRange.php
new file mode 100644
index 0000000..675697a
--- /dev/null
+++ b/library/Icingadb/Util/ThresholdRange.php
@@ -0,0 +1,213 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+/**
+ * The warning/critical threshold of a measured value
+ */
+class ThresholdRange
+{
+ /**
+ * The smallest value inside the range (null stands for -∞)
+ *
+ * @var float|null
+ */
+ protected $min;
+
+ /**
+ * The biggest value inside the range (null stands for ∞)
+ *
+ * @var float|null
+ */
+ protected $max;
+
+ /**
+ * Whether to invert the result of contains()
+ *
+ * @var bool
+ */
+ protected $inverted = false;
+
+ /**
+ * The unmodified range as passed to fromString()
+ *
+ * @var string
+ */
+ protected $raw;
+
+ /**
+ * Whether the threshold range is valid
+ *
+ * @var bool
+ */
+ protected $isValid = true;
+
+ /**
+ * Create a new instance based on a threshold range conforming to <https://nagios-plugins.org/doc/guidelines.html>
+ *
+ * @param string $rawRange
+ *
+ * @return ThresholdRange
+ */
+ public static function fromString(string $rawRange): self
+ {
+ $range = new static();
+ $range->raw = $rawRange;
+
+ if ($rawRange == '') {
+ return $range;
+ }
+
+ $rawRange = ltrim($rawRange);
+ if (substr($rawRange, 0, 1) === '@') {
+ $range->setInverted();
+ $rawRange = substr($rawRange, 1);
+ }
+
+ if (strpos($rawRange, ':') === false) {
+ $min = 0.0;
+ $max = trim($rawRange);
+ if (! is_numeric($max)) {
+ $range->isValid = false;
+ return $range;
+ }
+
+ $max = floatval(trim($rawRange));
+ } else {
+ list($min, $max) = explode(':', $rawRange, 2);
+ $min = trim($min);
+ $max = trim($max);
+
+ switch ($min) {
+ case '':
+ $min = 0.0;
+ break;
+ case '~':
+ $min = null;
+ break;
+ default:
+ if (! is_numeric($min)) {
+ $range->isValid = false;
+ return $range;
+ }
+
+ $min = floatval($min);
+ }
+
+ if (! empty($max) && ! is_numeric($max)) {
+ $range->isValid = false;
+ return $range;
+ }
+
+ $max = empty($max) ? null : floatval($max);
+ }
+
+ return $range->setMin($min)
+ ->setMax($max);
+ }
+
+ /**
+ * Set the smallest value inside the range (null stands for -∞)
+ *
+ * @param float|null $min
+ *
+ * @return $this
+ */
+ public function setMin(?float $min): self
+ {
+ $this->min = $min;
+ return $this;
+ }
+
+ /**
+ * Get the smallest value inside the range (null stands for -∞)
+ *
+ * @return float|null
+ */
+ public function getMin()
+ {
+ return $this->min;
+ }
+
+ /**
+ * Set the biggest value inside the range (null stands for ∞)
+ *
+ * @param float|null $max
+ *
+ * @return $this
+ */
+ public function setMax(?float $max): self
+ {
+ $this->max = $max;
+ return $this;
+ }
+
+ /**
+ * Get the biggest value inside the range (null stands for ∞)
+ *
+ * @return float|null
+ */
+ public function getMax()
+ {
+ return $this->max;
+ }
+
+ /**
+ * Set whether to invert the result of contains()
+ *
+ * @param bool $inverted
+ *
+ * @return $this
+ */
+ public function setInverted(bool $inverted = true): self
+ {
+ $this->inverted = $inverted;
+ return $this;
+ }
+
+ /**
+ * Get whether to invert the result of contains()
+ *
+ * @return bool
+ */
+ public function isInverted(): bool
+ {
+ return $this->inverted;
+ }
+
+ /**
+ * Return whether $value is inside $this
+ *
+ * @param float $value
+ *
+ * @return bool
+ */
+ public function contains(float $value): bool
+ {
+ return (bool) ($this->inverted ^ (
+ ($this->min === null || $this->min <= $value) && ($this->max === null || $this->max >= $value)
+ ));
+ }
+
+ /**
+ * Return whether the threshold range is valid
+ *
+ * @return bool
+ */
+ public function isValid()
+ {
+ return $this->isValid;
+ }
+
+ /**
+ * Return the textual representation of $this, suitable for fromString()
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return (string) $this->raw;
+ }
+}
diff --git a/library/Icingadb/Web/Control/GridViewModeSwitcher.php b/library/Icingadb/Web/Control/GridViewModeSwitcher.php
new file mode 100644
index 0000000..df5524b
--- /dev/null
+++ b/library/Icingadb/Web/Control/GridViewModeSwitcher.php
@@ -0,0 +1,38 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control;
+
+/**
+ * View mode switcher to toggle between grid and list view
+ */
+class GridViewModeSwitcher extends ViewModeSwitcher
+{
+ /** @var string Default view mode */
+ public const DEFAULT_VIEW_MODE = 'list';
+
+ /** @var array View mode-icon pairs */
+ public static $viewModes = [
+ 'list' => 'default',
+ 'grid' => 'grid'
+ ];
+
+ protected function getTitle(string $viewMode): string
+ {
+ $active = null;
+ $inactive = null;
+ switch ($viewMode) {
+ case 'list':
+ $active = t('List view active');
+ $inactive = t('Switch to list view');
+ break;
+ case 'grid':
+ $active = t('Grid view active');
+ $inactive = t('Switch to grid view');
+ break;
+ }
+
+ return $viewMode === $this->getViewMode() ? $active : $inactive;
+ }
+}
diff --git a/library/Icingadb/Web/Control/ProblemToggle.php b/library/Icingadb/Web/Control/ProblemToggle.php
new file mode 100644
index 0000000..c5aed82
--- /dev/null
+++ b/library/Icingadb/Web/Control/ProblemToggle.php
@@ -0,0 +1,74 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control;
+
+use ipl\Web\Common\FormUid;
+use ipl\Web\Compat\CompatForm;
+
+class ProblemToggle extends CompatForm
+{
+ use FormUid;
+
+ protected $filter;
+
+ protected $protector;
+
+ protected $defaultAttributes = [
+ 'name' => 'problem-toggle',
+ 'class' => 'icinga-form icinga-controls inline'
+ ];
+
+ public function __construct($filter)
+ {
+ $this->filter = $filter;
+ }
+
+ /**
+ * Set callback to protect ids with
+ *
+ * @param callable $protector
+ *
+ * @return $this
+ */
+ public function setIdProtector(callable $protector): self
+ {
+ $this->protector = $protector;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the toggle is checked
+ *
+ * @return bool
+ */
+ public function isChecked(): bool
+ {
+ $this->ensureAssembled();
+
+ return $this->getElement('problems')->isChecked();
+ }
+
+ protected function assemble()
+ {
+ $this->addElement('checkbox', 'problems', [
+ 'class' => 'autosubmit',
+ 'id' => $this->protectId('problems'),
+ 'label' => t('Problems Only'),
+ 'value' => $this->filter !== null
+ ]);
+
+ $this->add($this->createUidElement());
+ }
+
+ private function protectId($id)
+ {
+ if (is_callable($this->protector)) {
+ return call_user_func($this->protector, $id);
+ }
+
+ return $id;
+ }
+}
diff --git a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
new file mode 100644
index 0000000..b89e729
--- /dev/null
+++ b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
@@ -0,0 +1,406 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control\SearchBar;
+
+use Generator;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Util\ObjectSuggestionsCursor;
+use ipl\Html\HtmlElement;
+use ipl\Orm\Exception\InvalidColumnException;
+use ipl\Orm\Exception\InvalidRelationException;
+use ipl\Orm\Model;
+use ipl\Orm\Relation;
+use ipl\Orm\Relation\BelongsToMany;
+use ipl\Orm\Relation\HasOne;
+use ipl\Orm\Resolver;
+use ipl\Orm\UnionModel;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Seq;
+use ipl\Web\Control\SearchBar\SearchException;
+use ipl\Web\Control\SearchBar\Suggestions;
+use PDO;
+
+class ObjectSuggestions extends Suggestions
+{
+ use Auth;
+ use Database;
+
+ /** @var Model */
+ protected $model;
+
+ /** @var array */
+ protected $customVarSources;
+
+ public function __construct()
+ {
+ $this->customVarSources = [
+ 'checkcommand' => t('Checkcommand %s', '..<customvar-name>'),
+ 'eventcommand' => t('Eventcommand %s', '..<customvar-name>'),
+ 'host' => t('Host %s', '..<customvar-name>'),
+ 'hostgroup' => t('Hostgroup %s', '..<customvar-name>'),
+ 'notification' => t('Notification %s', '..<customvar-name>'),
+ 'notificationcommand' => t('Notificationcommand %s', '..<customvar-name>'),
+ 'service' => t('Service %s', '..<customvar-name>'),
+ 'servicegroup' => t('Servicegroup %s', '..<customvar-name>'),
+ 'timeperiod' => t('Timeperiod %s', '..<customvar-name>'),
+ 'user' => t('User %s', '..<customvar-name>'),
+ 'usergroup' => t('Usergroup %s', '..<customvar-name>')
+ ];
+ }
+
+ /**
+ * Set the model to show suggestions for
+ *
+ * @param string|Model $model
+ *
+ * @return $this
+ */
+ public function setModel($model): self
+ {
+ if (is_string($model)) {
+ $model = new $model();
+ }
+
+ $this->model = $model;
+
+ return $this;
+ }
+
+ /**
+ * Get the model to show suggestions for
+ *
+ * @return Model
+ */
+ public function getModel(): Model
+ {
+ if ($this->model === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->model;
+ }
+
+ protected function shouldShowRelationFor(string $column): bool
+ {
+ if (strpos($column, '.vars.') !== false) {
+ return false;
+ }
+
+ $tableName = $this->getModel()->getTableName();
+ $columnPath = explode('.', $column);
+
+ switch (count($columnPath)) {
+ case 3:
+ if ($columnPath[1] !== 'state' || ! in_array($tableName, ['host', 'service'])) {
+ return true;
+ }
+
+ // For host/service state relation columns apply the same rules
+ case 2:
+ return $columnPath[0] !== $tableName;
+ default:
+ return true;
+ }
+ }
+
+ protected function createQuickSearchFilter($searchTerm)
+ {
+ $model = $this->getModel();
+ $resolver = $model::on($this->getDb())->getResolver();
+
+ $quickFilter = Filter::any();
+ foreach ($model->getSearchColumns() as $column) {
+ $where = Filter::like($resolver->qualifyColumn($column, $model->getTableName()), $searchTerm);
+ $where->metaData()->set('columnLabel', $resolver->getColumnDefinition($where->getColumn())->getLabel());
+ $quickFilter->add($where);
+ }
+
+ return $quickFilter;
+ }
+
+ protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter)
+ {
+ $model = $this->getModel();
+ $query = $model::on($this->getDb());
+ $query->limit(static::DEFAULT_LIMIT);
+
+ if (strpos($column, ' ') !== false) {
+ // $column may be a label
+ list($path, $_) = Seq::find(
+ self::collectFilterColumns($query->getModel(), $query->getResolver()),
+ $column,
+ false
+ );
+ if ($path !== null) {
+ $column = $path;
+ }
+ }
+
+ $columnPath = $query->getResolver()->qualifyPath($column, $model->getTableName());
+ list($targetPath, $columnName) = preg_split('/(?<=vars)\.|\.(?=[^.]+$)/', $columnPath, 2);
+
+ $isCustomVar = false;
+ if (substr($targetPath, -5) === '.vars') {
+ $isCustomVar = true;
+ $targetPath = substr($targetPath, 0, -4) . 'customvar_flat';
+ }
+
+ if (strpos($targetPath, '.') !== false) {
+ try {
+ $query->with($targetPath); // TODO: Remove this, once ipl/orm does it as early
+ } catch (InvalidRelationException $e) {
+ throw new SearchException(sprintf(t('"%s" is not a valid relation'), $e->getRelation()));
+ }
+ }
+
+ if ($isCustomVar) {
+ $columnPath = $targetPath . '.flatvalue';
+ $query->filter(Filter::like($targetPath . '.flatname', $columnName));
+ }
+
+ $inputFilter = Filter::like($columnPath, $searchTerm);
+ $query->columns($columnPath);
+ $query->orderBy($columnPath);
+
+ // This had so many iterations, if it still doesn't work, consider removing it entirely :(
+ if ($searchFilter instanceof Filter\None) {
+ $query->filter($inputFilter);
+ } elseif ($searchFilter instanceof Filter\All) {
+ $searchFilter->add($inputFilter);
+
+ // There may be columns part of $searchFilter which target the base table. These must be
+ // optimized, otherwise they influence what we'll suggest to the user. (i.e. less)
+ // The $inputFilter on the other hand must not be optimized, which it wouldn't, but since
+ // we force optimization on its parent chain, we have to negate that.
+ $searchFilter->metaData()->set('forceOptimization', true);
+ $inputFilter->metaData()->set('forceOptimization', false);
+ } else {
+ $searchFilter = $inputFilter;
+ }
+
+ $query->filter($searchFilter);
+ $this->applyRestrictions($query);
+
+ try {
+ return (new ObjectSuggestionsCursor($query->getDb(), $query->assembleSelect()->distinct()))
+ ->setFetchMode(PDO::FETCH_COLUMN);
+ } catch (InvalidColumnException $e) {
+ throw new SearchException(sprintf(t('"%s" is not a valid column'), $e->getColumn()));
+ }
+ }
+
+ protected function fetchColumnSuggestions($searchTerm)
+ {
+ $model = $this->getModel();
+ $query = $model::on($this->getDb());
+
+ // Ordinary columns first
+ foreach (self::collectFilterColumns($model, $query->getResolver()) as $columnName => $columnMeta) {
+ yield $columnName => $columnMeta;
+ }
+
+ // Custom variables only after the columns are exhausted and there's actually a chance the user sees them
+ $titleAdded = false;
+ $parsedArrayVars = [];
+ foreach ($this->getDb()->select($this->queryCustomvarConfig($searchTerm)) as $customVar) {
+ $search = $name = $customVar->flatname;
+ if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
+ $name = substr($search, 0, -(strlen($matches[1]) + 2));
+ if (isset($parsedArrayVars[$name])) {
+ continue;
+ }
+
+ $parsedArrayVars[$name] = true;
+ $search = $name . '[*]';
+ }
+
+ foreach ($this->customVarSources as $relation => $label) {
+ if (isset($customVar->$relation)) {
+ if (! $titleAdded) {
+ $titleAdded = true;
+ $this->addHtml(HtmlElement::create(
+ 'li',
+ ['class' => static::SUGGESTION_TITLE_CLASS],
+ t('Custom Variables')
+ ));
+ }
+
+ yield $relation . '.vars.' . $search => sprintf($label, $name);
+ }
+ }
+ }
+ }
+
+ protected function matchSuggestion($path, $label, $searchTerm)
+ {
+ if (preg_match('/[_.](id|bin|checksum)$/', $path)) {
+ // Only suggest exotic columns if the user knows about them
+ $trimmedSearch = trim($searchTerm, ' *');
+ return substr($path, -strlen($trimmedSearch)) === $trimmedSearch;
+ }
+
+ return parent::matchSuggestion($path, $label, $searchTerm);
+ }
+
+ /**
+ * Create a query to fetch all available custom variables matching the given term
+ *
+ * @param string $searchTerm
+ *
+ * @return Select
+ */
+ protected function queryCustomvarConfig(string $searchTerm): Select
+ {
+ $customVars = CustomvarFlat::on($this->getDb());
+ $tableName = $customVars->getModel()->getTableName();
+ $resolver = $customVars->getResolver();
+
+ $scalarQueries = [];
+ $aggregates = ['flatname'];
+ foreach ($resolver->getRelations($customVars->getModel()) as $name => $relation) {
+ if (isset($this->customVarSources[$name]) && $relation instanceof BelongsToMany) {
+ $query = $customVars->createSubQuery(
+ $relation->getTarget(),
+ $resolver->qualifyPath($name, $tableName)
+ );
+
+ $this->applyRestrictions($query);
+
+ $aggregates[$name] = new Expression("MAX($name)");
+ $scalarQueries[$name] = $query->assembleSelect()
+ ->resetColumns()->columns(new Expression('1'))
+ ->limit(1);
+ }
+ }
+
+ $customVars->columns('flatname');
+ $this->applyRestrictions($customVars);
+ $customVars->filter(Filter::like('flatname', $searchTerm));
+ $idColumn = $resolver->qualifyColumn('id', $resolver->getAlias($customVars->getModel()));
+ $customVars = $customVars->assembleSelect();
+
+ $customVars->columns($scalarQueries);
+ $customVars->groupBy($idColumn);
+ $customVars->limit(static::DEFAULT_LIMIT);
+
+ // This outer query exists only because there's no way to combine aggregates and sub queries (yet)
+ return (new Select())->columns($aggregates)->from(['results' => $customVars])->groupBy('flatname');
+ }
+
+ /**
+ * Collect all columns of this model and its relations that can be used for filtering
+ *
+ * @param Model $model
+ * @param Resolver $resolver
+ *
+ * @return Generator
+ */
+ public static function collectFilterColumns(Model $model, Resolver $resolver): Generator
+ {
+ if ($model instanceof UnionModel) {
+ $models = [];
+ foreach ($model->getUnions() as $union) {
+ /** @var Model $unionModel */
+ $unionModel = new $union[0]();
+ $models[$unionModel->getTableName()] = $unionModel;
+ self::collectRelations($resolver, $unionModel, $models, []);
+ }
+ } else {
+ $models = [$model->getTableName() => $model];
+ self::collectRelations($resolver, $model, $models, []);
+ }
+
+ /** @var Model $targetModel */
+ foreach ($models as $path => $targetModel) {
+ foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) {
+ yield $path . '.' . $columnName => $definition->getLabel();
+ }
+ }
+
+ foreach ($resolver->getBehaviors($model) as $behavior) {
+ if ($behavior instanceof ReRoute) {
+ foreach ($behavior->getRoutes() as $name => $route) {
+ $relation = $resolver->resolveRelation(
+ $resolver->qualifyPath($route, $model->getTableName()),
+ $model
+ );
+ foreach ($resolver->getColumnDefinitions($relation->getTarget()) as $columnName => $definition) {
+ yield $name . '.' . $columnName => $definition->getLabel();
+ }
+ }
+ }
+ }
+
+ if ($model instanceof UnionModel) {
+ $queries = $model->getUnions();
+ $baseModelClass = end($queries)[0];
+ $model = new $baseModelClass();
+ }
+
+ $foreignMetaDataSources = [];
+ if (! $model instanceof Host) {
+ $foreignMetaDataSources[] = 'host.user';
+ $foreignMetaDataSources[] = 'host.usergroup';
+ }
+
+ if (! $model instanceof Service) {
+ $foreignMetaDataSources[] = 'service.user';
+ $foreignMetaDataSources[] = 'service.usergroup';
+ }
+
+ foreach ($foreignMetaDataSources as $path) {
+ $foreignColumnDefinitions = $resolver->getColumnDefinitions($resolver->resolveRelation(
+ $resolver->qualifyPath($path, $model->getTableName()),
+ $model
+ )->getTarget());
+ foreach ($foreignColumnDefinitions as $columnName => $columnDefinition) {
+ yield "$path.$columnName" => $columnDefinition->getLabel();
+ }
+ }
+ }
+
+ /**
+ * Collect all direct relations of the given model
+ *
+ * A direct relation is either a direct descendant of the model
+ * or a descendant of such related in a to-one cardinality.
+ *
+ * @param Resolver $resolver
+ * @param Model $subject
+ * @param array $models
+ * @param array $path
+ */
+ protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path)
+ {
+ foreach ($resolver->getRelations($subject) as $name => $relation) {
+ /** @var Relation $relation */
+ if (
+ empty($path) || (
+ ($name === 'state' && $path[count($path) - 1] !== 'last_comment')
+ || $name === 'last_comment'
+ || $name === 'notificationcommand' && $path[0] === 'notification'
+ )
+ ) {
+ $relationPath = [$name];
+ if ($relation instanceof HasOne && empty($path)) {
+ array_unshift($relationPath, $subject->getTableName());
+ }
+
+ $relationPath = array_merge($path, $relationPath);
+ $models[join('.', $relationPath)] = $relation->getTarget();
+ self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath);
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Web/Control/ViewModeSwitcher.php b/library/Icingadb/Web/Control/ViewModeSwitcher.php
new file mode 100644
index 0000000..8068aee
--- /dev/null
+++ b/library/Icingadb/Web/Control/ViewModeSwitcher.php
@@ -0,0 +1,219 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control;
+
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\FormElement\HiddenElement;
+use ipl\Html\FormElement\InputElement;
+use ipl\Html\HtmlElement;
+use ipl\Web\Common\FormUid;
+use ipl\Web\Widget\IcingaIcon;
+
+class ViewModeSwitcher extends Form
+{
+ use FormUid;
+
+ protected $defaultAttributes = [
+ 'class' => 'view-mode-switcher',
+ 'name' => 'view-mode-switcher'
+ ];
+
+ /** @var string Default view mode */
+ const DEFAULT_VIEW_MODE = 'common';
+
+ /** @var string Default view mode param */
+ const DEFAULT_VIEW_MODE_PARAM = 'view';
+
+ /** @var array View mode-icon pairs */
+ public static $viewModes = [
+ 'minimal' => 'minimal',
+ 'common' => 'default',
+ 'detailed' => 'detailed',
+ 'tabular' => 'tabular'
+ ];
+
+ /** @var string */
+ protected $defaultViewMode;
+
+ /** @var string */
+ protected $method = 'POST';
+
+ /** @var callable */
+ protected $protector;
+
+ /** @var string */
+ protected $viewModeParam = self::DEFAULT_VIEW_MODE_PARAM;
+
+ /**
+ * Get the default mode
+ *
+ * @return string
+ */
+ public function getDefaultViewMode(): string
+ {
+ return $this->defaultViewMode ?: static::DEFAULT_VIEW_MODE;
+ }
+
+ /**
+ * Set the default view mode
+ *
+ * @param string $defaultViewMode
+ *
+ * @return $this
+ */
+ public function setDefaultViewMode(string $defaultViewMode): self
+ {
+ $this->defaultViewMode = $defaultViewMode;
+
+ return $this;
+ }
+
+ /**
+ * Get the view mode URL parameter
+ *
+ * @return string
+ */
+ public function getViewModeParam(): string
+ {
+ return $this->viewModeParam;
+ }
+
+ /**
+ * Set the view mode URL parameter
+ *
+ * @param string $viewModeParam
+ *
+ * @return $this
+ */
+ public function setViewModeParam(string $viewModeParam): self
+ {
+ $this->viewModeParam = $viewModeParam;
+
+ return $this;
+ }
+
+ /**
+ * Get the view mode
+ *
+ * @return string
+ */
+ public function getViewMode(): string
+ {
+ $viewMode = $this->getPopulatedValue($this->getViewModeParam(), $this->getDefaultViewMode());
+
+ if (array_key_exists($viewMode, static::$viewModes)) {
+ return $viewMode;
+ }
+
+ return $this->getDefaultViewMode();
+ }
+
+ /**
+ * Set the view mode
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setViewMode(string $name)
+ {
+ $this->populate([$this->getViewModeParam() => $name]);
+
+ return $this;
+ }
+
+ /**
+ * Set callback to protect ids with
+ *
+ * @param callable $protector
+ *
+ * @return $this
+ */
+ public function setIdProtector(callable $protector): self
+ {
+ $this->protector = $protector;
+
+ return $this;
+ }
+
+ private function protectId($id)
+ {
+ if (is_callable($this->protector)) {
+ return call_user_func($this->protector, $id);
+ }
+
+ return $id;
+ }
+
+ protected function assemble()
+ {
+ $viewModeParam = $this->getViewModeParam();
+
+ $this->addElement($this->createUidElement());
+ $this->addElement(new HiddenElement($viewModeParam));
+
+ foreach (static::$viewModes as $viewMode => $icon) {
+ if ($viewMode === 'tabular') {
+ continue;
+ }
+
+ $protectedId = $this->protectId('view-mode-switcher-' . $icon);
+ $input = new InputElement($viewModeParam, [
+ 'class' => 'autosubmit',
+ 'id' => $protectedId,
+ 'name' => $viewModeParam,
+ 'type' => 'radio',
+ 'value' => $viewMode
+ ]);
+ $input->getAttributes()->registerAttributeCallback('checked', function () use ($viewMode) {
+ return $viewMode === $this->getViewMode();
+ });
+
+ $label = new HtmlElement(
+ 'label',
+ Attributes::create([
+ 'for' => $protectedId
+ ]),
+ new IcingaIcon($icon)
+ );
+ $label->getAttributes()->registerAttributeCallback('title', function () use ($viewMode) {
+
+ return $this->getTitle($viewMode);
+ });
+
+ $this->addHtml($input, $label);
+ }
+ }
+
+ /**
+ * Return the title for the view mode when it is active and inactive
+ *
+ * @param string $viewMode
+ *
+ * @return string Title for the view mode when it is active and inactive
+ */
+ protected function getTitle(string $viewMode): string
+ {
+ $active = null;
+ $inactive = null;
+ switch ($viewMode) {
+ case 'minimal':
+ $active = t('Minimal view active');
+ $inactive = t('Switch to minimal view');
+ break;
+ case 'common':
+ $active = t('Common view active');
+ $inactive = t('Switch to common view');
+ break;
+ case 'detailed':
+ $active = t('Detailed view active');
+ $inactive = t('Switch to detailed view');
+ break;
+ }
+
+ return $viewMode === $this->getViewMode() ? $active : $inactive;
+ }
+}
diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php
new file mode 100644
index 0000000..ad9f07e
--- /dev/null
+++ b/library/Icingadb/Web/Controller.php
@@ -0,0 +1,542 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web;
+
+use Exception;
+use Generator;
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Version;
+use Icinga\Application\Web;
+use Icinga\Data\ConfigObject;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\Http\HttpBadRequestException;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\SearchControls;
+use Icinga\Module\Icingadb\Data\CsvResultSet;
+use Icinga\Module\Icingadb\Data\JsonResultSet;
+use Icinga\Module\Icingadb\Web\Control\GridViewModeSwitcher;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ItemTable\StateItemTable;
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use Icinga\Module\Pdfexport\ProvidedHook\Pdfexport;
+use Icinga\Security\SecurityException;
+use Icinga\User\Preferences;
+use Icinga\User\Preferences\PreferencesStore;
+use Icinga\Util\Environment;
+use Icinga\Util\Json;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+use ipl\Orm\UnionQuery;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\PaginationControl;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class Controller extends CompatController
+{
+ use Auth;
+ use Database;
+ use SearchControls;
+
+ /** @var Filter\Rule Filter from query string parameters */
+ private $filter;
+
+ /** @var string|null */
+ private $format;
+
+ /** @var bool */
+ private $formatProcessed = false;
+
+ /**
+ * Get the filter created from query string parameters
+ *
+ * @return Filter\Rule
+ */
+ public function getFilter(): Filter\Rule
+ {
+ if ($this->filter === null) {
+ $this->filter = QueryString::parse((string) $this->params);
+ }
+
+ return $this->filter;
+ }
+
+ /**
+ * Create column control
+ *
+ * @param Query $query
+ * @param ViewModeSwitcher $viewModeSwitcher
+ *
+ * @return array provided columns
+ *
+ * @throws HttpBadRequestException
+ */
+ public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwitcher): array
+ {
+ // All of that is essentially what `ColumnControl::apply()` should do
+ $viewMode = $this->getRequest()->getUrl()->getParam($viewModeSwitcher->getViewModeParam());
+ $columnsDef = $this->params->shift('columns');
+ if (! $columnsDef) {
+ if ($viewMode === 'tabular') {
+ $this->httpBadRequest('Missing parameter "columns"');
+ }
+
+ return [];
+ }
+
+ $columns = [];
+ foreach (explode(',', $columnsDef) as $column) {
+ if ($column = trim($column)) {
+ $columns[] = $column;
+ }
+ }
+
+ $query->withColumns($columns);
+
+ if (! $viewMode) {
+ $viewModeSwitcher->setViewMode('tabular');
+ }
+
+ // For now this also returns the columns, but they should be accessible
+ // by calling `ColumnControl::getColumns()` in the future
+ return $columns;
+ }
+
+ /**
+ * Create and return the ViewModeSwitcher
+ *
+ * This automatically shifts the view mode URL parameter from {@link $params}.
+ *
+ * @param PaginationControl $paginationControl
+ * @param LimitControl $limitControl
+ * @param bool $verticalPagination
+ *
+ * @return ViewModeSwitcher|GridViewModeSwitcher
+ */
+ public function createViewModeSwitcher(
+ PaginationControl $paginationControl,
+ LimitControl $limitControl,
+ bool $verticalPagination = false
+ ): ViewModeSwitcher {
+ $controllerName = $this->getRequest()->getControllerName();
+
+ // TODO: Make this configurable somehow. The route shouldn't be checked to choose the view modes!
+ if ($controllerName === 'hostgroups' || $controllerName === 'servicegroups') {
+ $viewModeSwitcher = new GridViewModeSwitcher();
+ } else {
+ $viewModeSwitcher = new ViewModeSwitcher();
+ }
+
+ $viewModeSwitcher->setIdProtector([$this->getRequest(), 'protectId']);
+
+ $user = $this->Auth()->getUser();
+ if (($preferredModes = $user->getAdditional('icingadb.view_modes')) === null) {
+ try {
+ $preferredModes = Json::decode(
+ $user->getPreferences()->getValue('icingadb', 'view_modes', '[]'),
+ true
+ );
+ } catch (JsonDecodeException $e) {
+ Logger::error('Failed to load preferred view modes for user "%s": %s', $user->getUsername(), $e);
+ $preferredModes = [];
+ }
+
+ $user->setAdditional('icingadb.view_modes', $preferredModes);
+ }
+
+ $requestRoute = $this->getRequest()->getUrl()->getPath();
+ if (isset($preferredModes[$requestRoute])) {
+ $viewModeSwitcher->setDefaultViewMode($preferredModes[$requestRoute]);
+ }
+
+ $viewModeSwitcher->populate([
+ $viewModeSwitcher->getViewModeParam() => $this->params->shift($viewModeSwitcher->getViewModeParam())
+ ]);
+
+ $session = $this->Window()->getSessionNamespace(
+ 'icingadb-viewmode-' . $this->Window()->getContainerId()
+ );
+
+ $viewModeSwitcher->on(
+ ViewModeSwitcher::ON_SUCCESS,
+ function (ViewModeSwitcher $viewModeSwitcher) use (
+ $user,
+ $preferredModes,
+ $paginationControl,
+ $verticalPagination,
+ &$session
+ ) {
+ $viewMode = $viewModeSwitcher->getValue($viewModeSwitcher->getViewModeParam());
+ $requestUrl = Url::fromRequest();
+
+ $preferredModes[$requestUrl->getPath()] = $viewMode;
+ $user->setAdditional('icingadb.view_modes', $preferredModes);
+
+ try {
+ $preferencesStore = PreferencesStore::create(new ConfigObject([
+ //TODO: Don't set store key as it will no longer be needed once we drop support for
+ // lower version of icingaweb2 then v2.11.
+ //https://github.com/Icinga/icingaweb2/pull/4765
+ 'store' => Config::app()->get('global', 'config_backend', 'db'),
+ 'resource' => Config::app()->get('global', 'config_resource')
+ ]), $user);
+ $preferencesStore->load();
+ $preferencesStore->save(
+ new Preferences(['icingadb' => ['view_modes' => Json::encode($preferredModes)]])
+ );
+ } catch (Exception $e) {
+ Logger::error('Failed to save preferred view mode for user "%s": %s', $user->getUsername(), $e);
+ }
+
+ $pageParam = $paginationControl->getPageParam();
+ $limitParam = LimitControl::DEFAULT_LIMIT_PARAM;
+ $currentPage = $paginationControl->getCurrentPageNumber();
+
+ $requestUrl->setParam($viewModeSwitcher->getViewModeParam(), $viewMode);
+ if (! $requestUrl->hasParam($limitParam)) {
+ if ($viewMode === 'minimal' || $viewMode === 'grid') {
+ $session->set('previous_page', $currentPage);
+ $session->set('request_path', $requestUrl->getPath());
+
+ $limit = $paginationControl->getLimit();
+ if (! $verticalPagination) {
+ // We are computing it based on the first element being rendered on this current page
+ $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit * 2)) + 1);
+ } else {
+ $currentPage = (int) (round($currentPage * $limit / ($limit * 2)));
+ }
+
+ $session->set('current_page', $currentPage);
+ } elseif (
+ $viewModeSwitcher->getDefaultViewMode() === 'minimal'
+ || $viewModeSwitcher->getDefaultViewMode() === 'grid'
+ ) {
+ $limit = $paginationControl->getLimit();
+ if ($currentPage === $session->get('current_page')) {
+ // No other page numbers have been selected, i.e the user only
+ // switches back and forth without changing the page numbers
+ $currentPage = $session->get('previous_page');
+ } elseif (! $verticalPagination) {
+ $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit / 2)) + 1);
+ } else {
+ $currentPage = (int) (floor($currentPage * $limit / ($limit / 2)));
+ }
+
+ $session->clear();
+ }
+
+ if (($requestUrl->hasParam($pageParam) && $currentPage > 1) || $currentPage > 1) {
+ $requestUrl->setParam($pageParam, $currentPage);
+ } else {
+ $requestUrl->remove($pageParam);
+ }
+ }
+
+ $this->redirectNow($requestUrl);
+ }
+ )->handleRequest(ServerRequest::fromGlobals());
+
+ $viewMode = $viewModeSwitcher->getViewMode();
+ if ($viewMode === 'minimal' || $viewMode === 'grid') {
+ $hasLimitParam = Url::fromRequest()->hasParam($limitControl->getLimitParam());
+
+ if ($paginationControl->getDefaultPageSize() <= LimitControl::DEFAULT_LIMIT && ! $hasLimitParam) {
+ $paginationControl->setDefaultPageSize($paginationControl->getDefaultPageSize() * 2);
+ $limitControl->setDefaultLimit($limitControl->getDefaultLimit() * 2);
+
+ $paginationControl->apply();
+ }
+ }
+
+ $requestPath = $session->get('request_path');
+ if ($requestPath && $requestPath !== $requestRoute) {
+ $session->clear();
+ }
+
+ return $viewModeSwitcher;
+ }
+
+ /**
+ * Process a search request
+ *
+ * @param Query $query
+ * @param array $additionalColumns
+ *
+ * @return void
+ */
+ public function handleSearchRequest(Query $query, array $additionalColumns = [])
+ {
+ $q = trim($this->params->shift('q', ''), ' *');
+ if (! $q) {
+ return;
+ }
+
+ $filter = Filter::any();
+ $this->prepareSearchFilter($query, $q, $filter, $additionalColumns);
+
+ $redirectUrl = Url::fromRequest();
+ $redirectUrl->setParams($this->params)->setFilter($filter);
+
+ $this->getResponse()->redirectAndExit($redirectUrl);
+ }
+
+ /**
+ * Prepare the given search filter
+ *
+ * @param Query $query
+ * @param string $search
+ * @param Filter\Any $filter
+ * @param array $additionalColumns
+ *
+ * @return void
+ */
+ protected function prepareSearchFilter(Query $query, string $search, Filter\Any $filter, array $additionalColumns)
+ {
+ $columns = array_merge($query->getModel()->getSearchColumns(), $additionalColumns);
+ foreach ($columns as $column) {
+ $filter->add(Filter::like(
+ $query->getResolver()->qualifyColumn($column, $query->getModel()->getTableName()),
+ "*$search*"
+ ));
+ }
+ }
+
+ /**
+ * Require permission to access the given route
+ *
+ * @param string $name If NULL, the current controller name is used
+ *
+ * @throws SecurityException
+ */
+ public function assertRouteAccess(string $name = null)
+ {
+ if (! $name) {
+ $name = $this->getRequest()->getControllerName();
+ }
+
+ if (! $this->isPermittedRoute($name)) {
+ throw new SecurityException('No permission to access this route');
+ }
+ }
+
+ public function export(Query ...$queries)
+ {
+ if ($this->format === 'sql') {
+ foreach ($queries as $query) {
+ list($sql, $values) = $query->getDb()->getQueryBuilder()->assembleSelect($query->assembleSelect());
+
+ $unused = [];
+ foreach ($values as $value) {
+ $pos = strpos($sql, '?');
+ if ($pos !== false) {
+ if (is_string($value)) {
+ $value = "'" . $value . "'";
+ }
+
+ $sql = substr_replace($sql, $value, $pos, 1);
+ } else {
+ $unused[] = $value;
+ }
+ }
+
+ if (!empty($unused)) {
+ $sql .= ' /* Unused values: "' . join('", "', $unused) . '" */';
+ }
+
+ $this->content->add(Html::tag('pre', $sql));
+ }
+
+ return true;
+ }
+
+ // It only makes sense to export a single result to CSV or JSON
+ $query = $queries[0];
+
+ // No matter the format, a limit should only apply if set
+ if ($this->format !== null) {
+ $query->limit(Url::fromRequest()->getParam('limit'));
+ }
+
+ if ($this->format === 'json' || $this->format === 'csv') {
+ $response = $this->getResponse();
+ $fileName = $this->view->title;
+
+ ob_end_clean();
+ Environment::raiseExecutionTime();
+
+ if ($this->format === 'json') {
+ $response
+ ->setHeader('Content-Type', 'application/json')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $fileName . '.json'
+ )
+ ->sendResponse();
+
+ JsonResultSet::stream($query);
+ } else {
+ $response
+ ->setHeader('Content-Type', 'text/csv')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $fileName . '.csv'
+ )
+ ->sendResponse();
+
+ CsvResultSet::stream($query);
+ }
+ }
+
+ $this->getTabs()->enableDataExports();
+ }
+
+ /**
+ * @todo Remove once support for Icinga Web 2 v2.9.x is dropped
+ */
+ protected function sendAsPdf()
+ {
+ if (! Icinga::app()->getModuleManager()->has('pdfexport')) {
+ throw new ConfigurationError('The pdfexport module is required for exports to PDF');
+ }
+
+ if (version_compare(Version::VERSION, '2.10.0', '>=')) {
+ parent::sendAsPdf();
+ return;
+ }
+
+ putenv('ICINGAWEB_EXPORT_FORMAT=pdf');
+ Environment::raiseMemoryLimit('512M');
+ Environment::raiseExecutionTime(300);
+
+ $time = DateFormatter::formatDateTime(time());
+
+ $doc = (new PrintableHtmlDocument())
+ ->setTitle($this->view->title ?? '')
+ ->setHeader(Html::wantHtml([
+ Html::tag('span', ['class' => 'title']),
+ Html::tag('time', null, $time)
+ ]))
+ ->setFooter(Html::wantHtml([
+ Html::tag('span', null, [
+ t('Page') . ' ',
+ Html::tag('span', ['class' => 'pageNumber']),
+ ' / ',
+ Html::tag('span', ['class' => 'totalPages'])
+ ]),
+ Html::tag('p', null, Url::fromRequest()->setParams($this->params))
+ ]))
+ ->addHtml($this->content);
+ $doc->getAttributes()->add('class', 'icinga-module module-icingadb');
+
+ Pdfexport::first()->streamPdfFromHtml($doc, sprintf(
+ '%s-%s',
+ $this->view->title ?: $this->getRequest()->getActionName(),
+ $time
+ ));
+ }
+
+ public function dispatch($action)
+ {
+ // Notify helpers of action preDispatch state
+ $this->_helper->notifyPreDispatch();
+
+ $this->preDispatch();
+
+ if ($this->getRequest()->isDispatched()) {
+ // If pre-dispatch hooks introduced a redirect then stop dispatch
+ // @see ZF-7496
+ if (! $this->getResponse()->isRedirect()) {
+ $interceptable = $this->$action();
+ if ($interceptable instanceof Generator) {
+ foreach ($interceptable as $stopSignal) {
+ if ($stopSignal === true) {
+ $this->formatProcessed = true;
+ break;
+ }
+ }
+ }
+ }
+ $this->postDispatch();
+ }
+
+ // whats actually important here is that this action controller is
+ // shutting down, regardless of dispatching; notify the helpers of this
+ // state
+ $this->_helper->notifyPostDispatch();
+ }
+
+ protected function addContent(ValidHtml $content)
+ {
+ if ($content instanceof BaseItemList || $content instanceof BaseItemTable) {
+ $this->content->getAttributes()->add('class', 'full-width');
+ } elseif ($content instanceof StateItemTable) {
+ $this->content->getAttributes()->add('class', 'full-height');
+ }
+
+ return parent::addContent($content);
+ }
+
+ public function filter(Query $query, Filter\Rule $filter = null): self
+ {
+ if ($this->format !== 'sql' || $this->hasPermission('config/authentication/roles/show')) {
+ $this->applyRestrictions($query);
+ }
+
+ if ($query instanceof UnionQuery) {
+ foreach ($query->getUnions() as $query) {
+ $query->filter($filter ?: $this->getFilter());
+ }
+ } else {
+ $query->filter($filter ?: $this->getFilter());
+ }
+
+ return $this;
+ }
+
+ public function preDispatch()
+ {
+ parent::preDispatch();
+
+ $this->format = $this->params->shift('format');
+ }
+
+ public function postDispatch()
+ {
+ if (! $this->formatProcessed && $this->format !== null && $this->format !== 'pdf') {
+ // The purpose of this is not only to show that a requested format isn't supported.
+ // It's main purpose is to not allow to bypass restrictions with `?format=sql` as
+ // it may be possible that an action applies restrictions, but doesn't support any
+ // output formats. Since the restrictions are bypassed in method `$this->filter()`
+ // for the SQL output format and the actual format processing is part of a different
+ // method (`$this->export()`) which needs to be called explicitly by an action,
+ // it's otherwise possible for bad individuals to access unrestricted data.
+ $this->httpBadRequest(t('This route does not support the requested output format'));
+ }
+
+ parent::postDispatch();
+ }
+
+ protected function moduleInit()
+ {
+ /** @var Web $app */
+ $app = Icinga::app();
+ $app->getFrontController()
+ ->getPlugin('Zend_Controller_Plugin_ErrorHandler')
+ ->setErrorHandlerModule('icingadb');
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Action.php b/library/Icingadb/Web/Navigation/Action.php
new file mode 100644
index 0000000..d02f933
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Action.php
@@ -0,0 +1,134 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Macros;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Web\Navigation\NavigationItem;
+use ipl\Web\Url;
+
+class Action extends NavigationItem
+{
+ use Auth;
+ use Macros;
+
+ /**
+ * Whether this action's macros were already resolved
+ *
+ * @var bool
+ */
+ protected $resolved = false;
+
+ /**
+ * This action's object
+ *
+ * @var Host|Service
+ */
+ protected $object;
+
+ /**
+ * The filter to use when being asked whether to render this action
+ *
+ * @var string
+ */
+ protected $filter;
+
+ /**
+ * This action's raw url attribute
+ *
+ * @var string
+ */
+ protected $rawUrl;
+
+ /**
+ * Set this action's object
+ *
+ * @param Host|Service $object
+ *
+ * @return $this
+ */
+ public function setObject($object): self
+ {
+ $this->object = $object;
+
+ return $this;
+ }
+
+ /**
+ * Get this action's object
+ *
+ * @return Host|Service
+ */
+ protected function getObject()
+ {
+ return $this->object;
+ }
+
+ /**
+ * Set the filter to use when being asked whether to render this action
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setFilter(string $filter): self
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Get the filter to use when being asked whether to render this action
+ *
+ * @return ?string
+ */
+ public function getFilter(): ?string
+ {
+ return $this->filter;
+ }
+
+ /**
+ * Set this item's url
+ *
+ * @param \Icinga\Web\Url|string $url
+ *
+ * @return $this
+ */
+ public function setUrl($url): self
+ {
+ if (is_string($url)) {
+ $this->rawUrl = $url;
+ } else {
+ parent::setUrl($url);
+ }
+
+ return $this;
+ }
+
+ public function getUrl(): ?\Icinga\Web\Url
+ {
+ $url = parent::getUrl();
+ if (! $this->resolved && $url === null && $this->rawUrl !== null) {
+ $this->setUrl(Url::fromPath($this->expandMacros($this->rawUrl, $this->getObject())));
+ $this->resolved = true;
+ return parent::getUrl();
+ } else {
+ return $url;
+ }
+ }
+
+ public function getRender(): bool
+ {
+ if ($this->render === null) {
+ $filter = $this->getFilter();
+ $this->render = ! $filter || $this->isMatchedOn($filter, $this->getObject());
+ }
+
+ return $this->render;
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/IcingadbHostAction.php b/library/Icingadb/Web/Navigation/IcingadbHostAction.php
new file mode 100644
index 0000000..a5fc256
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/IcingadbHostAction.php
@@ -0,0 +1,9 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation;
+
+class IcingadbHostAction extends Action
+{
+}
diff --git a/library/Icingadb/Web/Navigation/IcingadbServiceAction.php b/library/Icingadb/Web/Navigation/IcingadbServiceAction.php
new file mode 100644
index 0000000..d623951
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/IcingadbServiceAction.php
@@ -0,0 +1,9 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation;
+
+class IcingadbServiceAction extends Action
+{
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
new file mode 100644
index 0000000..fc64c7d
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use ipl\Web\Url;
+
+class HostProblemsBadge extends ProblemsBadge
+{
+ use Auth;
+
+ protected function fetchProblemsCount()
+ {
+ $summary = HoststateSummary::on($this->getDb());
+ $this->applyRestrictions($summary);
+ $count = (int) $summary->first()->hosts_down_unhandled;
+ if ($count) {
+ $this->setTitle(sprintf(
+ tp('One unhandled host down', '%d unhandled hosts down', $count),
+ $count
+ ));
+ }
+
+ return $count;
+ }
+
+ protected function getUrl(): Url
+ {
+ return Links::hosts()->setParams(['host.state.is_problem' => 'y', 'sort' => 'host.state.severity desc']);
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
new file mode 100644
index 0000000..658fa1c
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
@@ -0,0 +1,173 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\Navigation\Renderer\NavigationItemRenderer;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+abstract class ProblemsBadge extends NavigationItemRenderer
+{
+ use Database;
+
+ const STATE_CRITICAL = 'critical';
+ const STATE_UNKNOWN = 'unknown';
+
+ /** @var int Count cache */
+ protected $count;
+
+ /** @var string State text */
+ protected $state;
+
+ /** @var string Title */
+ protected $title;
+
+ protected $linkDisabled;
+
+ abstract protected function fetchProblemsCount();
+
+ abstract protected function getUrl();
+
+ public function getProblemsCount()
+ {
+ if ($this->count === null) {
+ try {
+ $count = $this->fetchProblemsCount();
+ } catch (Exception $e) {
+ Logger::debug($e);
+
+ $this->count = 1;
+
+ $this->setState(static::STATE_UNKNOWN);
+ $this->setTitle($e->getMessage());
+
+ return $this->count;
+ }
+
+ $this->count = $this->round($count);
+
+ $this->setState(static::STATE_CRITICAL);
+ }
+
+ return $this->count;
+ }
+
+ /**
+ * Set the state text
+ *
+ * @param string $state
+ *
+ * @return $this
+ */
+ public function setState(string $state): self
+ {
+ $this->state = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get the state text
+ *
+ * @return string
+ */
+ public function getState(): string
+ {
+ if ($this->state === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->state;
+ }
+
+ /**
+ * Set the title
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle(string $title): self
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ /**
+ * Get the title
+ *
+ * @return ?string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ public function render(NavigationItem $item = null): string
+ {
+ if ($item === null) {
+ $item = $this->getItem();
+ }
+
+ $item->setCssClass('badge-nav-item icinga-module module-icingadb');
+
+ $html = new HtmlDocument();
+
+ $badge = $this->createBadge();
+ if ($badge !== null) {
+ if ($this->linkDisabled) {
+ $badge->addAttributes(['class' => 'disabled']);
+ $this->setEscapeLabel(false);
+ $label = $this->view()->escape($item->getLabel());
+ $item->setLabel($badge . $label);
+ } else {
+ $html->add(new Link($badge, $this->getUrl(), ['title' => $this->getTitle()]));
+ }
+ }
+
+ return $html
+ ->prepend(new HtmlString(parent::render($item)))
+ ->render();
+ }
+
+ protected function createBadge()
+ {
+ $count = $this->getProblemsCount();
+
+ if ($count) {
+ return (new StateBadge($count, $this->getState()))
+ ->addAttributes(['class' => 'badge', 'title' => $this->getTitle()]);
+ }
+
+ return null;
+ }
+
+ protected function round($count)
+ {
+ if ($count > 1000000) {
+ $count = round($count, -6) / 1000000 . 'M';
+ } elseif ($count > 1000) {
+ $count = round($count, -3) / 1000 . 'k';
+ }
+
+ return $count;
+ }
+
+ public function disableLink()
+ {
+ $this->linkDisabled = true;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
new file mode 100644
index 0000000..b2f2cae
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
@@ -0,0 +1,36 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use ipl\Web\Url;
+
+class ServiceProblemsBadge extends ProblemsBadge
+{
+ use Auth;
+
+ protected function fetchProblemsCount()
+ {
+ $summary = ServicestateSummary::on($this->getDb());
+ $this->applyRestrictions($summary);
+ $count = (int) $summary->first()->services_critical_unhandled;
+ if ($count) {
+ $this->setTitle(sprintf(
+ tp('One unhandled service critical', '%d unhandled services critical', $count),
+ $count
+ ));
+ }
+
+ return $count;
+ }
+
+ protected function getUrl(): Url
+ {
+ return Links::services()
+ ->setParams(['service.state.is_problem' => 'y', 'sort' => 'service.state.severity desc']);
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php
new file mode 100644
index 0000000..703db65
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php
@@ -0,0 +1,66 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
+
+class TotalProblemsBadge extends BadgeNavigationItemRenderer
+{
+ /**
+ * Cached count
+ *
+ * @var int
+ */
+ protected $count;
+
+ /**
+ * State to severity map
+ *
+ * @var array
+ */
+ protected static $stateSeverityMap = [
+ self::STATE_OK => 0,
+ self::STATE_PENDING => 1,
+ self::STATE_UNKNOWN => 2,
+ self::STATE_WARNING => 3,
+ self::STATE_CRITICAL => 4,
+ ];
+
+ /**
+ * Severity to state map
+ *
+ * @var array
+ */
+ protected static $severityStateMap = [
+ self::STATE_OK,
+ self::STATE_PENDING,
+ self::STATE_UNKNOWN,
+ self::STATE_WARNING,
+ self::STATE_CRITICAL
+ ];
+
+ public function getCount()
+ {
+ if ($this->count === null) {
+ $countMap = array_fill(0, 5, 0);
+ $maxSeverity = 0;
+ foreach ($this->getItem()->getChildren() as $child) {
+ $renderer = $child->getRenderer();
+ if ($renderer instanceof ProblemsBadge) {
+ $count = $renderer->getProblemsCount();
+ if ($count) {
+ $severity = static::$stateSeverityMap[$renderer->getState()];
+ $countMap[$severity] += $count;
+ $maxSeverity = max($maxSeverity, $severity);
+ }
+ }
+ }
+ $this->count = $countMap[$maxSeverity];
+ $this->state = static::$severityStateMap[$maxSeverity];
+ }
+
+ return $this->count;
+ }
+}
diff --git a/library/Icingadb/Widget/AttemptBall.php b/library/Icingadb/Widget/AttemptBall.php
new file mode 100644
index 0000000..e57c59c
--- /dev/null
+++ b/library/Icingadb/Widget/AttemptBall.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+
+/**
+ * Visually represents one single check attempt.
+ */
+class AttemptBall extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'ball'];
+
+ /**
+ * Create a new attempt ball
+ *
+ * @param bool $taken Whether the attempt was taken
+ */
+ public function __construct(bool $taken = false)
+ {
+ if ($taken) {
+ $this->addAttributes(['class' => 'ball-size-s taken']);
+ } else {
+ $this->addAttributes(['class' => 'ball-size-xs']);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/CheckAttempt.php b/library/Icingadb/Widget/CheckAttempt.php
new file mode 100644
index 0000000..cf12de3
--- /dev/null
+++ b/library/Icingadb/Widget/CheckAttempt.php
@@ -0,0 +1,54 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+
+/**
+ * Visually represents the check attempts taken out of max check attempts.
+ */
+class CheckAttempt extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'check-attempt'];
+
+ /** @var int Current attempt */
+ protected $attempt;
+
+ /** @var int Max check attempts */
+ protected $maxAttempts;
+
+ /**
+ * Create a new check attempt widget
+ *
+ * @param int $attempt Current check attempt
+ * @param int $maxAttempts Max check attempts
+ */
+ public function __construct(int $attempt, int $maxAttempts)
+ {
+ $this->attempt = $attempt;
+ $this->maxAttempts = $maxAttempts;
+ }
+
+ protected function assemble()
+ {
+ if ($this->attempt == $this->maxAttempts) {
+ return;
+ }
+
+ if ($this->maxAttempts > 5) {
+ $this->add(FormattedString::create('%d/%d', $this->attempt, $this->maxAttempts));
+ } else {
+ for ($i = 0; $i < $this->attempt; ++$i) {
+ $this->add(new AttemptBall(true));
+ }
+ for ($i = $this->attempt; $i < $this->maxAttempts; ++$i) {
+ $this->add(new AttemptBall());
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/CheckStatistics.php b/library/Icingadb/Widget/Detail/CheckStatistics.php
new file mode 100644
index 0000000..8a826d5
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CheckStatistics.php
@@ -0,0 +1,373 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Widget\CheckAttempt;
+use Icinga\Util\Format;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Web\Common\Card;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Compat\StyleWithNonce;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Web\Widget\TimeSince;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Web\Widget\VerticalKeyValue;
+
+class CheckStatistics extends Card
+{
+ const TOP_LEFT_BUBBLE_FLAG = <<<'SVG'
+<svg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'>
+ <path class='bg' d='M0 0L13 13L3.15334e-06 13L0 0Z'/>
+ <path class='border' fill-rule='evenodd' clip-rule='evenodd'
+ d='M0 0L3.3959e-06 14L14 14L0 0ZM1 2.41421L1 13L11.5858 13L1 2.41421Z'/>
+</svg>
+SVG;
+
+ const TOP_RIGHT_BUBBLE_FLAG = <<<'SVG'
+<svg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'>
+ <path class='bg' d="M12 0L-1 13L12 13L12 0Z"/>
+ <path class='border' fill-rule="evenodd" clip-rule="evenodd"
+ d="M12 0L12 14L-2 14L12 0ZM11 2.41421L11 13L0.414213 13L11 2.41421Z"/>
+</svg>
+SVG;
+
+ protected $object;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => ['progress-bar', 'check-statistics']];
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ }
+
+ protected function assembleBody(BaseHtmlElement $body)
+ {
+ $styleElement = (new StyleWithNonce())
+ ->setModule('icingadb');
+
+ $hPadding = 10;
+ $durationScale = 80;
+ $checkInterval = $this->getCheckInterval();
+
+ $timeline = new HtmlElement('div', Attributes::create(['class' => ['check-timeline', 'timeline']]));
+ $above = new HtmlElement('ul', Attributes::create(['class' => 'above']));
+ $below = new HtmlElement('ul', Attributes::create(['class' => 'below']));
+ $progressBar = new HtmlElement('div', Attributes::create(['class' => 'bar']));
+ $overdueBar = null;
+
+ $now = time();
+ $executionTime = ($this->object->state->execution_time / 1000) + ($this->object->state->latency / 1000);
+
+ $nextCheckTime = $this->object->state->next_check !== null && ! $this->isChecksDisabled()
+ ? $this->object->state->next_check->getTimestamp()
+ : null;
+ if ($this->object->state->is_overdue) {
+ $nextCheckTime = $this->object->state->next_update->getTimestamp();
+
+ $durationScale = 60;
+
+ $overdueBar = new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'timeline-overlay']),
+ new HtmlElement('div', Attributes::create(['class' => 'now']))
+ );
+
+ $above->addHtml(new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'now']),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'bubble']),
+ new HtmlElement('strong', null, Text::create(t('Now')))
+ )
+ ));
+
+ $this->getAttributes()->add('class', 'check-overdue');
+ } else {
+ $progressBar->addHtml(new HtmlElement('div', Attributes::create(['class' => 'now'])));
+ }
+
+ if ($nextCheckTime !== null && ! $this->object->state->is_overdue && $nextCheckTime < $now) {
+ // If the next check is already in the past but not overdue, it means the check is probably running.
+ // Icinga only updates the state once the check reports a result, that's why we have to simulate the
+ // execution start and end time, as well as the next check time.
+ $lastUpdateTime = $nextCheckTime;
+ $nextCheckTime = $this->object->state->next_update->getTimestamp() - $executionTime;
+ $executionEndTime = $lastUpdateTime + $executionTime;
+ } else {
+ $lastUpdateTime = $this->object->state->last_update !== null
+ ? $this->object->state->last_update->getTimestamp() - $executionTime
+ : null;
+ $executionEndTime = $this->object->state->last_update !== null
+ ? $this->object->state->last_update->getTimestamp()
+ : null;
+ }
+
+ if ($this->object->state->is_overdue) {
+ $leftNow = 100;
+ } elseif ($nextCheckTime === null) {
+ $leftNow = 0;
+ } elseif (! $this->object->state->is_reachable && time() - $executionEndTime > $checkInterval * 2) {
+ // We have no way of knowing whether the dependency pauses check scheduling.
+ // The only way to detect this, is to measure how old the last update is.
+ $nextCheckTime = null;
+ $leftNow = 0;
+ } elseif ($nextCheckTime - $lastUpdateTime <= 0) {
+ $leftNow = 0;
+ } else {
+ $leftNow = 100 * (1 - ($nextCheckTime - time()) / ($nextCheckTime - $lastUpdateTime));
+ if ($leftNow > 100) {
+ $leftNow = 100;
+ } elseif ($leftNow < 0) {
+ $leftNow = 0;
+ }
+ }
+
+ $styleElement->addFor($progressBar, ['width' => sprintf('%F%%', $leftNow)]);
+
+ $leftExecutionEnd = $nextCheckTime !== null && $nextCheckTime - $lastUpdateTime > 0 ? $durationScale * (
+ 1 - ($nextCheckTime - $executionEndTime) / ($nextCheckTime - $lastUpdateTime)
+ ) : 0;
+
+ $markerLast = new HtmlElement('div', Attributes::create([
+ 'class' => ['highlighted', 'marker', 'left'],
+ 'title' => $lastUpdateTime !== null ? DateFormatter::formatDateTime($lastUpdateTime) : null
+ ]));
+ $markerNext = new HtmlElement('div', Attributes::create([
+ 'class' => ['highlighted', 'marker', 'right'],
+ 'title' => $nextCheckTime !== null ? DateFormatter::formatDateTime($nextCheckTime) : null
+ ]));
+ $markerExecutionEnd = new HtmlElement('div', Attributes::create(['class' => ['highlighted', 'marker']]));
+ $styleElement->addFor($markerExecutionEnd, [
+ 'left' => sprintf('%F%%', $hPadding + $leftExecutionEnd)
+ ]);
+
+ $progress = new HtmlElement('div', Attributes::create([
+ 'class' => ['progress', time() < $executionEndTime ? 'running' : null]
+ ]), $progressBar);
+ if ($nextCheckTime !== null) {
+ $progress->addAttributes([
+ 'data-animate-progress' => true,
+ 'data-start-time' => $lastUpdateTime,
+ 'data-end-time' => $nextCheckTime,
+ 'data-switch-after' => $executionTime,
+ 'data-switch-class' => 'running'
+ ]);
+ }
+
+ $timeline->addHtml(
+ $progress,
+ $markerLast,
+ $markerExecutionEnd,
+ $markerNext
+ )->add($overdueBar);
+
+ $executionStart = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'left']),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => ['bubble', 'upwards', 'top-right-aligned']]),
+ new VerticalKeyValue(
+ t('Execution Start'),
+ $lastUpdateTime ? new TimeAgo($lastUpdateTime) : t('PENDING')
+ ),
+ HtmlString::create(self::TOP_RIGHT_BUBBLE_FLAG)
+ )
+ );
+ $executionEnd = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'positioned']),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => ['bubble', 'upwards', 'top-left-aligned']]),
+ new VerticalKeyValue(
+ t('Execution End'),
+ $executionEndTime !== null
+ ? ($executionEndTime > $now
+ ? new TimeUntil($executionEndTime)
+ : new TimeAgo($executionEndTime))
+ : t('PENDING')
+ ),
+ HtmlString::create(self::TOP_LEFT_BUBBLE_FLAG)
+ )
+ );
+
+ $styleElement->addFor($executionEnd, ['left' => sprintf('%F%%', $hPadding + $leftExecutionEnd)]);
+
+ $intervalLine = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'interval-line']),
+ new VerticalKeyValue(t('Interval'), Format::seconds($checkInterval))
+ );
+
+ $styleElement->addFor($intervalLine, [
+ 'left' => sprintf('%F%%', $hPadding + $leftExecutionEnd),
+ 'width' => sprintf('%F%%', $durationScale - $leftExecutionEnd)
+ ]);
+
+ $executionLine = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => ['interval-line', 'execution-line']]),
+ new VerticalKeyValue(
+ sprintf('%s / %s', t('Execution Time'), t('Latency')),
+ FormattedString::create(
+ '%s / %s',
+ $this->object->state->execution_time !== null
+ ? Format::seconds($this->object->state->execution_time / 1000)
+ : (new EmptyState(t('n. a.')))->setTag('span'),
+ $this->object->state->latency !== null
+ ? Format::seconds($this->object->state->latency / 1000)
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ )
+ );
+
+ $styleElement->addFor($executionLine, [
+ 'left' => sprintf('%F%%', $hPadding),
+ 'width' => sprintf('%F%%', $leftExecutionEnd)
+ ]);
+
+ if ($executionEndTime !== null) {
+ $executionLine->addHtml(new HtmlElement('div', Attributes::create(['class' => 'start'])));
+ $executionLine->addHtml(new HtmlElement('div', Attributes::create(['class' => 'end'])));
+ }
+
+ if ($this->isChecksDisabled()) {
+ $nextCheckBubbleContent = new VerticalKeyValue(
+ t('Next Check'),
+ t('n.a')
+ );
+
+ $this->addAttributes(['class' => 'checks-disabled']);
+ } else {
+ $nextCheckBubbleContent = $this->object->state->is_overdue
+ ? new VerticalKeyValue(t('Overdue'), new TimeSince($nextCheckTime))
+ : new VerticalKeyValue(
+ t('Next Check'),
+ $nextCheckTime !== null
+ ? ($nextCheckTime > $now
+ ? new TimeUntil($nextCheckTime)
+ : new TimeAgo($nextCheckTime))
+ : t('PENDING')
+ );
+ }
+
+ $nextCheck = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'right']),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => ['bubble', 'upwards']]),
+ $nextCheckBubbleContent
+ )
+ );
+
+ $above->addHtml($executionLine);
+
+ $below->addHtml(
+ $executionStart,
+ $executionEnd,
+ $intervalLine,
+ $nextCheck
+ );
+
+ $body->addHtml($above, $timeline, $below, $styleElement);
+ }
+
+ /**
+ * Checks if both active and passive checks are disabled
+ *
+ * @return bool
+ */
+ protected function isChecksDisabled(): bool
+ {
+ return ! ($this->object->active_checks_enabled || $this->object->passive_checks_enabled);
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $checkSource = (new EmptyState(t('n. a.')))->setTag('span');
+ if ($this->object->state->check_source) {
+ $checkSource = Text::create($this->object->state->check_source);
+ }
+
+ $header->addHtml(
+ new VerticalKeyValue(t('Command'), $this->object->checkcommand_name),
+ new VerticalKeyValue(
+ t('Scheduling Source'),
+ $this->object->state->scheduling_source ?? (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ );
+
+ if ($this->object->timeperiod->id) {
+ $header->addHtml(new VerticalKeyValue(
+ t('Timeperiod'),
+ $this->object->timeperiod->display_name ?? $this->object->timeperiod->name
+ ));
+ }
+
+ $header->addHtml(
+ new VerticalKeyValue(
+ t('Attempts'),
+ new CheckAttempt((int) $this->object->state->check_attempt, (int) $this->object->max_check_attempts)
+ ),
+ new VerticalKeyValue(t('Check Source'), $checkSource)
+ );
+ }
+
+ /**
+ * Get the active `check_interval` OR `check_retry_interval`
+ *
+ * @return int
+ */
+ protected function getCheckInterval(): int
+ {
+ if (! ($this->object->state->is_problem && $this->object->state->state_type === 'soft')) {
+ return $this->object->check_interval;
+ }
+
+ $delay = ($this->object->state->execution_time + $this->object->state->latency) / 1000 + 5;
+ $interval = $this->object->state->next_check->getTimestamp()
+ - $this->object->state->last_update->getTimestamp();
+
+ // In case passive check is used, the check_retry_interval has no effect.
+ // Since there is no flag in the database to check if the passive check was triggered.
+ // We have to manually check if the check_retry_interval matches the calculated interval.
+ if (
+ $this->object->check_retry_interval - $delay <= $interval
+ && $this->object->check_retry_interval + $delay >= $interval
+ ) {
+ return $this->object->check_retry_interval;
+ }
+
+ return $this->object->check_interval;
+ }
+
+ protected function assemble()
+ {
+ parent::assemble();
+
+ if ($this->isChecksDisabled()) {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'checks-disabled-overlay']),
+ new HtmlElement(
+ 'strong',
+ Attributes::create(['class' => 'notes']),
+ Text::create(t('active and passive checks are disabled'))
+ )
+ ));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/CommentDetail.php b/library/Icingadb/Widget/Detail/CommentDetail.php
new file mode 100644
index 0000000..5b0923e
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CommentDetail.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteCommentForm;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class CommentDetail extends BaseHtmlElement
+{
+ use Auth;
+ use TicketLinks;
+
+ protected $comment;
+
+ protected $defaultAttributes = ['class' => ['object-detail', 'comment-detail']];
+
+ protected $tag = 'div';
+
+ public function __construct(Comment $comment)
+ {
+ $this->comment = $comment;
+ }
+
+ protected function createComment(): array
+ {
+ return [
+ Html::tag('h2', t('Comment')),
+ new MarkdownText($this->createTicketLinks($this->comment->text))
+ ];
+ }
+
+ protected function createDetails(): array
+ {
+ $details = [];
+
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ if ($this->comment->object_type === 'host') {
+ $details[] = new HorizontalKeyValue(t('Host'), [
+ $this->comment->host->name,
+ ' ',
+ new StateBall($this->comment->host->state->getStateText())
+ ]);
+ } else {
+ $details[] = new HorizontalKeyValue(t('Service'), Html::sprintf(
+ t('%s on %s', '<service> on <host>'),
+ [$this->comment->service->name, ' ', new StateBall($this->comment->service->state->getStateText())],
+ $this->comment->host->name
+ ));
+ }
+
+ $details[] = new HorizontalKeyValue(t('Author'), $this->comment->author);
+ $details[] = new HorizontalKeyValue(
+ t('Acknowledgement'),
+ $this->comment->entry_type === 'ack' ? t('Yes') : t('No')
+ );
+ $details[] = new HorizontalKeyValue(
+ t('Persistent'),
+ $this->comment->is_persistent ? t('Yes') : t('No')
+ );
+ $details[] = new HorizontalKeyValue(
+ t('Created'),
+ DateFormatter::formatDateTime($this->comment->entry_time->getTimestamp())
+ );
+ $details[] = new HorizontalKeyValue(t('Expires'), $this->comment->expire_time !== null
+ ? DateFormatter::formatDateTime($this->comment->expire_time->getTimestamp())
+ : t('Never'));
+ } else {
+ if ($this->comment->expire_time !== null) {
+ $details[] = Html::tag(
+ 'p',
+ Html::sprintf(
+ $this->comment->entry_type === 'ack'
+ ? t('This acknowledgement expires %s.', '..<time-until>')
+ : t('This comment expires %s.', '..<time-until>'),
+ new TimeUntil($this->comment->expire_time->getTimestamp())
+ )
+ );
+ }
+
+ if ($this->comment->is_sticky) {
+ $details[] = Html::tag('p', t('This acknowledgement is sticky.'));
+ }
+ }
+
+ if (! empty($details)) {
+ array_unshift($details, Html::tag('h2', t('Details')));
+ }
+
+ return $details;
+ }
+
+ protected function createRemoveCommentForm()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ return null;
+ }
+
+ $action = Links::commentsDelete();
+ $action->setFilter(Filter::equal('name', $this->comment->name));
+
+ return (new DeleteCommentForm())
+ ->setObjects([$this->comment])
+ ->populate(['redirect' => '__BACK__'])
+ ->setAction($action->getAbsoluteUrl());
+ }
+
+ protected function assemble()
+ {
+ $this->add($this->createComment());
+
+ $details = $this->createDetails();
+
+ if (! empty($details)) {
+ $this->add($details);
+ }
+
+ if (
+ $this->isGrantedOn(
+ 'icingadb/command/comment/delete',
+ $this->comment->{$this->comment->object_type}
+ )
+ ) {
+ $this->add($this->createRemoveCommentForm());
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/CustomVarTable.php b/library/Icingadb/Widget/Detail/CustomVarTable.php
new file mode 100644
index 0000000..9d6916b
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CustomVarTable.php
@@ -0,0 +1,268 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\CustomVarRendererHook;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+use Closure;
+
+class CustomVarTable extends BaseHtmlElement
+{
+ /** @var array The variables */
+ protected $data;
+
+ /** @var ?Model The object the variables are bound to */
+ protected $object;
+
+ /** @var Closure Callback to apply hooks */
+ protected $hookApplier;
+
+ /** @var array The groups as identified by hooks */
+ protected $groups = [];
+
+ /** @var string Header title */
+ protected $headerTitle;
+
+ /** @var int The nesting level */
+ protected $level = 0;
+
+ protected $tag = 'table';
+
+ /** @var HtmlElement The table body */
+ protected $body;
+
+ protected $defaultAttributes = [
+ 'class' => ['custom-var-table', 'name-value-table']
+ ];
+
+ /**
+ * Create a new CustomVarTable
+ *
+ * @param iterable $data
+ * @param ?Model $object
+ */
+ public function __construct($data, Model $object = null)
+ {
+ $this->data = $data;
+ $this->object = $object;
+ $this->body = new HtmlElement('tbody');
+ }
+
+ /**
+ * Set the header to show
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ protected function setHeader(string $title): self
+ {
+ $this->headerTitle = $title;
+
+ return $this;
+ }
+
+ /**
+ * Add a new row to the body
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function addRow($name, $value)
+ {
+ $this->body->addHtml(new HtmlElement(
+ 'tr',
+ Attributes::create(['class' => "level-{$this->level}"]),
+ new HtmlElement('th', null, Html::wantHtml($name)),
+ new HtmlElement('td', null, Html::wantHtml($value))
+ ));
+ }
+
+ /**
+ * Render a variable
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function renderVar($name, $value)
+ {
+ if ($this->object !== null && $this->level === 0) {
+ list($name, $value, $group) = call_user_func($this->hookApplier, $name, $value);
+ if ($group !== null) {
+ $this->groups[$group][] = [$name, $value];
+ return;
+ }
+ }
+
+ $isArray = is_array($value);
+ switch (true) {
+ case $isArray && is_int(key($value)):
+ $this->renderArray($name, $value);
+ break;
+ case $isArray:
+ $this->renderObject($name, $value);
+ break;
+ default:
+ $this->renderScalar($name, $value);
+ }
+ }
+
+ /**
+ * Render an array
+ *
+ * @param string $name
+ * @param array $array
+ *
+ * @return void
+ */
+ protected function renderArray($name, array $array)
+ {
+ $numItems = count($array);
+ $name = (new HtmlDocument())->addHtml(
+ Html::wantHtml($name),
+ Text::create(' (Array)')
+ );
+
+ $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems));
+
+ ++$this->level;
+
+ ksort($array);
+ foreach ($array as $key => $value) {
+ $this->renderVar("[$key]", $value);
+ }
+
+ --$this->level;
+ }
+
+ /**
+ * Render an object (associative array)
+ *
+ * @param mixed $name
+ * @param array $object
+ *
+ * @return void
+ */
+ protected function renderObject($name, array $object)
+ {
+ $numItems = count($object);
+ $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems));
+
+ ++$this->level;
+
+ ksort($object);
+ foreach ($object as $key => $value) {
+ $this->renderVar($key, $value);
+ }
+
+ --$this->level;
+ }
+
+ /**
+ * Render a scalar
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function renderScalar($name, $value)
+ {
+ if ($value === '') {
+ $value = new EmptyState(t('empty string'));
+ }
+
+ $this->addRow($name, $value);
+ }
+
+ /**
+ * Render a group
+ *
+ * @param string $name
+ * @param iterable $entries
+ *
+ * @return void
+ */
+ protected function renderGroup(string $name, $entries)
+ {
+ $table = new self($entries);
+
+ $wrapper = $this->getWrapper();
+ if ($wrapper === null) {
+ $wrapper = new HtmlDocument();
+ $wrapper->addHtml($this);
+ $this->prependWrapper($wrapper);
+ }
+
+ $wrapper->addHtml($table->setHeader($name));
+ }
+
+ protected function assemble()
+ {
+ if ($this->object !== null) {
+ $this->hookApplier = CustomVarRendererHook::prepareForObject($this->object);
+ }
+
+ if ($this->headerTitle !== null) {
+ $this->getAttributes()
+ ->add('class', 'collapsible')
+ ->add('data-visible-height', 100)
+ ->add('data-toggle-element', 'thead')
+ ->add(
+ 'id',
+ preg_replace('/\s+/', '-', strtolower($this->headerTitle)) . '-customvars'
+ );
+
+ $this->addHtml(new HtmlElement('thead', null, new HtmlElement(
+ 'tr',
+ null,
+ new HtmlElement(
+ 'th',
+ Attributes::create(['colspan' => 2]),
+ new HtmlElement(
+ 'span',
+ null,
+ new Icon('angle-right'),
+ new Icon('angle-down')
+ ),
+ Text::create($this->headerTitle)
+ )
+ )));
+ }
+
+ if (is_array($this->data)) {
+ ksort($this->data);
+ }
+
+ foreach ($this->data as $name => $value) {
+ $this->renderVar($name, $value);
+ }
+
+ $this->addHtml($this->body);
+
+ // Hooks can return objects as replacement for keys, hence a generator is needed for group entries
+ $genGenerator = function ($entries) {
+ foreach ($entries as list($key, $value)) {
+ yield $key => $value;
+ }
+ };
+
+ foreach ($this->groups as $group => $entries) {
+ $this->renderGroup($group, $genGenerator($entries));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/DowntimeCard.php b/library/Icingadb/Widget/Detail/DowntimeCard.php
new file mode 100644
index 0000000..81f59da
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/DowntimeCard.php
@@ -0,0 +1,258 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Model\Downtime;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Web\Compat\StyleWithNonce;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class DowntimeCard extends BaseHtmlElement
+{
+ protected $downtime;
+
+ protected $duration;
+
+ protected $defaultAttributes = ['class' => 'progress-bar downtime-progress'];
+
+ protected $tag = 'div';
+
+ protected $start;
+
+ protected $end;
+
+ public function __construct(Downtime $downtime)
+ {
+ $this->downtime = $downtime;
+
+ $this->start = $this->downtime->scheduled_start_time->getTimestamp();
+ $this->end = $this->downtime->scheduled_end_time->getTimestamp();
+
+ if ($this->downtime->end_time > $this->downtime->scheduled_end_time) {
+ $this->duration = $this->downtime->end_time->getTimestamp() - $this->start;
+ } else {
+ $this->duration = $this->end - $this->start;
+ }
+ }
+
+ protected function assemble()
+ {
+ $styleElement = (new StyleWithNonce())
+ ->setModule('icingadb');
+
+ $timeline = Html::tag('div', ['class' => 'downtime-timeline timeline']);
+ $hPadding = 10;
+
+ $above = Html::tag('ul', ['class' => 'above']);
+ $below = Html::tag('ul', ['class' => 'below']);
+
+ $markerStart = new HtmlElement('div', Attributes::create(['class' => ['marker' , 'left']]));
+ $markerEnd = new HtmlElement('div', Attributes::create(['class' => ['marker', 'right']]));
+
+ $timelineProgress = null;
+ $flexProgress = null;
+ $markerFlexStart = null;
+ $markerFlexEnd = null;
+
+ if ($this->end < time()) {
+ $endTime = new TimeAgo($this->end);
+ } else {
+ $endTime = new TimeUntil($this->end);
+ }
+
+ if ($this->downtime->is_flexible && $this->downtime->is_in_effect) {
+ $this->addAttributes(['class' => 'flexible in-effect']);
+
+ $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->downtime->start_time->getTimestamp());
+ $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->downtime->end_time->getTimestamp());
+
+ $evade = false;
+ if ($flexEndLeft - $flexStartLeft < 2) {
+ $flexStartLeft -= 1;
+ $flexEndLeft += 1;
+
+ if ($flexEndLeft > $hPadding + $this->calcRelativeLeft($this->end)) {
+ $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->end) - .5;
+ $flexStartLeft = $flexEndLeft - 2;
+ }
+
+ if ($flexStartLeft < $hPadding + $this->calcRelativeLeft($this->start)) {
+ $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->start) + .5;
+ $flexEndLeft = $flexStartLeft + 2;
+ }
+
+ $evade = true;
+ }
+
+ $markerFlexStart = new HtmlElement('div', Attributes::create(['class' => ['highlighted', 'marker']]));
+ $markerFlexEnd = new HtmlElement('div', Attributes::create(['class' => ['highlighted', 'marker']]));
+
+ $styleElement
+ ->addFor($markerFlexStart, ['left' => sprintf('%F%%', $flexStartLeft)])
+ ->addFor($markerFlexEnd, ['left' => sprintf('%F%%', $flexEndLeft)]);
+
+ $scheduledEndBubble = new HtmlElement(
+ 'li',
+ null,
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => ['bubble', 'upwards']]),
+ new VerticalKeyValue(t('Scheduled End'), $endTime)
+ )
+ );
+
+ $timelineProgress = new HtmlElement('div', Attributes::create([
+ 'class' => ['progress', 'downtime-elapsed'],
+ 'data-animate-progress' => true,
+ 'data-start-time' => ((float) $this->downtime->start_time->format('U.u')),
+ 'data-end-time' => ((float) $this->downtime->end_time->format('U.u'))
+ ]), new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'bar']),
+ new HtmlElement('div', Attributes::create(['class' => 'now']))
+ ));
+
+ $styleElement->addFor($timelineProgress, [
+ 'left' => sprintf('%F%%', $flexStartLeft),
+ 'width' => sprintf('%F%%', $flexEndLeft - $flexStartLeft)
+ ]);
+
+ if (time() > $this->end) {
+ $styleElement
+ ->addFor($markerEnd, [
+ 'left' => sprintf('%F%%', $hPadding + $this->calcRelativeLeft($this->end))
+ ])
+ ->addFor($scheduledEndBubble, [
+ 'left' => sprintf('%F%%', $hPadding + $this->calcRelativeLeft($this->end))
+ ]);
+ } else {
+ $scheduledEndBubble->getAttributes()
+ ->add('class', 'right');
+ }
+
+ $below->add([
+ Html::tag(
+ 'li',
+ ['class' => 'left'],
+ Html::tag(
+ 'div',
+ ['class' => ['bubble', 'upwards']],
+ new VerticalKeyValue(t('Scheduled Start'), new TimeAgo($this->start))
+ )
+ ),
+ $scheduledEndBubble
+ ]);
+
+ $aboveStart = Html::tag('li', ['class' => 'positioned'], Html::tag(
+ 'div',
+ ['class' => ['bubble', ($evade ? 'left-aligned' : null)]],
+ new VerticalKeyValue(t('Start'), new TimeAgo($this->downtime->start_time->getTimestamp()))
+ ));
+
+ $aboveEnd = Html::tag('li', ['class' => 'positioned'], Html::tag(
+ 'div',
+ ['class' => ['bubble', ($evade ? 'right-aligned' : null)]],
+ new VerticalKeyValue(t('End'), new TimeUntil($this->downtime->end_time->getTimestamp()))
+ ));
+
+ $styleElement
+ ->addFor($aboveStart, ['left' => sprintf('%F%%', $flexStartLeft)])
+ ->addFor($aboveEnd, ['left' => sprintf('%F%%', $flexEndLeft)]);
+
+ $above->add([$aboveStart, $aboveEnd, $styleElement]);
+ } elseif ($this->downtime->is_flexible) {
+ $this->addAttributes(['class' => 'flexible']);
+
+ $below->add([
+ Html::tag(
+ 'li',
+ ['class' => 'left'],
+ Html::tag(
+ 'div',
+ ['class' => ['bubble', 'upwards']],
+ new VerticalKeyValue(
+ t('Scheduled Start'),
+ time() > $this->start
+ ? new TimeAgo($this->start)
+ : new TimeUntil($this->start)
+ )
+ )
+ ),
+ Html::tag(
+ 'li',
+ ['class' => 'right'],
+ Html::tag(
+ 'div',
+ ['class' => ['bubble', 'upwards']],
+ new VerticalKeyValue(t('Scheduled End'), $endTime)
+ )
+ )
+ ]);
+
+ $above = null;
+ } else {
+ if (time() >= $this->start) {
+ $timelineProgress = new HtmlElement('div', Attributes::create([
+ 'class' => ['progress', 'downtime-elapsed'],
+ 'data-animate-progress' => true,
+ 'data-start-time' => $this->start,
+ 'data-end-time' => $this->end
+ ]), new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'bar']),
+ new HtmlElement('div', Attributes::create(['class' => 'now']))
+ ));
+ }
+
+ $below->add([
+ Html::tag(
+ 'li',
+ ['class' => 'left'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('Start'), new TimeAgo($this->start))
+ )
+ ),
+ Html::tag(
+ 'li',
+ ['class' => 'right'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('End'), new TimeUntil($this->end))
+ )
+ )
+ ]);
+
+ $above = null;
+ }
+
+ $timeline->add([
+ $timelineProgress,
+ $flexProgress,
+ $markerStart,
+ $markerEnd,
+ $markerFlexStart,
+ $markerFlexEnd
+ ]);
+
+ $this->add([
+ $above,
+ $timeline,
+ $below
+ ]);
+ }
+
+ protected function calcRelativeLeft($value)
+ {
+ return round(($value - $this->start) / $this->duration * 80, 2);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/DowntimeDetail.php b/library/Icingadb/Widget/Detail/DowntimeDetail.php
new file mode 100644
index 0000000..9e50f7f
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/DowntimeDetail.php
@@ -0,0 +1,206 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Date\DateFormatter as WebDateFormatter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteDowntimeForm;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBall;
+
+class DowntimeDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+ use HostLink;
+ use ServiceLink;
+
+ /** @var BaseHtmlElement */
+ protected $control;
+
+ /** @var Downtime */
+ protected $downtime;
+
+ protected $defaultAttributes = ['class' => ['object-detail', 'downtime-detail']];
+
+ protected $tag = 'div';
+
+ public function __construct(Downtime $downtime)
+ {
+ $this->downtime = $downtime;
+ }
+
+ protected function createCancelDowntimeForm()
+ {
+ $action = Links::downtimesDelete();
+ $action->setFilter(Filter::equal('name', $this->downtime->name));
+
+ return (new DeleteDowntimeForm())
+ ->setObjects([$this->downtime])
+ ->populate(['redirect' => '__BACK__'])
+ ->setAction($action->getAbsoluteUrl());
+ }
+
+ protected function createTimeline(): DowntimeCard
+ {
+ return new DowntimeCard($this->downtime);
+ }
+
+ protected function assemble()
+ {
+ $this->add(Html::tag('h2', t('Comment')));
+ $this->add(Html::tag('div', [
+ new Icon('user'),
+ Html::sprintf(
+ t('%s commented: %s', '<username> ..: <comment>'),
+ $this->downtime->author,
+ new MarkdownText($this->downtime->comment)
+ )
+ ]));
+
+ $this->add(Html::tag('h2', t('Details')));
+
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->addHtml(new HorizontalKeyValue(
+ t('Type'),
+ $this->downtime->is_flexible ? t('Flexible') : t('Fixed')
+ ));
+ if ($this->downtime->object_type === 'host') {
+ $this->addHtml(new HorizontalKeyValue(t('Host'), [
+ $this->downtime->host->name,
+ ' ',
+ new StateBall($this->downtime->host->state->getStateText())
+ ]));
+ } else {
+ $this->addHtml(new HorizontalKeyValue(t('Service'), Html::sprintf(
+ t('%s on %s', '<service> on <host>'),
+ [
+ $this->downtime->service->name,
+ ' ',
+ new StateBall($this->downtime->service->state->getStateText())
+ ],
+ $this->downtime->host->name
+ )));
+ }
+ }
+
+ if ($this->downtime->triggered_by_id !== null || $this->downtime->parent_id !== null) {
+ if ($this->downtime->triggered_by_id !== null) {
+ $label = t('Triggered By');
+ $relatedDowntime = $this->downtime->triggered_by;
+ } else {
+ $label = t('Parent');
+ $relatedDowntime = $this->downtime->parent;
+ }
+
+ $this->addHtml(new HorizontalKeyValue(
+ $label,
+ HtmlElement::create('span', ['class' => 'accompanying-text'], TemplateString::create(
+ $relatedDowntime->is_flexible
+ ? t('{{#link}}Flexible Downtime{{/link}} for %s')
+ : t('{{#link}}Fixed Downtime{{/link}} for %s'),
+ ['link' => new Link(null, Links::downtime($relatedDowntime), ['class' => 'subject'])],
+ ($relatedDowntime->object_type === 'host'
+ ? $this->createHostLink($relatedDowntime->host, true)
+ : $this->createServiceLink($relatedDowntime->service, $relatedDowntime->host, true))
+ ))
+ ));
+ }
+
+ $this->add(new HorizontalKeyValue(
+ t('Created'),
+ WebDateFormatter::formatDateTime($this->downtime->entry_time->getTimestamp())
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Start time'),
+ $this->downtime->start_time
+ ? WebDateFormatter::formatDateTime($this->downtime->start_time->getTimestamp())
+ : new EmptyState(t('Not started yet'))
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('End time'),
+ $this->downtime->end_time
+ ? WebDateFormatter::formatDateTime($this->downtime->end_time->getTimestamp())
+ : new EmptyState(t('Not started yet'))
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled Start'),
+ WebDateFormatter::formatDateTime($this->downtime->scheduled_start_time->getTimestamp())
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled End'),
+ WebDateFormatter::formatDateTime($this->downtime->scheduled_end_time->getTimestamp())
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled Duration'),
+ DateFormatter::formatDuration($this->downtime->scheduled_duration / 1000)
+ ));
+ if ($this->downtime->is_flexible) {
+ $this->add(new HorizontalKeyValue(
+ t('Flexible Duration'),
+ DateFormatter::formatDuration($this->downtime->flexible_duration / 1000)
+ ));
+ }
+
+ $query = Downtime::on($this->getDb())->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ])
+ ->limit(3)
+ ->filter(Filter::equal('parent_id', $this->downtime->id))
+ ->orFilter(Filter::equal('triggered_by_id', $this->downtime->id));
+ $this->applyRestrictions($query);
+
+ $children = $query->peekAhead()->execute();
+ if ($children->hasResult()) {
+ $this->addHtml(
+ new HtmlElement('h2', null, Text::create(t('Children'))),
+ new DowntimeList($children),
+ (new ShowMore($children, Links::downtimes()->setQueryString(
+ QueryString::render(Filter::any(
+ Filter::equal('downtime.parent.name', $this->downtime->name),
+ Filter::equal('downtime.triggered_by.name', $this->downtime->name)
+ ))
+ )))->setBaseTarget('_next')
+ );
+ }
+
+ $this->add(Html::tag('h2', t('Progress')));
+ $this->add($this->createTimeline());
+
+ if (
+ getenv('ICINGAWEB_EXPORT_FORMAT') !== 'pdf'
+ && $this->isGrantedOn(
+ 'icingadb/command/downtime/delete',
+ $this->downtime->{$this->downtime->object_type}
+ )
+ ) {
+ $this->add($this->createCancelDowntimeForm());
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/EventDetail.php b/library/Icingadb/Widget/Detail/EventDetail.php
new file mode 100644
index 0000000..181c9ae
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/EventDetail.php
@@ -0,0 +1,651 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use DateTime;
+use DateTimeZone;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Model\AcknowledgementHistory;
+use Icinga\Module\Icingadb\Model\CommentHistory;
+use Icinga\Module\Icingadb\Model\DowntimeHistory;
+use Icinga\Module\Icingadb\Model\FlappingHistory;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\NotificationHistory;
+use Icinga\Module\Icingadb\Model\StateHistory;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Widget\CopyToClipboard;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemTable\UserTable;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Str;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBall;
+
+class EventDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+ use HostLink;
+ use ServiceLink;
+ use TicketLinks;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'object-detail'];
+
+ /** @var History */
+ protected $event;
+
+ public function __construct(History $event)
+ {
+ $this->event = $event;
+ }
+
+ protected function assembleNotificationEvent(NotificationHistory $notification)
+ {
+ $pluginOutput = [];
+
+ $commandName = $notification->object_type === 'host'
+ ? $this->event->host->checkcommand_name
+ : $this->event->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($notification->text)) {
+ $notificationText = new EmptyState(t('Output unavailable.'));
+ } else {
+ $notificationText = new PluginOutputContainer(
+ (new PluginOutput($notification->text))
+ ->setCommandName($notification->object_type === 'host'
+ ? $this->event->host->checkcommand_name
+ : $this->event->service->checkcommand_name)
+ );
+
+ CopyToClipboard::attachTo($notificationText);
+ }
+
+ $pluginOutput = [
+ HtmlElement::create('h2', null, $notification->author ? t('Comment') : t('Plugin Output')),
+ HtmlElement::create('div', [
+ 'id' => 'check-output-' . $commandName,
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100
+ ], $notificationText)
+ ];
+ } else {
+ $pluginOutput[] = new EmptyState(t('Waiting for Icinga DB to synchronize the config.'));
+ }
+
+ if ($notification->object_type === 'host') {
+ $objectKey = t('Host');
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(HostStates::text($notification->previous_hard_state), StateBall::SIZE_MEDIUM),
+ new StateBall(HostStates::text($notification->state), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ]);
+ } else {
+ $objectKey = t('Service');
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(ServiceStates::text($notification->previous_hard_state), StateBall::SIZE_MEDIUM),
+ new StateBall(ServiceStates::text($notification->state), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ]);
+ }
+
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ new HorizontalKeyValue(
+ t('Sent On'),
+ DateFormatter::formatDateTime($notification->send_time->getTimestamp())
+ )
+ ];
+
+ if ($notification->author) {
+ $eventInfo[] = (new HorizontalKeyValue(t('Sent by'), [
+ new Icon('user'),
+ $notification->author
+ ]));
+ }
+
+ $eventInfo[] = new HorizontalKeyValue(t('Type'), ucfirst(Str::camel($notification->type)));
+ $eventInfo[] = new HorizontalKeyValue(t('State'), $notification->object_type === 'host'
+ ? ucfirst(HostStates::text($notification->state))
+ : ucfirst(ServiceStates::text($notification->state)));
+ $eventInfo[] = new HorizontalKeyValue($objectKey, $objectInfo);
+
+
+ $notifiedUsers = [new HtmlElement('h2', null, Text::create(t('Notified Users')))];
+
+ if ($notification->users_notified === 0) {
+ $notifiedUsers[] = new EmptyState(t('None', 'notified users: none'));
+ } elseif (! $this->isPermittedRoute('users')) {
+ $notifiedUsers[] = Text::create(sprintf(tp(
+ 'This notification was sent to a single user',
+ 'This notification was sent to %d users',
+ $notification->users_notified
+ ), $notification->users_notified));
+ } elseif ($notification->users_notified > 0) {
+ $users = $notification->user
+ ->limit(5)
+ ->peekAhead();
+
+ $users = $users->execute();
+ /** @var ResultSet $users */
+
+ $notifiedUsers[] = new UserTable($users);
+ $notifiedUsers[] = (new ShowMore(
+ $users,
+ Links::users()->addParams(['notification_history.id' => bin2hex($notification->id)]),
+ sprintf(t('Show all %d recipients'), $notification->users_notified)
+ ))->setBaseTarget('_next');
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $pluginOutput,
+ 200 => $eventInfo,
+ 500 => $notifiedUsers
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleStateChangeEvent(StateHistory $stateChange)
+ {
+ $pluginOutput = [];
+
+ $commandName = $stateChange->object_type === 'host'
+ ? $this->event->host->checkcommand_name
+ : $this->event->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($stateChange->output) && empty($stateChange->long_output)) {
+ $commandOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $commandOutput = new PluginOutputContainer(
+ (new PluginOutput($stateChange->output . "\n" . $stateChange->long_output))
+ ->setCommandName($commandName)
+ );
+
+ CopyToClipboard::attachTo($commandOutput);
+ }
+
+ $pluginOutput = [
+ new HtmlElement('h2', null, Text::create(t('Plugin Output'))),
+ HtmlElement::create('div', [
+ 'id' => 'check-output-' . $commandName,
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100
+ ], $commandOutput)
+ ];
+ } else {
+ $pluginOutput[] = new EmptyState(t('Waiting for Icinga DB to synchronize the config.'));
+ }
+
+ if ($stateChange->object_type === 'host') {
+ $objectKey = t('Host');
+ $objectState = $stateChange->state_type === 'hard' ? $stateChange->hard_state : $stateChange->soft_state;
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(HostStates::text($stateChange->previous_soft_state), StateBall::SIZE_MEDIUM),
+ new StateBall(HostStates::text($objectState), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ]);
+ } else {
+ $objectKey = t('Service');
+ $objectState = $stateChange->state_type === 'hard' ? $stateChange->hard_state : $stateChange->soft_state;
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(ServiceStates::text($stateChange->previous_soft_state), StateBall::SIZE_MEDIUM),
+ new StateBall(ServiceStates::text($objectState), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ]);
+ }
+
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ new HorizontalKeyValue(
+ t('Occurred On'),
+ DateFormatter::formatDateTime($stateChange->event_time->getTimestamp())
+ ),
+ new HorizontalKeyValue(t('Scheduling Source'), $stateChange->scheduling_source),
+ new HorizontalKeyValue(t('Check Source'), $stateChange->check_source)
+ ];
+
+ if ($stateChange->state_type === 'soft') {
+ $eventInfo[] = new HorizontalKeyValue(t('Check Attempt'), sprintf(
+ t('%d of %d'),
+ $stateChange->check_attempt,
+ $stateChange->max_check_attempts
+ ));
+ }
+
+ $eventInfo[] = new HorizontalKeyValue(
+ t('State'),
+ $stateChange->object_type === 'host'
+ ? ucfirst(HostStates::text($objectState))
+ : ucfirst(ServiceStates::text($objectState))
+ );
+
+ $eventInfo[] = new HorizontalKeyValue(
+ t('State Type'),
+ $stateChange->state_type === 'hard' ? t('Hard', 'state') : t('Soft', 'state')
+ );
+
+ $eventInfo[] = new HorizontalKeyValue($objectKey, $objectInfo);
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $pluginOutput,
+ 200 => $eventInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleDowntimeEvent(DowntimeHistory $downtime)
+ {
+ $commentInfo = [
+ new HtmlElement('h2', null, Text::create(t('Comment'))),
+ new MarkdownText($this->createTicketLinks($downtime->comment))
+ ];
+
+ $eventInfo = [new HtmlElement('h2', null, Text::create(t('Event Info')))];
+
+ if ($downtime->triggered_by_id !== null || $downtime->parent_id !== null) {
+ if ($downtime->triggered_by_id !== null) {
+ $label = t('Triggered By');
+ $relatedDowntime = $downtime->triggered_by;
+ } else {
+ $label = t('Parent');
+ $relatedDowntime = $downtime->parent;
+ }
+
+ $query = History::on($this->getDb())
+ ->columns('id')
+ ->filter(Filter::equal('event_type', 'downtime_start'))
+ ->filter(Filter::equal('history.downtime_history_id', $relatedDowntime->downtime_id));
+ $this->applyRestrictions($query);
+ if (($relatedEvent = $query->first()) !== null) {
+ /** @var History $relatedEvent */
+ $eventInfo[] = new HorizontalKeyValue(
+ $label,
+ HtmlElement::create('span', ['class' => 'accompanying-text'], TemplateString::create(
+ $relatedDowntime->is_flexible
+ ? t('{{#link}}Flexible Downtime{{/link}} for %s')
+ : t('{{#link}}Fixed Downtime{{/link}} for %s'),
+ ['link' => new Link(null, Links::event($relatedEvent), ['class' => 'subject'])],
+ ($relatedDowntime->object_type === 'host'
+ ? $this->createHostLink($relatedDowntime->host, true)
+ : $this->createServiceLink($relatedDowntime->service, $relatedDowntime->host, true))
+ ->addAttributes(['class' => 'subject'])
+ ))
+ );
+ }
+ }
+
+ $eventInfo[] = $downtime->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Entered On'),
+ DateFormatter::formatDateTime($downtime->entry_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Author'), [new Icon('user'), $downtime->author]);
+ // TODO: The following should be presented in a specific widget (maybe just like the downtime card)
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Triggered On'),
+ DateFormatter::formatDateTime($downtime->trigger_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Scheduled Start'),
+ DateFormatter::formatDateTime($downtime->scheduled_start_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Actual Start'),
+ DateFormatter::formatDateTime($downtime->start_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Scheduled End'),
+ DateFormatter::formatDateTime($downtime->scheduled_end_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Actual End'),
+ DateFormatter::formatDateTime($downtime->end_time->getTimestamp())
+ );
+
+ if ($downtime->is_flexible) {
+ $eventInfo[] = new HorizontalKeyValue(t('Flexible'), t('Yes'));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Duration'),
+ DateFormatter::formatDuration($downtime->flexible_duration / 1000)
+ );
+ }
+
+ $cancelInfo = [];
+ if ($downtime->has_been_cancelled) {
+ $cancelInfo = [
+ new HtmlElement('h2', null, Text::create(t('This downtime has been cancelled'))),
+ new HorizontalKeyValue(
+ t('Cancelled On'),
+ DateFormatter::formatDateTime($downtime->cancel_time->getTimestamp())
+ ),
+ new HorizontalKeyValue(t('Cancelled by'), [new Icon('user'), $downtime->cancelled_by])
+ ];
+ }
+
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $commentInfo,
+ 201 => $eventInfo,
+ 600 => $cancelInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleCommentEvent(CommentHistory $comment)
+ {
+ $commentInfo = [
+ new HtmlElement('h2', null, Text::create(t('Comment'))),
+ new MarkdownText($this->createTicketLinks($comment->comment))
+ ];
+
+ $eventInfo = [new HtmlElement('h2', null, Text::create(t('Event Info')))];
+ $eventInfo[] = $comment->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Entered On'),
+ DateFormatter::formatDateTime($comment->entry_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Author'), [new Icon('user'), $comment->author]);
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Expires On'),
+ $comment->expire_time
+ ? DateFormatter::formatDateTime($comment->expire_time->getTimestamp())
+ : new EmptyState(t('Never'))
+ );
+
+ $tiedToAckInfo = [];
+ if ($comment->entry_type === 'ack') {
+ $tiedToAckInfo = [
+ new HtmlElement('h2', null, Text::create(t('This comment is tied to an acknowledgement'))),
+ new HorizontalKeyValue(t('Sticky'), $comment->is_sticky ? t('Yes') : t('No')),
+ new HorizontalKeyValue(t('Persistent'), $comment->is_persistent ? t('Yes') : t('No'))
+ ];
+ }
+
+ $removedInfo = [];
+ if ($comment->has_been_removed) {
+ $removedInfo[] = new HtmlElement('h2', null, Text::create(t('This comment has been removed')));
+ if ($comment->removed_by) {
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Removed On'),
+ DateFormatter::formatDateTime($comment->remove_time->getTimestamp())
+ );
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Removed by'),
+ [new Icon('user'), $comment->removed_by]
+ );
+ } else {
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Expired On'),
+ DateFormatter::formatDateTime($comment->remove_time->getTimestamp())
+ );
+ }
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $commentInfo,
+ 201 => $eventInfo,
+ 500 => $tiedToAckInfo,
+ 600 => $removedInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleFlappingEvent(FlappingHistory $flapping)
+ {
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ $flapping->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ )),
+ new HorizontalKeyValue(
+ t('Started on'),
+ DateFormatter::formatDateTime($flapping->start_time->getTimestamp())
+ )
+ ];
+ if ($this->event->event_type === 'flapping_start') {
+ $eventInfo[] = new HorizontalKeyValue(t('Reason'), sprintf(
+ t('State change rate of %.2f%% exceeded the threshold (%.2f%%)'),
+ $flapping->percent_state_change_start,
+ $flapping->flapping_threshold_high
+ ));
+ } else {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Ended on'),
+ DateFormatter::formatDateTime($flapping->end_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Reason'), sprintf(
+ t('State change rate of %.2f%% undercut the threshold (%.2f%%)'),
+ $flapping->percent_state_change_end,
+ $flapping->flapping_threshold_low
+ ));
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $eventInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleAcknowledgeEvent(AcknowledgementHistory $acknowledgement)
+ {
+ $commentInfo = [];
+ if ($acknowledgement->comment) {
+ $commentInfo = [
+ new HtmlElement('h2', null, Text::create(t('Comment'))),
+ new MarkdownText($this->createTicketLinks($acknowledgement->comment))
+ ];
+ } elseif (! isset($acknowledgement->author)) {
+ $commentInfo[] = new EmptyState(t('This acknowledgement was set before Icinga DB history recording'));
+ }
+
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ new HorizontalKeyValue(
+ t('Set on'),
+ DateFormatter::formatDateTime($acknowledgement->set_time->getTimestamp())
+ ),
+ new HorizontalKeyValue(t('Author'), $acknowledgement->author
+ ? [new Icon('user'), $acknowledgement->author]
+ : new EmptyState(t('n. a.'))),
+ $acknowledgement->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ))
+ ];
+
+ if ($this->event->event_type === 'ack_set') {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Expires On'),
+ $acknowledgement->expire_time
+ ? DateFormatter::formatDateTime($acknowledgement->expire_time->getTimestamp())
+ : new EmptyState(t('Never'))
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Sticky'), isset($acknowledgement->is_sticky)
+ ? ($acknowledgement->is_sticky ? t('Yes') : t('No'))
+ : new EmptyState(t('n. a.')));
+ $eventInfo[] = new HorizontalKeyValue(t('Persistent'), isset($acknowledgement->is_persistent)
+ ? ($acknowledgement->is_persistent ? t('Yes') : t('No'))
+ : new EmptyState(t('n. a.')));
+ } else {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Cleared on'),
+ DateFormatter::formatDateTime(
+ $acknowledgement->clear_time
+ ? $acknowledgement->clear_time->getTimestamp()
+ : $this->event->event_time->getTimestamp()
+ )
+ );
+ if ($acknowledgement->cleared_by) {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Cleared by'),
+ [new Icon('user', $acknowledgement->cleared_by)]
+ );
+ } else {
+ $expired = false;
+ if ($acknowledgement->expire_time) {
+ $now = (new DateTime())->setTimezone(new DateTimeZone('UTC'));
+ $expiresOn = clone $now;
+ $expiresOn->setTimestamp($acknowledgement->expire_time->getTimestamp());
+ if ($now <= $expiresOn) {
+ $expired = true;
+ $eventInfo[] = new HorizontalKeyValue(t('Removal Reason'), t(
+ 'The acknowledgement expired on %s',
+ DateFormatter::formatDateTime($acknowledgement->expire_time->getTimestamp())
+ ));
+ }
+ }
+
+ if (! $expired) {
+ if ($acknowledgement->is_sticky) {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Reason'),
+ $acknowledgement->object_type === 'host'
+ ? t('Host recovered')
+ : t('Service recovered')
+ );
+ } else {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Reason'),
+ $acknowledgement->object_type === 'host'
+ ? t('Host recovered') // Hosts have no other state between UP and DOWN
+ : t('Service changed its state')
+ );
+ }
+ }
+ }
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $commentInfo,
+ 201 => $eventInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectDetailExtensionHook::loadExtensions($this->event);
+ }
+
+ protected function assemble()
+ {
+ switch ($this->event->event_type) {
+ case 'notification':
+ $this->assembleNotificationEvent($this->event->notification);
+
+ break;
+ case 'state_change':
+ $this->assembleStateChangeEvent($this->event->state);
+
+ break;
+ case 'downtime_start':
+ case 'downtime_end':
+ $this->assembleDowntimeEvent($this->event->downtime);
+
+ break;
+ case 'comment_add':
+ case 'comment_remove':
+ $this->assembleCommentEvent($this->event->comment);
+
+ break;
+ case 'flapping_start':
+ case 'flapping_end':
+ $this->assembleFlappingEvent($this->event->flapping);
+
+ break;
+ case 'ack_set':
+ case 'ack_clear':
+ $this->assembleAcknowledgeEvent($this->event->acknowledgement);
+
+ break;
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostDetail.php b/library/Icingadb/Widget/Detail/HostDetail.php
new file mode 100644
index 0000000..8b80480
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostDetail.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\EmptyState;
+
+class HostDetail extends ObjectDetail
+{
+ protected $serviceSummary;
+
+ public function __construct(Host $object, ServicestateSummary $serviceSummary)
+ {
+ parent::__construct($object);
+
+ $this->serviceSummary = $serviceSummary;
+ }
+
+ protected function createServiceStatistics(): array
+ {
+ if ($this->serviceSummary->services_total > 0) {
+ $services = new ServiceStatistics($this->serviceSummary);
+ $services->setBaseFilter(Filter::equal('host.name', $this->object->name));
+ } else {
+ $services = new EmptyState(t('This host has no services'));
+ }
+
+ return [Html::tag('h2', t('Services')), $services];
+ }
+
+ protected function assemble()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->add($this->createPrintHeader());
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $this->createPluginOutput(),
+ 190 => $this->createServiceStatistics(),
+ 300 => $this->createActions(),
+ 301 => $this->createNotes(),
+ 400 => $this->createComments(),
+ 401 => $this->createDowntimes(),
+ 500 => $this->createGroups(),
+ 501 => $this->createNotifications(),
+ 600 => $this->createCheckStatistics(),
+ 601 => $this->createPerformanceData(),
+ 700 => $this->createCustomVars(),
+ 701 => $this->createFeatureToggles()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostInspectionDetail.php b/library/Icingadb/Widget/Detail/HostInspectionDetail.php
new file mode 100644
index 0000000..93b35b8
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostInspectionDetail.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\ObjectInspectionDetail;
+
+class HostInspectionDetail extends ObjectInspectionDetail
+{
+ protected function assemble()
+ {
+ $this->add([
+ $this->createSourceLocation(),
+ $this->createLastCheckResult(),
+ $this->createAttributes(),
+ $this->createCustomVariables(),
+ $this->createRedisInfo()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostMetaInfo.php b/library/Icingadb/Widget/Detail/HostMetaInfo.php
new file mode 100644
index 0000000..78209aa
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostMetaInfo.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Web\Widget\Icon;
+
+class HostMetaInfo extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'object-meta-info'];
+
+ /** @var Host */
+ protected $host;
+
+ public function __construct(Host $host)
+ {
+ $this->host = $host;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml(
+ new VerticalKeyValue('host.name', $this->host->name),
+ new HtmlElement(
+ 'div',
+ null,
+ new HorizontalKeyValue(
+ 'host.address',
+ $this->host->address ?: new EmptyState(t('None', 'address'))
+ ),
+ new HorizontalKeyValue(
+ 'host.address6',
+ $this->host->address6 ?: new EmptyState(t('None', 'address'))
+ )
+ ),
+ new VerticalKeyValue(
+ 'last_state_change',
+ $this->host->state->last_state_change !== null
+ ? DateFormatter::formatDateTime($this->host->state->last_state_change->getTimestamp())
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ );
+
+ $collapsible = new HtmlElement('div', Attributes::create([
+ 'class' => 'collapsible',
+ 'id' => 'object-meta-info',
+ 'data-toggle-element' => '.object-meta-info-control',
+ 'data-visible-height' => 0
+ ]));
+
+ $renderHelper = new HtmlDocument();
+ $renderHelper->addHtml(
+ $this,
+ new HtmlElement(
+ 'button',
+ Attributes::create(['class' => 'object-meta-info-control']),
+ new Icon('angle-double-up', ['class' => 'collapse-icon']),
+ new Icon('angle-double-down', ['class' => 'expand-icon'])
+ )
+ );
+
+ $this->addWrapper($collapsible);
+ $this->addWrapper($renderHelper);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostStatistics.php b/library/Icingadb/Widget/Detail/HostStatistics.php
new file mode 100644
index 0000000..bcfc3f8
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostStatistics.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\HostStateBadges;
+use ipl\Html\ValidHtml;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\HtmlString;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Link;
+
+class HostStatistics extends ObjectStatistics
+{
+ protected $summary;
+
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function createDonut(): ValidHtml
+ {
+ $donut = (new Donut())
+ ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending']);
+
+ return HtmlString::create($donut->render());
+ }
+
+ protected function createTotal(): ValidHtml
+ {
+ $url = Links::hosts();
+ if ($this->hasBaseFilter()) {
+ $url->setFilter($this->getBaseFilter());
+ }
+
+ return new Link(
+ (new VerticalKeyValue(
+ tp('Host', 'Hosts', $this->summary->hosts_total),
+ $this->shortenAmount($this->summary->hosts_total)
+ ))->setAttribute('title', $this->summary->hosts_total),
+ $url
+ );
+ }
+
+ protected function createBadges(): ValidHtml
+ {
+ $badges = new HostStateBadges($this->summary);
+ if ($this->hasBaseFilter()) {
+ $badges->setBaseFilter($this->getBaseFilter());
+ }
+
+ return $badges;
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/MultiselectQuickActions.php b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php
new file mode 100644
index 0000000..f398d80
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php
@@ -0,0 +1,194 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Stdlib\BaseFilter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+
+class MultiselectQuickActions extends BaseHtmlElement
+{
+ use BaseFilter;
+ use Auth;
+
+ protected $summary;
+
+ protected $type;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'quick-actions'];
+
+ public function __construct($type, $summary)
+ {
+ $this->summary = $summary;
+ $this->type = $type;
+ }
+
+ protected function assemble()
+ {
+ $unacknowledged = "{$this->type}s_problems_unacknowledged";
+ $acks = "{$this->type}s_acknowledged";
+ $activeChecks = "{$this->type}s_active_checks_enabled";
+
+ if (
+ $this->summary->$unacknowledged > $this->summary->$acks
+ && $this->isGrantedOnType(
+ 'icingadb/command/acknowledge-problem',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'acknowledge',
+ t('Acknowledge'),
+ 'check-circle',
+ t('Acknowledge this problem, suppress all future notifications for it and tag it as being handled')
+ );
+ }
+
+ if (
+ $this->summary->$acks > 0
+ && $this->isGrantedOnType(
+ 'icingadb/command/remove-acknowledgement',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $removeAckForm = (new RemoveAcknowledgementForm())
+ ->setAction($this->getLink('removeAcknowledgement'))
+ ->setObjects(array_fill(0, $this->summary->$acks, null));
+
+ $this->add(Html::tag('li', $removeAckForm));
+ }
+
+ if (
+ $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false)
+ || (
+ $this->summary->$activeChecks > 0
+ && $this->isGrantedOnType(
+ 'icingadb/command/schedule-check/active-only',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ )
+ ) {
+ $this->add(Html::tag('li', (new CheckNowForm())->setAction($this->getLink('checkNow'))));
+ }
+
+ if ($this->isGrantedOnType('icingadb/command/comment/add', $this->type, $this->getBaseFilter(), false)) {
+ $this->assembleAction(
+ 'addComment',
+ t('Comment'),
+ 'comment',
+ t('Add a new comment')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType(
+ 'icingadb/command/send-custom-notification',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'sendCustomNotification',
+ t('Notification'),
+ 'bell',
+ t('Send a custom notification')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType(
+ 'icingadb/command/downtime/schedule',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'scheduleDowntime',
+ t('Downtime'),
+ 'plug',
+ t('Schedule a downtime to suppress all problem notifications within a specific period of time')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false)
+ || (
+ $this->summary->$activeChecks > 0
+ && $this->isGrantedOnType(
+ 'icingadb/command/schedule-check/active-only',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ )
+ ) {
+ $this->assembleAction(
+ 'scheduleCheck',
+ t('Reschedule'),
+ 'calendar',
+ t('Schedule the next active check at a different time than the current one')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType(
+ 'icingadb/command/process-check-result',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'processCheckresult',
+ t('Process check result'),
+ 'edit',
+ t('Submit passive check result')
+ );
+ }
+ }
+
+ protected function assembleAction(string $action, string $label, string $icon, string $title)
+ {
+ $link = Html::tag(
+ 'a',
+ [
+ 'href' => $this->getLink($action),
+ 'class' => 'action-link',
+ 'title' => $title,
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ],
+ [
+ new Icon($icon),
+ $label
+ ]
+ );
+
+ $this->add(Html::tag('li', $link));
+ }
+
+ protected function getLink(string $action): string
+ {
+ return Url::fromPath("icingadb/{$this->type}s/$action")
+ ->setFilter($this->getBaseFilter())
+ ->getAbsoluteUrl();
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ObjectDetail.php b/library/Icingadb/Widget/Detail/ObjectDetail.php
new file mode 100644
index 0000000..a688173
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectDetail.php
@@ -0,0 +1,596 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Exception;
+use Icinga\Application\ClassLoader;
+use Icinga\Application\Hook;
+use Icinga\Application\Hook\GrapherHook;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Web;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\HostLinks;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\Macros;
+use Icinga\Module\Icingadb\Compat\CompatHost;
+use Icinga\Module\Icingadb\Compat\CompatService;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use Icinga\Module\Icingadb\Web\Navigation\Action;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Common\ServiceLinks;
+use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
+use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Widget\CopyToClipboard;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\TagList;
+use Icinga\Module\Monitoring\Hook\DetailviewExtensionHook;
+use Icinga\Web\Navigation\Navigation;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class ObjectDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+ use Macros;
+
+ protected $object;
+
+ protected $compatObject;
+
+ protected $objectType;
+
+ protected $defaultAttributes = [
+ // Class host-detail is kept as the grafana module's iframe.js depends on it
+ 'class' => ['object-detail', 'host-detail'],
+ 'data-pdfexport-page-breaks-at' => 'h2'
+ ];
+
+ protected $tag = 'div';
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ $this->objectType = $object instanceof Host ? 'host' : 'service';
+ }
+
+ protected function compatObject()
+ {
+ if ($this->compatObject === null) {
+ $this->compatObject = CompatHost::fromModel($this->object);
+ }
+
+ return $this->compatObject;
+ }
+
+ protected function createPrintHeader()
+ {
+ $info = [new HorizontalKeyValue(t('Name'), $this->object->name)];
+
+ if ($this->objectType === 'host') {
+ $info[] = new HorizontalKeyValue(
+ t('IPv4 Address'),
+ $this->object->address ?: new EmptyState(t('None', 'address'))
+ );
+ $info[] = new HorizontalKeyValue(
+ t('IPv6 Address'),
+ $this->object->address6 ?: new EmptyState(t('None', 'address'))
+ );
+ }
+
+ $info[] = new HorizontalKeyValue(t('State'), [
+ $this->object->state->getStateTextTranslated(),
+ ' ',
+ new StateBall($this->object->state->getStateText())
+ ]);
+
+ $info[] = new HorizontalKeyValue(
+ t('Last State Change'),
+ DateFormatter::formatDateTime($this->object->state->last_state_change->getTimestamp())
+ );
+
+ return [
+ new HtmlElement('h2', null, Text::create(
+ $this->objectType === 'host' ? t('Host') : t('Service')
+ )),
+ $info
+ ];
+ }
+
+ protected function createActions()
+ {
+ $this->fetchCustomVars();
+
+ $navigation = new Navigation();
+ $navigation->load('icingadb-' . $this->objectType . '-action');
+ /** @var Action $item */
+ foreach ($navigation as $item) {
+ $item->setObject($this->object);
+ }
+
+ $monitoringInstalled = Icinga::app()->getModuleManager()->hasInstalled('monitoring');
+ $obj = $monitoringInstalled ? $this->compatObject() : $this->object;
+ foreach ($this->object->action_url->first()->action_url ?? [] as $url) {
+ $url = $this->expandMacros($url, $obj);
+ $navigation->addItem(
+ Html::wantHtml([
+ // Add warning to links that open in new tabs, as recommended by WCAG20 G201
+ new Icon('external-link-alt', ['title' => t('Link opens in a new window')]),
+ $url
+ ])->render(),
+ [
+ 'target' => '_blank',
+ 'url' => $url,
+ 'renderer' => [
+ 'NavigationItemRenderer',
+ 'escape_label' => false
+ ]
+ ]
+ );
+ }
+
+ $moduleActions = ObjectActionsHook::loadActions($this->object);
+
+ $nativeExtensionProviders = [];
+ foreach ($moduleActions->getContent() as $item) {
+ if ($item->getAttributes()->has('data-icinga-module')) {
+ $nativeExtensionProviders[$item->getAttributes()->get('data-icinga-module')->getValue()] = true;
+ }
+ }
+
+ if (Icinga::app()->getModuleManager()->hasInstalled('monitoring')) {
+ foreach (Hook::all('Monitoring\\' . ucfirst($this->objectType) . 'Actions') as $hook) {
+ $moduleName = ClassLoader::extractModuleName(get_class($hook));
+ if (! isset($nativeExtensionProviders[$moduleName])) {
+ try {
+ $navigation->merge($hook->getNavigation($this->compatObject()));
+ } catch (Exception $e) {
+ Logger::error("Failed to load legacy action hook: %s\n%s", $e, $e->getTraceAsString());
+ $navigation->addItem($moduleName, ['label' => IcingaException::describe($e), 'url' => '#']);
+ }
+ }
+ }
+ }
+
+ if ($moduleActions->isEmpty() && ($navigation->isEmpty() || ! $navigation->hasRenderableItems())) {
+ return null;
+ }
+
+ return [
+ Html::tag('h2', t('Actions')),
+ new HtmlString($navigation->getRenderer()->setCssClass('object-detail-actions')->render()),
+ $moduleActions->isEmpty() ? null : $moduleActions
+ ];
+ }
+
+ protected function createCheckStatistics(): array
+ {
+ return [
+ Html::tag('h2', t('Check Statistics')),
+ new CheckStatistics($this->object)
+ ];
+ }
+
+ protected function createComments(): array
+ {
+ if ($this->objectType === 'host') {
+ $link = HostLinks::comments($this->object);
+ $relations = ['host', 'host.state'];
+ } else {
+ $link = ServiceLinks::comments($this->object, $this->object->host);
+ $relations = ['service', 'service.state', 'service.host', 'service.host.state'];
+ }
+
+ $comments = $this->object->comment
+ ->with($relations)
+ ->limit(3)
+ ->peekAhead();
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $comments->filter(Filter::equal('object_type', $this->objectType));
+
+ $comments = $comments->execute();
+ /** @var ResultSet $comments */
+
+ $content = [Html::tag('h2', t('Comments'))];
+
+ if ($comments->hasResult()) {
+ $content[] = (new CommentList($comments))->setObjectLinkDisabled()->setTicketLinkEnabled();
+ $content[] = (new ShowMore($comments, $link))->setBaseTarget('_next');
+ } else {
+ $content[] = new EmptyState(t('No comments created.'));
+ }
+
+ return $content;
+ }
+
+ protected function createCustomVars(): array
+ {
+ $content = [Html::tag('h2', t('Custom Variables'))];
+
+ $this->fetchCustomVars();
+ $vars = (new CustomvarFlat())->unFlattenVars($this->object->customvar_flat);
+ if (! empty($vars)) {
+ $content[] = new HtmlElement('div', Attributes::create([
+ 'id' => $this->objectType . '-customvars',
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200
+ ]), new CustomVarTable($vars, $this->object));
+ } else {
+ $content[] = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return $content;
+ }
+
+ protected function createDowntimes(): array
+ {
+ if ($this->objectType === 'host') {
+ $link = HostLinks::downtimes($this->object);
+ $relations = ['host', 'host.state'];
+ } else {
+ $link = ServiceLinks::downtimes($this->object, $this->object->host);
+ $relations = ['service', 'service.state', 'service.host', 'service.host.state'];
+ }
+
+ $downtimes = $this->object->downtime
+ ->with($relations)
+ ->limit(3)
+ ->peekAhead();
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $downtimes->filter(Filter::equal('object_type', $this->objectType));
+
+ $downtimes = $downtimes->execute();
+ /** @var ResultSet $downtimes */
+
+ $content = [Html::tag('h2', t('Downtimes'))];
+
+ if ($downtimes->hasResult()) {
+ $content[] = (new DowntimeList($downtimes))->setObjectLinkDisabled()->setTicketLinkEnabled();
+ $content[] = (new ShowMore($downtimes, $link))->setBaseTarget('_next');
+ } else {
+ $content[] = new EmptyState(t('No downtimes scheduled.'));
+ }
+
+ return $content;
+ }
+
+ protected function createGroups(): array
+ {
+ $groups = [Html::tag('h2', t('Groups'))];
+
+ if ($this->objectType === 'host') {
+ $hostgroups = [];
+ if ($this->isPermittedRoute('hostgroups')) {
+ $hostgroups = $this->object->hostgroup;
+ $this->applyRestrictions($hostgroups);
+ }
+
+ $hostgroupList = new TagList();
+ foreach ($hostgroups as $hostgroup) {
+ $hostgroupList->addLink($hostgroup->display_name, Links::hostgroup($hostgroup));
+ }
+
+ $groups[] = $hostgroupList->hasContent()
+ ? $hostgroupList
+ : new EmptyState(t('Not a member of any host group.'));
+ } else {
+ $servicegroups = [];
+ if ($this->isPermittedRoute('servicegroups')) {
+ $servicegroups = $this->object->servicegroup;
+ $this->applyRestrictions($servicegroups);
+ }
+
+ $servicegroupList = new TagList();
+ foreach ($servicegroups as $servicegroup) {
+ $servicegroupList->addLink($servicegroup->display_name, Links::servicegroup($servicegroup));
+ }
+
+ $groups[] = $servicegroupList->hasContent()
+ ? $servicegroupList
+ : new EmptyState(t('Not a member of any service group.'));
+ }
+
+ return $groups;
+ }
+
+ protected function createNotes()
+ {
+ $navigation = new Navigation();
+ $notes = trim($this->object->notes);
+
+ $monitoringInstalled = Icinga::app()->getModuleManager()->hasInstalled('monitoring');
+ $obj = $monitoringInstalled ? $this->compatObject() : $this->object;
+ foreach ($this->object->notes_url->first()->notes_url ?? [] as $url) {
+ $url = $this->expandMacros($url, $obj);
+ $navigation->addItem(
+ Html::wantHtml([
+ // Add warning to links that open in new tabs, as recommended by WCAG20 G201
+ new Icon('external-link-alt', ['title' => t('Link opens in a new window')]),
+ $url
+ ])->render(),
+ [
+ 'target' => '_blank',
+ 'url' => $url,
+ 'renderer' => [
+ 'NavigationItemRenderer',
+ 'escape_label' => false
+ ]
+ ]
+ );
+ }
+
+ $content = [];
+
+ if (! $navigation->isEmpty() && $navigation->hasRenderableItems()) {
+ $content[] = new HtmlString($navigation->getRenderer()->setCssClass('object-detail-actions')->render());
+ }
+
+ if ($notes !== '') {
+ $content[] = (new MarkdownText($notes))
+ ->addAttributes([
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200,
+ 'id' => $this->objectType . '-notes'
+ ]);
+ }
+
+ if (empty($content)) {
+ return null;
+ }
+
+ array_unshift($content, Html::tag('h2', t('Notes')));
+
+ return $content;
+ }
+
+ protected function createNotifications(): array
+ {
+ list($users, $usergroups) = $this->getUsersAndUsergroups();
+
+ $userList = new TagList();
+ $usergroupList = new TagList();
+
+ foreach ($users as $user) {
+ $userList->addLink([new Icon(Icons::USER), $user->display_name], Links::user($user));
+ }
+
+ foreach ($usergroups as $usergroup) {
+ $usergroupList->addLink(
+ [new Icon(Icons::USERGROUP), $usergroup->display_name],
+ Links::usergroup($usergroup)
+ );
+ }
+
+ return [
+ Html::tag('h2', t('Notifications')),
+ new HorizontalKeyValue(
+ t('Users'),
+ $userList->hasContent() ? $userList : new EmptyState(t('No users configured.'))
+ ),
+ new HorizontalKeyValue(
+ t('User Groups'),
+ $usergroupList->hasContent()
+ ? $usergroupList
+ : new EmptyState(t('No user groups configured.'))
+ )
+ ];
+ }
+
+ protected function createPerformanceData(): array
+ {
+ $content[] = Html::tag('h2', t('Performance Data'));
+
+ if (empty($this->object->state->performance_data)) {
+ $content[] = new EmptyState(t('No performance data available.'));
+ } else {
+ $content[] = new HtmlElement(
+ 'div',
+ Attributes::create(['id' => 'check-perfdata-' . $this->object->checkcommand_name]),
+ new PerfDataTable($this->object->state->normalized_performance_data)
+ );
+ }
+
+ return $content;
+ }
+
+ protected function createPluginOutput(): array
+ {
+ if (empty($this->object->state->output) && empty($this->object->state->long_output)) {
+ $pluginOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->object));
+ CopyToClipboard::attachTo($pluginOutput);
+ }
+
+ return [
+ Html::tag('h2', t('Plugin Output')),
+ Html::tag(
+ 'div',
+ [
+ 'id' => 'check-output-' . $this->object->checkcommand_name,
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100
+ ],
+ $pluginOutput
+ )
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ $extensions = ObjectDetailExtensionHook::loadExtensions($this->object);
+
+ $nativeExtensionProviders = [];
+ foreach ($extensions as $extension) {
+ if ($extension instanceof BaseHtmlElement && $extension->getAttributes()->has('data-icinga-module')) {
+ $nativeExtensionProviders[$extension->getAttributes()->get('data-icinga-module')->getValue()] = true;
+ }
+ }
+
+ if (! Icinga::app()->getModuleManager()->hasInstalled('monitoring')) {
+ return $extensions;
+ }
+
+ foreach (Hook::all('Grapher') as $grapher) {
+ /** @var GrapherHook $grapher */
+ $moduleName = ClassLoader::extractModuleName(get_class($grapher));
+
+ if (isset($nativeExtensionProviders[$moduleName])) {
+ continue;
+ }
+
+ try {
+ $graph = HtmlString::create($grapher->getPreviewHtml($this->compatObject()));
+ } catch (Exception $e) {
+ Logger::error("Failed to load legacy grapher: %s\n%s", $e, $e->getTraceAsString());
+ $graph = Text::create(IcingaException::describe($e));
+ }
+
+ $location = ObjectDetailExtensionHook::BASE_LOCATIONS[ObjectDetailExtensionHook::GRAPH_SECTION];
+ while (isset($extensions[$location])) {
+ $location++;
+ }
+
+ $extensions[$location] = $graph;
+ }
+
+ foreach (Hook::all('Monitoring\DetailviewExtension') as $extension) {
+ /** @var DetailviewExtensionHook $extension */
+ $moduleName = $extension->getModule()->getName();
+
+ if (isset($nativeExtensionProviders[$moduleName])) {
+ continue;
+ }
+
+ try {
+ /** @var Web $app */
+ $app = Icinga::app();
+ $renderedExtension = $extension
+ ->setView($app->getViewRenderer()->view)
+ ->getHtmlForObject($this->compatObject());
+
+ $extensionHtml = new HtmlElement(
+ 'div',
+ Attributes::create([
+ 'class' => 'icinga-module module-' . $moduleName,
+ 'data-icinga-module' => $moduleName
+ ]),
+ HtmlString::create($renderedExtension)
+ );
+ } catch (Exception $e) {
+ Logger::error("Failed to load legacy detail extension: %s\n%s", $e, $e->getTraceAsString());
+ $extensionHtml = Text::create(IcingaException::describe($e));
+ }
+
+ $location = ObjectDetailExtensionHook::BASE_LOCATIONS[ObjectDetailExtensionHook::DETAIL_SECTION];
+ while (isset($extensions[$location])) {
+ $location++;
+ }
+
+ $extensions[$location] = $extensionHtml;
+ }
+
+ return $extensions;
+ }
+
+ protected function createFeatureToggles(): array
+ {
+ $form = new ToggleObjectFeaturesForm($this->object);
+
+ if ($this->objectType === 'host') {
+ $form->setAction(HostLinks::toggleFeatures($this->object)->getAbsoluteUrl());
+ } else {
+ $form->setAction(ServiceLinks::toggleFeatures($this->object, $this->object->host)->getAbsoluteUrl());
+ }
+
+ return [
+ Html::tag('h2', t('Feature Commands')),
+ $form
+ ];
+ }
+
+ protected function getUsersAndUsergroups(): array
+ {
+ $users = [];
+ $usergroups = [];
+ $groupBy = false;
+
+ if ($this->objectType === 'host') {
+ $objectFilter = Filter::all(
+ Filter::equal('notification.host_id', $this->object->id),
+ Filter::unlike('notification.service_id', '*')
+ );
+ $objectFilter->metaData()->set('forceOptimization', false);
+ $groupBy = true;
+ } else {
+ $objectFilter = Filter::equal(
+ 'notification.service_id',
+ $this->object->id
+ );
+ }
+
+ $userQuery = null;
+ if ($this->isPermittedRoute('users')) {
+ $userQuery = User::on($this->getDb());
+ $userQuery->filter($objectFilter);
+ $this->applyRestrictions($userQuery);
+ if ($groupBy) {
+ $userQuery->getSelectBase()->groupBy(['user.id']);
+ }
+
+ foreach ($userQuery as $user) {
+ $users[$user->name] = $user;
+ }
+ }
+
+ if ($this->isPermittedRoute('usergroups')) {
+ $usergroupQuery = Usergroup::on($this->getDb());
+ $usergroupQuery->filter($objectFilter);
+ $this->applyRestrictions($usergroupQuery);
+ if ($groupBy && $userQuery !== null) {
+ $userQuery->getSelectBase()->groupBy(['usergroup.id']);
+ }
+
+ foreach ($usergroupQuery as $usergroup) {
+ $usergroups[$usergroup->name] = $usergroup;
+ }
+ }
+
+ return [$users, $usergroups];
+ }
+
+ protected function fetchCustomVars()
+ {
+ $customvarFlat = $this->object->customvar_flat;
+ if (! $customvarFlat instanceof ResultSet) {
+ $this->applyRestrictions($customvarFlat);
+ $customvarFlat->withColumns(['customvar.name', 'customvar.value']);
+ $this->object->customvar_flat = $customvarFlat->execute();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ObjectStatistics.php b/library/Icingadb/Widget/Detail/ObjectStatistics.php
new file mode 100644
index 0000000..477bf5f
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectStatistics.php
@@ -0,0 +1,59 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use ipl\Stdlib\BaseFilter;
+
+abstract class ObjectStatistics extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'object-statistics'];
+
+ abstract protected function createDonut(): ValidHtml;
+
+ abstract protected function createTotal(): ValidHtml;
+
+ abstract protected function createBadges(): ValidHtml;
+
+ /**
+ * Shorten the given amount to 4 characters max
+ *
+ * @param int $amount
+ *
+ * @return string
+ */
+ protected function shortenAmount(int $amount): string
+ {
+ if ($amount < 10000) {
+ return (string) $amount;
+ }
+
+ if ($amount < 999500) {
+ return sprintf('%dk', round($amount / 1000.0));
+ }
+
+ if ($amount < 9959000) {
+ return sprintf('%.1fM', $amount / 1000000.0);
+ }
+
+ // I think we can rule out amounts over 1 Billion
+ return sprintf('%dM', $amount / 1000000.0);
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ Html::tag('li', ['class' => 'object-statistics-graph'], $this->createDonut()),
+ Html::tag('li', ['class' => ['object-statistics-total', 'text-center']], $this->createTotal()),
+ Html::tag('li', $this->createBadges())
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ObjectsDetail.php b/library/Icingadb/Widget/Detail/ObjectsDetail.php
new file mode 100644
index 0000000..a65dfe6
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectsDetail.php
@@ -0,0 +1,190 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Util\FeatureStatus;
+use Icinga\Module\Icingadb\Widget\HostStateBadges;
+use Icinga\Module\Icingadb\Widget\ServiceStateBadges;
+use ipl\Orm\Query;
+use ipl\Stdlib\BaseFilter;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\ActionLink;
+
+class ObjectsDetail extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ protected $summary;
+
+ protected $query;
+
+ protected $type;
+
+ protected $defaultAttributes = ['class' => 'objects-detail'];
+
+ protected $tag = 'div';
+
+ /**
+ * Construct an object detail summary widget
+ *
+ * @param string $type
+ * @param HoststateSummary|ServicestateSummary $summary
+ * @param Query $query
+ */
+ public function __construct(string $type, $summary, Query $query)
+ {
+ $this->summary = $summary;
+ $this->query = $query;
+ $this->type = $type;
+ }
+
+ protected function createChart(): BaseHtmlElement
+ {
+ $content = Html::tag('div', ['class' => 'multiselect-summary']);
+
+ if ($this->type === 'host') {
+ $hostsChart = (new Donut())
+ ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending']);
+
+ $badges = (new HostStateBadges($this->summary))
+ ->setBaseFilter($this->getBaseFilter());
+
+ $content->add([
+ HtmlString::create($hostsChart->render()),
+ new VerticalKeyValue(
+ tp('Host', 'Hosts', $this->summary->hosts_total),
+ $this->summary->hosts_total
+ ),
+ $badges
+ ]);
+ } else {
+ $servicesChart = (new Donut())
+ ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled'])
+ ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning'])
+ ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled'])
+ ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown'])
+ ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending']);
+
+ $badges = (new ServiceStateBadges($this->summary))
+ ->setBaseFilter($this->getBaseFilter());
+
+ $content->add([
+ HtmlString::create($servicesChart->render()),
+ new VerticalKeyValue(
+ tp('Service', 'Services', $this->summary->services_total),
+ $this->summary->services_total
+ ),
+ $badges
+ ]);
+ }
+
+ return $content;
+ }
+
+ protected function createComments(): array
+ {
+ $content = [Html::tag('h2', t('Comments'))];
+
+ if ($this->summary->comments_total > 0) {
+ $content[] = new ActionLink(
+ sprintf(
+ tp('Show %d comment', 'Show %d comments', $this->summary->comments_total),
+ $this->summary->comments_total
+ ),
+ Links::comments()->setFilter($this->getBaseFilter())
+ );
+ } else {
+ $content[] = new EmptyState(t('No comments created.'));
+ }
+
+ return $content;
+ }
+
+ protected function createDowntimes(): array
+ {
+ $content = [Html::tag('h2', t('Downtimes'))];
+
+ if ($this->summary->downtimes_total > 0) {
+ $content[] = new ActionLink(
+ sprintf(
+ tp('Show %d downtime', 'Show %d downtimes', $this->summary->downtimes_total),
+ $this->summary->downtimes_total
+ ),
+ Links::downtimes()->setFilter($this->getBaseFilter())
+ );
+ } else {
+ $content[] = new EmptyState(t('No downtimes scheduled.'));
+ }
+
+ return $content;
+ }
+
+ protected function createFeatureToggles(): array
+ {
+ $form = new ToggleObjectFeaturesForm(new FeatureStatus($this->type, $this->summary));
+
+ if ($this->type === 'host') {
+ $form->setAction(
+ Links::toggleHostsFeatures()
+ ->setFilter($this->getBaseFilter())
+ ->getAbsoluteUrl()
+ );
+ } else {
+ $form->setAction(
+ Links::toggleServicesFeatures()
+ ->setFilter($this->getBaseFilter())
+ ->getAbsoluteUrl()
+ );
+ }
+
+ return [
+ Html::tag('h2', t('Feature Commands')),
+ $form
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectsDetailExtensionHook::loadExtensions(
+ $this->type,
+ $this->query,
+ $this->getBaseFilter()
+ );
+ }
+
+ protected function createSummary(): array
+ {
+ return [
+ Html::tag('h2', t('Summary')),
+ $this->createChart()
+ ];
+ }
+
+ protected function assemble()
+ {
+ $this->add(ObjectsDetailExtensionHook::injectExtensions([
+ 190 => $this->createSummary(),
+ 400 => $this->createComments(),
+ 401 => $this->createDowntimes(),
+ 701 => $this->createFeatureToggles()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/PerfDataTable.php b/library/Icingadb/Widget/Detail/PerfDataTable.php
new file mode 100644
index 0000000..aaee7c9
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/PerfDataTable.php
@@ -0,0 +1,130 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Util\PerfData;
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Table;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+
+class PerfDataTable extends Table
+{
+ use Translation;
+
+ protected $defaultAttributes = [
+ 'class' => 'performance-data-table collapsible',
+ 'data-visible-rows' => 6
+ ];
+
+ /** @var string The perfdata string */
+ protected $perfdataStr;
+
+ /** @var int Max labels to show; 0 for no limit */
+ protected $limit;
+
+ /** @var string The color indicating the perfdata state */
+ protected $color;
+
+ /**
+ * Display the given perfdata string to the user
+ *
+ * @param string $perfdataStr The perfdata string
+ * @param int $limit Max labels to show; 0 for no limit
+ * @param string $color The color indicating the perfdata state
+ */
+ public function __construct(string $perfdataStr, int $limit = 0, string $color = PerfData::PERFDATA_OK)
+ {
+ $this->perfdataStr = $perfdataStr;
+ $this->limit = $limit;
+ $this->color = $color;
+ }
+
+ public function assemble()
+ {
+ $pieChartData = PerfDataSet::fromString($this->perfdataStr)->asArray();
+ $keys = [
+ '' => '',
+ 'label' => t('Label'),
+ 'value' => t('Value'),
+ 'min' => t('Min'),
+ 'max' => t('Max'),
+ 'warn' => t('Warning'),
+ 'crit' => t('Critical')
+ ];
+
+ $containsSparkline = false;
+ foreach ($pieChartData as $perfdata) {
+ if ($perfdata->isVisualizable() || ! $perfdata->isValid()) {
+ $containsSparkline = true;
+ break;
+ }
+ }
+
+ $headerRow = new HtmlElement('tr');
+ foreach ($keys as $key => $col) {
+ if (! $containsSparkline && $key === '') {
+ continue;
+ }
+
+ $headerRow->addHtml(new HtmlElement('th', Attributes::create([
+ 'class' => $key === 'label' ? 'title' : null
+ ]), Text::create($col)));
+ }
+
+ $this->getHeader()->addHtml($headerRow);
+
+ $count = 0;
+ foreach ($pieChartData as $perfdata) {
+ if ($this->limit > 0 && $count === $this->limit) {
+ break;
+ }
+
+ $count++;
+ $cols = [];
+ if ($containsSparkline) {
+ if ($perfdata->isVisualizable()) {
+ $cols[] = Table::td(
+ HtmlString::create($perfdata->asInlinePie($this->color)->render()),
+ ['class' => 'sparkline-col']
+ );
+ } elseif (! $perfdata->isValid()) {
+ $cols[] = Table::td(
+ new Icon(
+ 'triangle-exclamation',
+ [
+ 'title' => $this->translate(
+ 'Evaluation failed. Performance data is invalid.'
+ ),
+ 'class' => ['invalid-perfdata']
+ ]
+ ),
+ ['class' => 'sparkline-col']
+ );
+ } else {
+ $cols[] = Table::td('');
+ }
+ }
+
+ foreach ($perfdata->toArray() as $column => $value) {
+ $cols[] = Table::td(
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => $value ? null : 'no-value']),
+ $value ? Text::create($value) : new EmptyState(t('None', 'value'))
+ ),
+ ['class' => $column === 'label' ? 'title' : null]
+ );
+ }
+
+ $this->addHtml(Table::tr($cols));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/QuickActions.php b/library/Icingadb/Widget/Detail/QuickActions.php
new file mode 100644
index 0000000..2ea26c2
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/QuickActions.php
@@ -0,0 +1,148 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\HostLinks;
+use Icinga\Module\Icingadb\Common\ServiceLinks;
+use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\Icon;
+
+class QuickActions extends BaseHtmlElement
+{
+ use Auth;
+
+ /** @var Host|Service */
+ protected $object;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'quick-actions'];
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ }
+
+ protected function assemble()
+ {
+ if ($this->object->state->is_problem) {
+ if ($this->object->state->is_acknowledged) {
+ if ($this->isGrantedOn('icingadb/command/remove-acknowledgement', $this->object)) {
+ $removeAckForm = (new RemoveAcknowledgementForm())
+ ->setAction($this->getLink('removeAcknowledgement'))
+ ->setObjects([$this->object]);
+
+ $this->add(Html::tag('li', $removeAckForm));
+ }
+ } elseif ($this->isGrantedOn('icingadb/command/acknowledge-problem', $this->object)) {
+ $this->assembleAction(
+ 'acknowledge',
+ t('Acknowledge'),
+ 'check-circle',
+ t('Acknowledge this problem, suppress all future notifications for it and tag it as being handled')
+ );
+ }
+ }
+
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $this->object)
+ || (
+ $this->object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $this->object)
+ )
+ ) {
+ $this->add(Html::tag('li', (new CheckNowForm())->setAction($this->getLink('checkNow'))));
+ }
+
+ if ($this->isGrantedOn('icingadb/command/comment/add', $this->object)) {
+ $this->assembleAction(
+ 'addComment',
+ t('Comment', 'verb'),
+ 'comment',
+ t('Add a new comment')
+ );
+ }
+
+ if ($this->isGrantedOn('icingadb/command/send-custom-notification', $this->object)) {
+ $this->assembleAction(
+ 'sendCustomNotification',
+ t('Notification'),
+ 'bell',
+ t('Send a custom notification')
+ );
+ }
+
+ if ($this->isGrantedOn('icingadb/command/downtime/schedule', $this->object)) {
+ $this->assembleAction(
+ 'scheduleDowntime',
+ t('Downtime'),
+ 'plug',
+ t('Schedule a downtime to suppress all problem notifications within a specific period of time')
+ );
+ }
+
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $this->object)
+ || (
+ $this->object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $this->object)
+ )
+ ) {
+ $this->assembleAction(
+ 'scheduleCheck',
+ t('Reschedule'),
+ 'calendar',
+ t('Schedule the next active check at a different time than the current one')
+ );
+ }
+
+ if ($this->isGrantedOn('icingadb/command/process-check-result', $this->object)) {
+ $this->assembleAction(
+ 'processCheckresult',
+ t('Process check result'),
+ 'edit',
+ sprintf(
+ t('Submit a one time or so called passive result for the %s check'),
+ $this->object->checkcommand_name
+ )
+ );
+ }
+ }
+
+ protected function assembleAction(string $action, string $label, string $icon, string $title)
+ {
+ $link = Html::tag(
+ 'a',
+ [
+ 'href' => $this->getLink($action),
+ 'class' => 'action-link',
+ 'title' => $title,
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ],
+ [
+ new Icon($icon),
+ $label
+ ]
+ );
+
+ $this->add(Html::tag('li', $link));
+ }
+
+ protected function getLink($action)
+ {
+ if ($this->object instanceof Host) {
+ return HostLinks::$action($this->object)->getAbsoluteUrl();
+ } else {
+ return ServiceLinks::$action($this->object, $this->object->host)->getAbsoluteUrl();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceDetail.php b/library/Icingadb/Widget/Detail/ServiceDetail.php
new file mode 100644
index 0000000..8421e31
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceDetail.php
@@ -0,0 +1,37 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Service;
+
+class ServiceDetail extends ObjectDetail
+{
+ public function __construct(Service $object)
+ {
+ parent::__construct($object);
+ }
+
+ protected function assemble()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->add($this->createPrintHeader());
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $this->createPluginOutput(),
+ 300 => $this->createActions(),
+ 301 => $this->createNotes(),
+ 400 => $this->createComments(),
+ 401 => $this->createDowntimes(),
+ 500 => $this->createGroups(),
+ 501 => $this->createNotifications(),
+ 600 => $this->createCheckStatistics(),
+ 601 => $this->createPerformanceData(),
+ 700 => $this->createCustomVars(),
+ 701 => $this->createFeatureToggles()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php b/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php
new file mode 100644
index 0000000..f29ee9b
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\ObjectInspectionDetail;
+
+class ServiceInspectionDetail extends ObjectInspectionDetail
+{
+ protected function assemble()
+ {
+ $this->add([
+ $this->createSourceLocation(),
+ $this->createLastCheckResult(),
+ $this->createAttributes(),
+ $this->createCustomVariables(),
+ $this->createRedisInfo()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceMetaInfo.php b/library/Icingadb/Widget/Detail/ServiceMetaInfo.php
new file mode 100644
index 0000000..cca7237
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceMetaInfo.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Web\Widget\Icon;
+
+class ServiceMetaInfo extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'object-meta-info'];
+
+ /** @var Service */
+ protected $service;
+
+ public function __construct(Service $service)
+ {
+ $this->service = $service;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml(
+ new VerticalKeyValue('service.name', $this->service->name),
+ new VerticalKeyValue(
+ 'last_state_change',
+ DateFormatter::formatDateTime($this->service->state->last_state_change->getTimestamp())
+ )
+ );
+
+ $collapsible = new HtmlElement('div', Attributes::create([
+ 'class' => 'collapsible',
+ 'id' => 'object-meta-info',
+ 'data-toggle-element' => '.object-meta-info-control',
+ 'data-visible-height' => 0
+ ]));
+
+ $renderHelper = new HtmlDocument();
+ $renderHelper->addHtml(
+ $this,
+ new HtmlElement(
+ 'button',
+ Attributes::create(['class' => 'object-meta-info-control']),
+ new Icon('angle-double-up', ['class' => 'collapse-icon']),
+ new Icon('angle-double-down', ['class' => 'expand-icon'])
+ )
+ );
+
+ $this->addWrapper($collapsible);
+ $this->addWrapper($renderHelper);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceStatistics.php b/library/Icingadb/Widget/Detail/ServiceStatistics.php
new file mode 100644
index 0000000..51aced1
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceStatistics.php
@@ -0,0 +1,64 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\ServiceStateBadges;
+use ipl\Html\ValidHtml;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\Link;
+
+class ServiceStatistics extends ObjectStatistics
+{
+ protected $summary;
+
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function createDonut(): ValidHtml
+ {
+ $donut = (new Donut())
+ ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled'])
+ ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning'])
+ ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled'])
+ ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown'])
+ ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending']);
+
+ return HtmlString::create($donut->render());
+ }
+
+ protected function createTotal(): ValidHtml
+ {
+ $url = Links::services();
+ if ($this->hasBaseFilter()) {
+ $url->setFilter($this->getBaseFilter());
+ }
+
+ return new Link(
+ (new VerticalKeyValue(
+ tp('Service', 'Services', $this->summary->services_total),
+ $this->shortenAmount($this->summary->services_total)
+ ))->setAttribute('title', $this->summary->services_total),
+ $url
+ );
+ }
+
+ protected function createBadges(): ValidHtml
+ {
+ $badges = new ServiceStateBadges($this->summary);
+ if ($this->hasBaseFilter()) {
+ $badges->setBaseFilter($this->getBaseFilter());
+ }
+
+ return $badges;
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/UserDetail.php b/library/Icingadb/Widget/Detail/UserDetail.php
new file mode 100644
index 0000000..0bc1acb
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/UserDetail.php
@@ -0,0 +1,188 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Html\Attributes;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemTable\UsergroupTable;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+
+class UserDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+
+ /** @var User The given user */
+ protected $user;
+
+ protected $defaultAttributes = ['class' => 'object-detail'];
+
+ protected $tag = 'div';
+
+ public function __construct(User $user)
+ {
+ $this->user = $user;
+ }
+
+ protected function createCustomVars(): array
+ {
+ $content = [new HtmlElement('h2', null, Text::create(t('Custom Variables')))];
+ $flattenedVars = $this->user->customvar_flat;
+ $this->applyRestrictions($flattenedVars);
+
+ $vars = $this->user->customvar_flat->getModel()->unflattenVars($flattenedVars);
+ if (! empty($vars)) {
+ $content[] = new HtmlElement('div', Attributes::create([
+ 'id' => 'user-customvars',
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200
+ ]), new CustomVarTable($vars, $this->user));
+ } else {
+ $content[] = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return $content;
+ }
+
+ protected function createUserDetail(): array
+ {
+ list($hostStates, $serviceStates) = $this->separateStates($this->user->states);
+ $hostStates = implode(', ', $this->localizeStates($hostStates));
+ $serviceStates = implode(', ', $this->localizeStates($serviceStates));
+ $types = implode(', ', $this->localizeTypes($this->user->types));
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Details'))),
+ new HorizontalKeyValue(t('Name'), $this->user->name),
+ new HorizontalKeyValue(t('E-Mail'), $this->user->email ?: new EmptyState(t('None', 'address'))),
+ new HorizontalKeyValue(t('Pager'), $this->user->pager ?: new EmptyState(t('None', 'phone-number'))),
+ new HorizontalKeyValue(t('Host States'), $hostStates ?: t('All')),
+ new HorizontalKeyValue(t('Service States'), $serviceStates ?: t('All')),
+ new HorizontalKeyValue(t('Types'), $types ?: t('All'))
+ ];
+ }
+
+ protected function createUsergroupList(): array
+ {
+ $userGroups = $this->user->usergroup->limit(6)->peekAhead()->execute();
+
+ $showMoreLink = (new ShowMore(
+ $userGroups,
+ Links::usergroups()->addParams(['user.name' => $this->user->name])
+ ))->setBaseTarget('_next');
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Groups'))),
+ new UsergroupTable($userGroups),
+ $showMoreLink
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectDetailExtensionHook::loadExtensions($this->user);
+ }
+
+ protected function assemble()
+ {
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $this->createUserDetail(),
+ 500 => $this->createUsergroupList(),
+ 700 => $this->createCustomVars()
+ ], $this->createExtensions()));
+ }
+
+ private function localizeTypes(array $types): array
+ {
+ $localizedTypes = [];
+ foreach ($types as $type) {
+ switch ($type) {
+ case 'problem':
+ $localizedTypes[] = t('Problem');
+ break;
+ case 'ack':
+ $localizedTypes[] = t('Acknowledgement');
+ break;
+ case 'recovery':
+ $localizedTypes[] = t('Recovery');
+ break;
+ case 'downtime_start':
+ $localizedTypes[] = t('Downtime Start');
+ break;
+ case 'downtime_end':
+ $localizedTypes[] = t('Downtime End');
+ break;
+ case 'downtime_removed':
+ $localizedTypes[] = t('Downtime Removed');
+ break;
+ case 'flapping_start':
+ $localizedTypes[] = t('Flapping Start');
+ break;
+ case 'flapping_end':
+ $localizedTypes[] = t('Flapping End');
+ break;
+ case 'custom':
+ $localizedTypes[] = t('Custom');
+ break;
+ }
+ }
+
+ return $localizedTypes;
+ }
+
+ private function localizeStates(array $states): array
+ {
+ $localizedState = [];
+ foreach ($states as $state) {
+ switch ($state) {
+ case 'up':
+ $localizedState[] = t('Up');
+ break;
+ case 'down':
+ $localizedState[] = t('Down');
+ break;
+ case 'ok':
+ $localizedState[] = t('Ok');
+ break;
+ case 'warning':
+ $localizedState[] = t('Warning');
+ break;
+ case 'critical':
+ $localizedState[] = t('Critical');
+ break;
+ case 'unknown':
+ $localizedState[] = t('Unknown');
+ break;
+ }
+ }
+
+ return $localizedState;
+ }
+
+ private function separateStates(array $states): array
+ {
+ $hostStates = [];
+ $serviceStates = [];
+
+ foreach ($states as $state) {
+ if ($state === 'Up' || $state === 'Down') {
+ $hostStates[] = $state;
+ } else {
+ $serviceStates[] = $state;
+ }
+ }
+
+ return [$hostStates, $serviceStates];
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/UsergroupDetail.php b/library/Icingadb/Widget/Detail/UsergroupDetail.php
new file mode 100644
index 0000000..249c795
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/UsergroupDetail.php
@@ -0,0 +1,98 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Widget\ItemTable\UserTable;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+
+class UsergroupDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+
+ /** @var Usergroup The given user group */
+ protected $usergroup;
+
+ protected $defaultAttributes = ['class' => 'object-detail'];
+
+ protected $tag = 'div';
+
+ public function __construct(Usergroup $usergroup)
+ {
+ $this->usergroup = $usergroup;
+ }
+
+ protected function createPrintHeader()
+ {
+ return [
+ new HtmlElement('h2', null, Text::create(t('Details'))),
+ new HorizontalKeyValue(t('Name'), $this->usergroup->name)
+ ];
+ }
+
+ protected function createCustomVars(): array
+ {
+ $content = [new HtmlElement('h2', null, Text::create(t('Custom Variables')))];
+ $flattenedVars = $this->usergroup->customvar_flat;
+ $this->applyRestrictions($flattenedVars);
+
+ $vars = $this->usergroup->customvar_flat->getModel()->unflattenVars($flattenedVars);
+ if (! empty($vars)) {
+ $content[] = new HtmlElement('div', Attributes::create([
+ 'id' => 'usergroup-customvars',
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200
+ ]), new CustomVarTable($vars, $this->usergroup));
+ } else {
+ $content[] = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return $content;
+ }
+
+ protected function createUserList(): array
+ {
+ $users = $this->usergroup->user->limit(6)->peekAhead()->execute();
+
+ $showMoreLink = (new ShowMore(
+ $users,
+ Links::users()->addParams(['usergroup.name' => $this->usergroup->name])
+ ))->setBaseTarget('_next');
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Users'))),
+ new UserTable($users),
+ $showMoreLink
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectDetailExtensionHook::loadExtensions($this->usergroup);
+ }
+
+ protected function assemble()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->add($this->createPrintHeader());
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 500 => $this->createUserList(),
+ 700 => $this->createCustomVars()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Health.php b/library/Icingadb/Widget/Health.php
new file mode 100644
index 0000000..8c99dca
--- /dev/null
+++ b/library/Icingadb/Widget/Health.php
@@ -0,0 +1,66 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Web\Widget\TimeSince;
+use ipl\Web\Widget\VerticalKeyValue;
+
+class Health extends BaseHtmlElement
+{
+ protected $data;
+
+ protected $tag = 'section';
+
+ public function __construct($data)
+ {
+ $this->data = $data;
+ }
+
+ protected function assemble()
+ {
+ if ($this->data->heartbeat->getTimestamp() > time() - 60) {
+ $this->add(Html::tag('div', ['class' => 'icinga-health up'], [
+ Html::sprintf(
+ t('Icinga 2 is up and running %s', '...since <timespan>'),
+ new TimeSince($this->data->icinga2_start_time->getTimestamp())
+ )
+ ]));
+ } else {
+ $this->add(Html::tag('div', ['class' => 'icinga-health down'], [
+ Html::sprintf(
+ t('Icinga 2 or Icinga DB is not running %s', '...since <timespan>'),
+ new TimeSince($this->data->heartbeat->getTimestamp())
+ )
+ ]));
+ }
+
+ $icingaInfo = Html::tag('div', ['class' => 'icinga-info'], [
+ new VerticalKeyValue(
+ t('Icinga 2 Version'),
+ $this->data->icinga2_version
+ ),
+ new VerticalKeyValue(
+ t('Icinga 2 Start Time'),
+ new TimeAgo($this->data->icinga2_start_time->getTimestamp())
+ ),
+ new VerticalKeyValue(
+ t('Last Heartbeat'),
+ new TimeAgo($this->data->heartbeat->getTimestamp())
+ ),
+ new VerticalKeyValue(
+ t('Active Icinga 2 Endpoint'),
+ $this->data->endpoint->name ?: t('N/A')
+ ),
+ new VerticalKeyValue(
+ t('Active Icinga Web Endpoint'),
+ gethostname() ?: t('N/A')
+ )
+ ]);
+ $this->add($icingaInfo);
+ }
+}
diff --git a/library/Icingadb/Widget/HostStateBadges.php b/library/Icingadb/Widget/HostStateBadges.php
new file mode 100644
index 0000000..8141e82
--- /dev/null
+++ b/library/Icingadb/Widget/HostStateBadges.php
@@ -0,0 +1,45 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\StateBadges;
+use ipl\Web\Url;
+
+class HostStateBadges extends StateBadges
+{
+ protected function getBaseUrl(): Url
+ {
+ return Links::hosts();
+ }
+
+ protected function getType(): string
+ {
+ return 'host';
+ }
+
+ protected function getPrefix(): string
+ {
+ return 'hosts';
+ }
+
+ protected function getStateInt(string $state): int
+ {
+ return HostStates::int($state);
+ }
+
+ protected function assemble()
+ {
+ $this->addAttributes(['class' => 'host-state-badges']);
+
+ $this->add(array_filter([
+ $this->createGroup('down'),
+ $this->createBadge('unknown'),
+ $this->createBadge('up'),
+ $this->createBadge('pending')
+ ]));
+ }
+}
diff --git a/library/Icingadb/Widget/HostStatusBar.php b/library/Icingadb/Widget/HostStatusBar.php
new file mode 100644
index 0000000..f900f76
--- /dev/null
+++ b/library/Icingadb/Widget/HostStatusBar.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Common\BaseStatusBar;
+use ipl\Html\BaseHtmlElement;
+
+class HostStatusBar extends BaseStatusBar
+{
+ protected function assembleTotal(BaseHtmlElement $total): void
+ {
+ $total->add(sprintf(tp('%d Host', '%d Hosts', $this->summary->hosts_total), $this->summary->hosts_total));
+ }
+
+ protected function createStateBadges(): BaseHtmlElement
+ {
+ return (new HostStateBadges($this->summary))->setBaseFilter($this->getBaseFilter());
+ }
+}
diff --git a/library/Icingadb/Widget/HostSummaryDonut.php b/library/Icingadb/Widget/HostSummaryDonut.php
new file mode 100644
index 0000000..db5fef8
--- /dev/null
+++ b/library/Icingadb/Widget/HostSummaryDonut.php
@@ -0,0 +1,78 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\BaseFilter;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\Card;
+use ipl\Web\Filter\QueryString;
+
+class HostSummaryDonut extends Card
+{
+ use BaseFilter;
+
+ protected $defaultAttributes = ['class' => 'donut-container', 'data-base-target' => '_next'];
+
+ /** @var HoststateSummary */
+ protected $summary;
+
+ public function __construct(HoststateSummary $summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function assembleBody(BaseHtmlElement $body)
+ {
+ $labelBigUrlFilter = Filter::all(
+ Filter::equal('host.state.soft_state', 1),
+ Filter::equal('host.state.is_handled', 'n')
+ );
+ if ($this->hasBaseFilter()) {
+ $labelBigUrlFilter->add($this->getBaseFilter());
+ }
+
+ $donut = (new Donut())
+ ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending'])
+ ->setLabelBig($this->summary->hosts_down_unhandled)
+ ->setLabelBigUrl(Links::hosts()->setFilter($labelBigUrlFilter)->addParams([
+ 'sort' => 'host.state.last_state_change'
+ ]))
+ ->setLabelBigEyeCatching($this->summary->hosts_down_unhandled > 0)
+ ->setLabelSmall(t('Down'));
+
+ $body->addHtml(
+ new HtmlElement('div', Attributes::create(['class' => 'donut']), new HtmlString($donut->render()))
+ );
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer)
+ {
+ $footer->addHtml((new HostStateBadges($this->summary))->setBaseFilter($this->getBaseFilter()));
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $header->addHtml(
+ new HtmlElement('h2', null, Text::create(t('Hosts'))),
+ new HtmlElement('span', Attributes::create(['class' => 'meta']), TemplateString::create(
+ t('{{#total}}Total{{/total}} %d'),
+ ['total' => new HtmlElement('span')],
+ (int) $this->summary->hosts_total
+ ))
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/IconImage.php b/library/Icingadb/Widget/IconImage.php
new file mode 100644
index 0000000..fcf25c8
--- /dev/null
+++ b/library/Icingadb/Widget/IconImage.php
@@ -0,0 +1,74 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+
+class IconImage extends BaseHtmlElement
+{
+ /** @var string */
+ protected $source;
+
+ /** @var ?string */
+ protected $alt;
+
+ protected $tag = 'img';
+
+ /**
+ * Create a new icon image
+ *
+ * @param string $source
+ * @param ?string $alt The alternative text
+ */
+ public function __construct(string $source, ?string $alt)
+ {
+ $this->source = $source;
+ $this->alt = $alt;
+ }
+
+ public function renderUnwrapped()
+ {
+ if (! $this->getAttributes()->has('src')) {
+ // If it's an icon we don't need the <img> tag
+ return '';
+ }
+
+ return parent::renderUnwrapped();
+ }
+
+ protected function assemble()
+ {
+ $src = $this->source;
+
+ if (strpos($src, '.') === false) {
+ $this->setWrapper((new HtmlDocument())->addHtml(new Icon($src)));
+ return;
+ }
+
+ if (strpos($src, '/') === false) {
+ $src = 'img/icons/' . $src;
+ }
+
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $srcUrl = Url::fromPath($src);
+ $srcPath = $srcUrl->getRelativeUrl();
+ if (! $srcUrl->isExternal() && file_exists($srcPath) && is_file($srcPath)) {
+ $mimeType = @mime_content_type($srcPath);
+ $content = @file_get_contents($srcPath);
+ if ($mimeType !== false && $content !== false) {
+ $src = "data:$mimeType;base64," . base64_encode($content);
+ }
+ }
+ }
+
+ $this->addAttributes([
+ 'src' => $src,
+ 'alt' => $this->alt
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseCommentListItem.php b/library/Icingadb/Widget/ItemList/BaseCommentListItem.php
new file mode 100644
index 0000000..de11c0c
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseCommentListItem.php
@@ -0,0 +1,131 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use ipl\Html\Html;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\MarkdownLine;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Model\Comment;
+use ipl\Html\FormattedString;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\TimeUntil;
+
+/**
+ * Comment item of a comment list. Represents one database row.
+ *
+ * @property Comment $item
+ * @property CommentList $list
+ */
+abstract class BaseCommentListItem extends BaseListItem
+{
+ use HostLink;
+ use ServiceLink;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use TicketLinks;
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->text));
+
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->addFrom($markdownLine);
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $isAck = $this->item->entry_type === 'ack';
+ $expires = $this->item->expire_time;
+
+ $subjectText = sprintf(
+ $isAck ? t('%s acknowledged', '<username>..') : t('%s commented', '<username>..'),
+ $this->item->author
+ );
+
+ $headerParts = [
+ new Icon(Icons::USER),
+ $this->getNoSubjectLink()
+ ? new HtmlElement('span', Attributes::create(['class' => 'subject']), Text::create($subjectText))
+ : new Link($subjectText, Links::comment($this->item), ['class' => 'subject'])
+ ];
+
+ if ($isAck) {
+ $label = [Text::create('ack')];
+
+ if ($this->item->is_persistent) {
+ array_unshift($label, new Icon(Icons::IS_PERSISTENT));
+ }
+
+ $headerParts[] = Text::create(' ');
+ $headerParts[] = new HtmlElement('span', Attributes::create(['class' => 'ack-badge badge']), ...$label);
+ }
+
+ if ($expires !== null) {
+ $headerParts[] = Text::create(' ');
+ $headerParts[] = new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'ack-badge badge']),
+ Text::create(t('EXPIRES'))
+ );
+ }
+
+ if ($this->getObjectLinkDisabled()) {
+ // pass
+ } elseif ($this->item->object_type === 'host') {
+ $headerParts[] = $this->createHostLink($this->item->host, true);
+ } else {
+ $headerParts[] = $this->createServiceLink($this->item->service, $this->item->service->host, true);
+ }
+
+ $title->addHtml(...$headerParts);
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $visual->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'user-ball']),
+ Text::create($this->item->author[0])
+ ));
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ if ($this->item->expire_time) {
+ return Html::tag(
+ 'span',
+ FormattedString::create(t("expires %s"), new TimeUntil($this->item->expire_time->getTimestamp()))
+ );
+ }
+
+ return Html::tag(
+ 'span',
+ FormattedString::create(t("created %s"), new TimeAgo($this->item->entry_time->getTimestamp()))
+ );
+ }
+
+ protected function init(): void
+ {
+ $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ $this->list->addMultiselectFilterAttribute($this, Filter::equal('name', $this->item->name));
+ $this->setObjectLinkDisabled($this->list->getObjectLinkDisabled());
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php b/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
new file mode 100644
index 0000000..7ebc1f6
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
@@ -0,0 +1,212 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Widget\MarkdownLine;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+/**
+ * Downtime item of a downtime list. Represents one database row.
+ *
+ * @property Downtime $item
+ * @property DowntimeList $list
+ */
+abstract class BaseDowntimeListItem extends BaseListItem
+{
+ use HostLink;
+ use ServiceLink;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use TicketLinks;
+
+ /** @var int Current Time */
+ protected $currentTime;
+
+ /** @var int Duration */
+ protected $duration;
+
+ /** @var int Downtime end time */
+ protected $endTime;
+
+ /** @var bool Whether the downtime is active */
+ protected $isActive;
+
+ /** @var int Downtime start time */
+ protected $startTime;
+
+ protected function init(): void
+ {
+ if ($this->item->is_flexible && $this->item->is_in_effect) {
+ $this->startTime = $this->item->start_time->getTimestamp();
+ $this->endTime = $this->item->end_time->getTimestamp();
+ } else {
+ $this->startTime = $this->item->scheduled_start_time->getTimestamp();
+ $this->endTime = $this->item->scheduled_end_time->getTimestamp();
+ }
+
+ $this->currentTime = time();
+
+ $this->isActive = $this->item->is_in_effect
+ || $this->item->is_flexible && $this->item->scheduled_start_time->getTimestamp() <= $this->currentTime;
+
+ $until = ($this->isActive ? $this->endTime : $this->startTime) - $this->currentTime;
+ $this->duration = explode(' ', DateFormatter::formatDuration(
+ $until <= 3600 ? $until : $until + (3600 - ((int) $until % 3600))
+ ), 2)[0];
+
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ $this->list->addMultiselectFilterAttribute($this, Filter::equal('name', $this->item->name));
+ $this->setObjectLinkDisabled($this->list->getObjectLinkDisabled());
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled());
+
+ if ($this->item->is_in_effect) {
+ $this->getAttributes()->add('class', 'in-effect');
+ }
+ }
+
+ protected function createProgress(): BaseHtmlElement
+ {
+ return new HtmlElement(
+ 'div',
+ Attributes::create([
+ 'class' => 'progress',
+ 'data-animate-progress' => true,
+ 'data-start-time' => $this->startTime,
+ 'data-end-time' => $this->endTime
+ ]),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'bar'])
+ )
+ );
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->comment));
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->addHtml(
+ new HtmlElement(
+ 'span',
+ null,
+ new Icon(Icons::USER),
+ Text::create($this->item->author)
+ ),
+ Text::create(': ')
+ )->addFrom($markdownLine);
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ if ($this->getObjectLinkDisabled()) {
+ $link = null;
+ } elseif ($this->item->object_type === 'host') {
+ $link = $this->createHostLink($this->item->host, true);
+ } else {
+ $link = $this->createServiceLink($this->item->service, $this->item->service->host, true);
+ }
+
+ if ($this->item->is_flexible) {
+ if ($link !== null) {
+ $template = t('{{#link}}Flexible Downtime{{/link}} for %s');
+ } else {
+ $template = t('Flexible Downtime');
+ }
+ } else {
+ if ($link !== null) {
+ $template = t('{{#link}}Fixed Downtime{{/link}} for %s');
+ } else {
+ $template = t('Fixed Downtime');
+ }
+ }
+
+ if ($this->getNoSubjectLink()) {
+ if ($link === null) {
+ $title->addHtml(HtmlElement::create('span', [ 'class' => 'subject'], $template));
+ } else {
+ $title->addHtml(TemplateString::create(
+ $template,
+ ['link' => HtmlElement::create('span', [ 'class' => 'subject'])],
+ $link
+ ));
+ }
+ } else {
+ if ($link === null) {
+ $title->addHtml(new Link($template, Links::downtime($this->item)));
+ } else {
+ $title->addHtml(TemplateString::create(
+ $template,
+ ['link' => new Link('', Links::downtime($this->item))],
+ $link
+ ));
+ }
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $dateTime = DateFormatter::formatDateTime($this->endTime);
+
+ if ($this->isActive) {
+ $visual->addHtml(Html::sprintf(
+ t('%s left', '<timespan>..'),
+ Html::tag(
+ 'strong',
+ Html::tag(
+ 'time',
+ [
+ 'datetime' => $dateTime,
+ 'title' => $dateTime
+ ],
+ $this->duration
+ )
+ )
+ ));
+ } else {
+ $visual->addHtml(Html::sprintf(
+ t('in %s', '..<timespan>'),
+ Html::tag('strong', $this->duration)
+ ));
+ }
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ $dateTime = DateFormatter::formatDateTime($this->isActive ? $this->endTime : $this->startTime);
+
+ return Html::tag(
+ 'time',
+ [
+ 'datetime' => $dateTime,
+ 'title' => $dateTime
+ ],
+ sprintf(
+ $this->isActive
+ ? t('expires in %s', '..<timespan>')
+ : t('starts in %s', '..<timespan>'),
+ $this->duration
+ )
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php b/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
new file mode 100644
index 0000000..6999324
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
@@ -0,0 +1,405 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Widget\MarkdownLine;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\CheckAttempt;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+abstract class BaseHistoryListItem extends BaseListItem
+{
+ use HostLink;
+ use NoSubjectLink;
+ use ServiceLink;
+ use TicketLinks;
+
+ /** @var History */
+ protected $item;
+
+ /** @var HistoryList */
+ protected $list;
+
+ protected function init(): void
+ {
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('id', bin2hex($this->item->id)));
+ }
+
+ abstract protected function getStateBallSize(): string;
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ switch ($this->item->event_type) {
+ case 'comment_add':
+ case 'comment_remove':
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->comment->comment));
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->comment->author,
+ ': '
+ ])->addFrom($markdownLine);
+
+ break;
+ case 'downtime_end':
+ case 'downtime_start':
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->downtime->comment));
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->downtime->author,
+ ': '
+ ])->addFrom($markdownLine);
+
+ break;
+ case 'flapping_start':
+ $caption
+ ->add(sprintf(
+ t('State Change Rate: %.2f%%; Start Threshold: %.2f%%'),
+ $this->item->flapping->percent_state_change_start,
+ $this->item->flapping->flapping_threshold_high
+ ))
+ ->getAttributes()
+ ->add('class', 'plugin-output');
+
+ break;
+ case 'flapping_end':
+ $caption
+ ->add(sprintf(
+ t('State Change Rate: %.2f%%; End Threshold: %.2f%%; Flapping for %s'),
+ $this->item->flapping->percent_state_change_end,
+ $this->item->flapping->flapping_threshold_low,
+ DateFormatter::formatDuration(
+ $this->item->flapping->end_time->getTimestamp()
+ - $this->item->flapping->start_time->getTimestamp()
+ )
+ ))
+ ->getAttributes()
+ ->add('class', 'plugin-output');
+
+ break;
+ case 'ack_clear':
+ case 'ack_set':
+ if (! isset($this->item->acknowledgement->comment) && ! isset($this->item->acknowledgement->author)) {
+ $caption->addHtml(new EmptyState(
+ t('This acknowledgement was set before Icinga DB history recording')
+ ));
+ } else {
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->acknowledgement->comment));
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->acknowledgement->author,
+ ': '
+ ])->addFrom($markdownLine);
+ }
+
+ break;
+ case 'notification':
+ if (! empty($this->item->notification->author)) {
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->notification->author,
+ ': ',
+ $this->item->notification->text
+ ]);
+ } else {
+ $commandName = $this->item->object_type === 'host'
+ ? $this->item->host->checkcommand_name
+ : $this->item->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($this->item->notification->text)) {
+ $caption->addHtml(new EmptyState(t('Output unavailable.')));
+ } else {
+ $caption->addHtml(new PluginOutputContainer(
+ (new PluginOutput($this->item->notification->text))
+ ->setCommandName($commandName)
+ ));
+ }
+ } else {
+ $caption->addHtml(new EmptyState(t('Waiting for Icinga DB to synchronize the config.')));
+ }
+ }
+
+ break;
+ case 'state_change':
+ $commandName = $this->item->object_type === 'host'
+ ? $this->item->host->checkcommand_name
+ : $this->item->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($this->item->state->output)) {
+ $caption->addHtml(new EmptyState(t('Output unavailable.')));
+ } else {
+ $caption->addHtml(new PluginOutputContainer(
+ (new PluginOutput($this->item->state->output))
+ ->setCommandName($commandName)
+ ));
+ }
+ } else {
+ $caption->addHtml(new EmptyState(t('Waiting for Icinga DB to synchronize the config.')));
+ }
+
+ break;
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ switch ($this->item->event_type) {
+ case 'comment_add':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::COMMENT)
+ ));
+
+ break;
+ case 'comment_remove':
+ case 'downtime_end':
+ case 'ack_clear':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::REMOVE)
+ ));
+
+ break;
+ case 'downtime_start':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IN_DOWNTIME)
+ ));
+
+ break;
+ case 'ack_set':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IS_ACKNOWLEDGED)
+ ));
+
+ break;
+ case 'flapping_end':
+ case 'flapping_start':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IS_FLAPPING)
+ ));
+
+ break;
+ case 'notification':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::NOTIFICATION)
+ ));
+
+ break;
+ case 'state_change':
+ if ($this->item->state->state_type === 'soft') {
+ $stateType = 'soft_state';
+ $previousStateType = 'previous_soft_state';
+
+ if ($this->item->state->previous_soft_state === 0) {
+ $previousStateType = 'hard_state';
+ }
+
+ $visual->addHtml(new CheckAttempt(
+ (int) $this->item->state->check_attempt,
+ (int) $this->item->state->max_check_attempts
+ ));
+ } else {
+ $stateType = 'hard_state';
+ $previousStateType = 'previous_hard_state';
+
+ if ($this->item->state->hard_state === $this->item->state->previous_hard_state) {
+ $previousStateType = 'previous_soft_state';
+ }
+ }
+
+ if ($this->item->object_type === 'host') {
+ $state = HostStates::text($this->item->state->$stateType);
+ $previousState = HostStates::text($this->item->state->$previousStateType);
+ } else {
+ $state = ServiceStates::text($this->item->state->$stateType);
+ $previousState = ServiceStates::text($this->item->state->$previousStateType);
+ }
+
+ $stateChange = new StateChange($state, $previousState);
+ if ($stateType === 'soft_state') {
+ $stateChange->setCurrentStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ }
+
+ if ($previousStateType === 'previous_soft_state') {
+ $stateChange->setPreviousStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ if ($stateType === 'soft_state') {
+ $visual->getAttributes()->add('class', 'small-state-change');
+ }
+ }
+
+ $visual->prependHtml($stateChange);
+
+ break;
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ switch ($this->item->event_type) {
+ case 'comment_add':
+ $subjectLabel = t('Comment added');
+
+ break;
+ case 'comment_remove':
+ if (! empty($this->item->comment->removed_by)) {
+ if ($this->item->comment->removed_by !== $this->item->comment->author) {
+ $subjectLabel = sprintf(
+ t('Comment removed by %s', '..<username>'),
+ $this->item->comment->removed_by
+ );
+ } else {
+ $subjectLabel = t('Comment removed by author');
+ }
+ } elseif (isset($this->item->comment->expire_time)) {
+ $subjectLabel = t('Comment expired');
+ } else {
+ $subjectLabel = t('Comment removed');
+ }
+
+ break;
+ case 'downtime_end':
+ if (! empty($this->item->downtime->cancelled_by)) {
+ if ($this->item->downtime->cancelled_by !== $this->item->downtime->author) {
+ $subjectLabel = sprintf(
+ t('Downtime cancelled by %s', '..<username>'),
+ $this->item->downtime->cancelled_by
+ );
+ } else {
+ $subjectLabel = t('Downtime cancelled by author');
+ }
+ } elseif ($this->item->downtime->has_been_cancelled === 'y') {
+ $subjectLabel = t('Downtime cancelled');
+ } else {
+ $subjectLabel = t('Downtime ended');
+ }
+
+ break;
+ case 'downtime_start':
+ $subjectLabel = t('Downtime started');
+
+ break;
+ case 'flapping_start':
+ $subjectLabel = t('Flapping started');
+
+ break;
+ case 'flapping_end':
+ $subjectLabel = t('Flapping stopped');
+
+ break;
+ case 'ack_set':
+ $subjectLabel = t('Acknowledgement set');
+
+ break;
+ case 'ack_clear':
+ if (! empty($this->item->acknowledgement->cleared_by)) {
+ if ($this->item->acknowledgement->cleared_by !== $this->item->acknowledgement->author) {
+ $subjectLabel = sprintf(
+ t('Acknowledgement cleared by %s', '..<username>'),
+ $this->item->acknowledgement->cleared_by
+ );
+ } else {
+ $subjectLabel = t('Acknowledgement cleared by author');
+ }
+ } elseif (isset($this->item->acknowledgement->expire_time)) {
+ $subjectLabel = t('Acknowledgement expired');
+ } else {
+ $subjectLabel = t('Acknowledgement cleared');
+ }
+
+ break;
+ case 'notification':
+ $subjectLabel = sprintf(
+ NotificationListItem::phraseForType($this->item->notification->type),
+ ucfirst($this->item->object_type)
+ );
+
+ break;
+ case 'state_change':
+ $state = $this->item->state->state_type === 'hard'
+ ? $this->item->state->hard_state
+ : $this->item->state->soft_state;
+ if ($state === 0) {
+ if ($this->item->object_type === 'service') {
+ $subjectLabel = t('Service recovered');
+ } else {
+ $subjectLabel = t('Host recovered');
+ }
+ } else {
+ if ($this->item->state->state_type === 'hard') {
+ $subjectLabel = t('Hard state changed');
+ } else {
+ $subjectLabel = t('Soft state changed');
+ }
+ }
+
+ break;
+ default:
+ $subjectLabel = $this->item->event_type;
+
+ break;
+ }
+
+ if ($this->getNoSubjectLink()) {
+ $title->addHtml(HtmlElement::create('span', ['class' => 'subject'], $subjectLabel));
+ } else {
+ $title->addHtml(new Link($subjectLabel, Links::event($this->item), ['class' => 'subject']));
+ }
+
+ if ($this->item->object_type === 'host') {
+ if (isset($this->item->host->id)) {
+ $link = $this->createHostLink($this->item->host, true);
+ }
+ } else {
+ if (isset($this->item->host->id, $this->item->service->id)) {
+ $link = $this->createServiceLink($this->item->service, $this->item->host, true);
+ }
+ }
+
+ $title->addHtml(Text::create(' '));
+ if (isset($link)) {
+ $title->addHtml($link);
+ }
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ return new TimeAgo($this->item->event_time->getTimestamp());
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseHostListItem.php b/library/Icingadb/Widget/ItemList/BaseHostListItem.php
new file mode 100644
index 0000000..edaf6c8
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseHostListItem.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Link;
+
+/**
+ * Host item of a host list. Represents one database row.
+ *
+ * @property Host $item
+ * @property HostList $list
+ */
+abstract class BaseHostListItem extends StateListItem
+{
+ use NoSubjectLink;
+
+ /**
+ * Create new subject link
+ *
+ * @return BaseHtmlElement
+ */
+ protected function createSubject()
+ {
+ if ($this->getNoSubjectLink()) {
+ return new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ );
+ } else {
+ return new Link($this->item->display_name, Links::host($this->item), ['class' => 'subject']);
+ }
+ }
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->getNoSubjectLink()) {
+ $this->setNoSubjectLink();
+ }
+
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name))
+ ->addMultiselectFilterAttribute($this, Filter::equal('host.name', $this->item->name));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseNotificationListItem.php b/library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
new file mode 100644
index 0000000..b538ac4
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
@@ -0,0 +1,189 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\TimeAgo;
+use InvalidArgumentException;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+abstract class BaseNotificationListItem extends BaseListItem
+{
+ use HostLink;
+ use NoSubjectLink;
+ use ServiceLink;
+
+ /** @var NotificationList */
+ protected $list;
+
+ protected function init(): void
+ {
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('id', bin2hex($this->item->history->id)));
+ }
+
+ /**
+ * Get a localized phrase for the given notification type
+ *
+ * @param string $type
+ *
+ * @return string
+ */
+ public static function phraseForType(string $type): string
+ {
+ switch ($type) {
+ case 'acknowledgement':
+ return t('Problem acknowledged');
+ case 'custom':
+ return t('Custom Notification triggered');
+ case 'downtime_end':
+ return t('Downtime ended');
+ case 'downtime_removed':
+ return t('Downtime removed');
+ case 'downtime_start':
+ return t('Downtime started');
+ case 'flapping_end':
+ return t('Flapping stopped');
+ case 'flapping_start':
+ return t('Flapping started');
+ case 'problem':
+ return t('%s ran into a problem');
+ case 'recovery':
+ return t('%s recovered');
+ default:
+ throw new InvalidArgumentException(sprintf('Type %s is not a valid notification type', $type));
+ }
+ }
+
+ abstract protected function getStateBallSize();
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ if (in_array($this->item->type, ['flapping_end', 'flapping_start', 'problem', 'recovery'])) {
+ $commandName = $this->item->object_type === 'host'
+ ? $this->item->host->checkcommand_name
+ : $this->item->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($this->item->text)) {
+ $caption->addHtml(new EmptyState(t('Output unavailable.')));
+ } else {
+ $caption->addHtml(new PluginOutputContainer(
+ (new PluginOutput($this->item->text))
+ ->setCommandName($commandName)
+ ));
+ }
+ } else {
+ $caption->addHtml(new EmptyState(t('Waiting for Icinga DB to synchronize the config.')));
+ }
+ } else {
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->author,
+ ': ',
+ $this->item->text
+ ]);
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ switch ($this->item->type) {
+ case 'acknowledgement':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IS_ACKNOWLEDGED)
+ ));
+
+ break;
+ case 'custom':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::NOTIFICATION)
+ ));
+
+ break;
+ case 'downtime_end':
+ case 'downtime_removed':
+ case 'downtime_start':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IN_DOWNTIME)
+ ));
+
+ break;
+ case 'flapping_end':
+ case 'flapping_start':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IS_FLAPPING)
+ ));
+
+ break;
+ case 'problem':
+ case 'recovery':
+ if ($this->item->object_type === 'host') {
+ $state = HostStates::text($this->item->state);
+ $previousHardState = HostStates::text($this->item->previous_hard_state);
+ } else {
+ $state = ServiceStates::text($this->item->state);
+ $previousHardState = ServiceStates::text($this->item->previous_hard_state);
+ }
+
+ $visual->addHtml(new StateChange($state, $previousHardState));
+
+ break;
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ if ($this->getNoSubjectLink()) {
+ $title->addHtml(HtmlElement::create(
+ 'span',
+ ['class' => 'subject'],
+ sprintf(self::phraseForType($this->item->type), ucfirst($this->item->object_type))
+ ));
+ } else {
+ $title->addHtml(new Link(
+ sprintf(self::phraseForType($this->item->type), ucfirst($this->item->object_type)),
+ Links::event($this->item->history),
+ ['class' => 'subject']
+ ));
+ }
+
+ if ($this->item->object_type === 'host') {
+ $link = $this->createHostLink($this->item->host, true);
+ } else {
+ $link = $this->createServiceLink($this->item->service, $this->item->host, true);
+ }
+
+ $title->addHtml(Text::create(' '), $link);
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ return new TimeAgo($this->item->send_time->getTimestamp());
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseServiceListItem.php b/library/Icingadb/Widget/ItemList/BaseServiceListItem.php
new file mode 100644
index 0000000..fe4f014
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseServiceListItem.php
@@ -0,0 +1,70 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\Attributes;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBall;
+
+/**
+ * Service item of a service list. Represents one database row.
+ *
+ * @property Service $item
+ * @property ServiceList $list
+ */
+abstract class BaseServiceListItem extends StateListItem
+{
+ use NoSubjectLink;
+
+ protected function createSubject()
+ {
+ $service = $this->item->display_name;
+ $host = [
+ new StateBall($this->item->host->state->getStateText(), StateBall::SIZE_MEDIUM),
+ ' ',
+ $this->item->host->display_name
+ ];
+
+ $host = new Link($host, Links::host($this->item->host), ['class' => 'subject']);
+ if ($this->getNoSubjectLink()) {
+ $service = new HtmlElement('span', Attributes::create(['class' => 'subject']), Text::create($service));
+ } else {
+ $service = new Link($service, Links::service($this->item, $this->item->host), ['class' => 'subject']);
+ }
+
+ return [Html::sprintf(t('%s on %s', '<service> on <host>'), $service, $host)];
+ }
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->getNoSubjectLink()) {
+ $this->setNoSubjectLink();
+ }
+
+ $this->list->addMultiselectFilterAttribute(
+ $this,
+ Filter::all(
+ Filter::equal('service.name', $this->item->name),
+ Filter::equal('host.name', $this->item->host->name)
+ )
+ );
+ $this->list->addDetailFilterAttribute(
+ $this,
+ Filter::all(
+ Filter::equal('name', $this->item->name),
+ Filter::equal('host.name', $this->item->host->name)
+ )
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/CommandTransportList.php b/library/Icingadb/Widget/ItemList/CommandTransportList.php
new file mode 100644
index 0000000..61d771d
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommandTransportList.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use ipl\Web\Common\BaseOrderedItemList;
+use ipl\Web\Url;
+
+class CommandTransportList extends BaseOrderedItemList
+{
+ use DetailActions;
+
+ protected function init(): void
+ {
+ $this->getAttributes()->add('class', 'command-transport-list');
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/command-transport/show'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return CommandTransportListItem::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/CommandTransportListItem.php b/library/Icingadb/Widget/ItemList/CommandTransportListItem.php
new file mode 100644
index 0000000..9873403
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommandTransportListItem.php
@@ -0,0 +1,71 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseOrderedListItem;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+class CommandTransportListItem extends BaseOrderedListItem
+{
+ protected function init(): void
+ {
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->addHtml(new Link(
+ new HtmlElement('strong', null, Text::create($this->item->name)),
+ Url::fromPath('icingadb/command-transport/show', ['name' => $this->item->name])
+ ));
+
+ $main->addHtml(new Link(
+ new Icon('trash', ['title' => sprintf(t('Remove command transport "%s"'), $this->item->name)]),
+ Url::fromPath('icingadb/command-transport/remove', ['name' => $this->item->name]),
+ [
+ 'class' => 'pull-right action-link',
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ ));
+
+ if ($this->getOrder() + 1 < $this->list->count()) {
+ $main->addHtml((new Link(
+ new Icon('arrow-down'),
+ Url::fromPath('icingadb/command-transport/sort', [
+ 'name' => $this->item->name,
+ 'pos' => $this->getOrder() + 1
+ ]),
+ ['class' => 'pull-right action-link']
+ ))->setBaseTarget('_self'));
+ }
+
+ if ($this->getOrder() > 0) {
+ $main->addHtml((new Link(
+ new Icon('arrow-up'),
+ Url::fromPath('icingadb/command-transport/sort', [
+ 'name' => $this->item->name,
+ 'pos' => $this->getOrder() - 1
+ ]),
+ ['class' => 'pull-right action-link']
+ ))->setBaseTarget('_self'));
+ }
+ }
+
+ protected function createVisual(): ?BaseHtmlElement
+ {
+ return null;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/CommentList.php b/library/Icingadb/Widget/ItemList/CommentList.php
new file mode 100644
index 0000000..5cf65ae
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommentList.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\CaptionDisabled;
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Url;
+
+class CommentList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use ViewMode;
+ use TicketLinks;
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'comment-list'];
+
+ protected function getItemClass(): string
+ {
+ $viewMode = $this->getViewMode();
+
+ $this->addAttributes(['class' => $viewMode]);
+
+ if ($viewMode === 'minimal') {
+ return CommentListItemMinimal::class;
+ } elseif ($viewMode === 'detailed') {
+ $this->removeAttribute('class', 'default-layout');
+ }
+
+ return CommentListItem::class;
+ }
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::commentsDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/comment'));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/CommentListItem.php b/library/Icingadb/Widget/ItemList/CommentListItem.php
new file mode 100644
index 0000000..3bbd0c2
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommentListItem.php
@@ -0,0 +1,12 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+
+class CommentListItem extends BaseCommentListItem
+{
+ use ListItemCommonLayout;
+}
diff --git a/library/Icingadb/Widget/ItemList/CommentListItemMinimal.php b/library/Icingadb/Widget/ItemList/CommentListItemMinimal.php
new file mode 100644
index 0000000..3c23ccd
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommentListItemMinimal.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+
+class CommentListItemMinimal extends BaseCommentListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->isCaptionDisabled()) {
+ $this->setCaptionDisabled();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/DowntimeList.php b/library/Icingadb/Widget/ItemList/DowntimeList.php
new file mode 100644
index 0000000..591ad98
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/DowntimeList.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\CaptionDisabled;
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Url;
+
+class DowntimeList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use ViewMode;
+ use TicketLinks;
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'downtime-list'];
+
+ protected function getItemClass(): string
+ {
+ $viewMode = $this->getViewMode();
+
+ $this->addAttributes(['class' => $viewMode]);
+
+ if ($viewMode === 'minimal') {
+ return DowntimeListItemMinimal::class;
+ } elseif ($viewMode === 'detailed') {
+ $this->removeAttribute('class', 'default-layout');
+ }
+
+ return DowntimeListItem::class;
+ }
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::downtimesDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/downtime'));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/DowntimeListItem.php b/library/Icingadb/Widget/ItemList/DowntimeListItem.php
new file mode 100644
index 0000000..cb7e9b3
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/DowntimeListItem.php
@@ -0,0 +1,23 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Html\BaseHtmlElement;
+
+class DowntimeListItem extends BaseDowntimeListItem
+{
+ use ListItemCommonLayout;
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ if ($this->item->is_in_effect) {
+ $main->add($this->createProgress());
+ }
+
+ $main->add($this->createHeader());
+ $main->add($this->createCaption());
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php b/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php
new file mode 100644
index 0000000..b8581d2
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+
+class DowntimeListItemMinimal extends BaseDowntimeListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->isCaptionDisabled()) {
+ $this->setCaptionDisabled();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HistoryList.php b/library/Icingadb/Widget/ItemList/HistoryList.php
new file mode 100644
index 0000000..d3b6232
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HistoryList.php
@@ -0,0 +1,57 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\CaptionDisabled;
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\LoadMore;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Orm\ResultSet;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Url;
+
+class HistoryList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ViewMode;
+ use LoadMore;
+ use TicketLinks;
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'history-list'];
+
+ protected function init(): void
+ {
+ /** @var ResultSet $data */
+ $data = $this->data;
+ $this->data = $this->getIterator($data);
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/event'));
+ }
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return HistoryListItemMinimal::class;
+ case 'detailed':
+ $this->removeAttribute('class', 'default-layout');
+
+ return HistoryListItemDetailed::class;
+ default:
+ return HistoryListItem::class;
+ }
+ }
+
+ protected function assemble(): void
+ {
+ $this->addAttributes(['class' => $this->getViewMode()]);
+
+ parent::assemble();
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HistoryListItem.php b/library/Icingadb/Widget/ItemList/HistoryListItem.php
new file mode 100644
index 0000000..c44a807
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HistoryListItem.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Web\Widget\StateBall;
+
+class HistoryListItem extends BaseHistoryListItem
+{
+ use ListItemCommonLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php b/library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php
new file mode 100644
index 0000000..7129d2d
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemDetailedLayout;
+use ipl\Web\Widget\StateBall;
+
+class HistoryListItemDetailed extends BaseHistoryListItem
+{
+ use ListItemDetailedLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php b/library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php
new file mode 100644
index 0000000..5a7f214
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+use ipl\Web\Widget\StateBall;
+
+class HistoryListItemMinimal extends BaseHistoryListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->isCaptionDisabled()) {
+ $this->setCaptionDisabled();
+ }
+ }
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_BIG;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostDetailHeader.php b/library/Icingadb/Widget/ItemList/HostDetailHeader.php
new file mode 100644
index 0000000..97176da
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostDetailHeader.php
@@ -0,0 +1,67 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\StateBall;
+
+class HostDetailHeader extends HostListItemMinimal
+{
+ protected function getStateBallSize(): string
+ {
+ return '';
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ if ($this->state->state_type === 'soft') {
+ $stateType = 'soft_state';
+ $previousStateType = 'previous_soft_state';
+
+ if ($this->state->previous_soft_state === 0) {
+ $previousStateType = 'hard_state';
+ }
+ } else {
+ $stateType = 'hard_state';
+ $previousStateType = 'previous_hard_state';
+
+ if ($this->state->hard_state === $this->state->previous_hard_state) {
+ $previousStateType = 'previous_soft_state';
+ }
+ }
+
+ $state = HostStates::text($this->state->$stateType);
+ $previousState = HostStates::text($this->state->$previousStateType);
+
+ $stateChange = new StateChange($state, $previousState);
+ if ($stateType === 'soft_state') {
+ $stateChange->setCurrentStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ }
+
+ if ($previousStateType === 'previous_soft_state') {
+ $stateChange->setPreviousStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ if ($stateType === 'soft_state') {
+ $visual->getAttributes()->add('class', 'small-state-change');
+ }
+ }
+
+ $stateChange->setIcon($this->state->getIcon());
+ $stateChange->setHandled($this->state->is_handled || ! $this->state->is_reachable);
+
+ $visual->addHtml($stateChange);
+ }
+
+ protected function assemble(): void
+ {
+ $attributes = $this->list->getAttributes();
+ if (! in_array('minimal', $attributes->get('class')->getValue())) {
+ $attributes->add('class', 'minimal');
+ }
+
+ parent::assemble();
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostList.php b/library/Icingadb/Widget/ItemList/HostList.php
new file mode 100644
index 0000000..2be1f84
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostList.php
@@ -0,0 +1,39 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Links;
+use ipl\Web\Url;
+
+/**
+ * Host list
+ */
+class HostList extends StateList
+{
+ protected $defaultAttributes = ['class' => 'host-list'];
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return HostListItemMinimal::class;
+ case 'detailed':
+ $this->removeAttribute('class', 'default-layout');
+
+ return HostListItemDetailed::class;
+ case 'objectHeader':
+ return HostDetailHeader::class;
+ default:
+ return HostListItem::class;
+ }
+ }
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::hostsDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/host'));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostListItem.php b/library/Icingadb/Widget/ItemList/HostListItem.php
new file mode 100644
index 0000000..2eae660
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostListItem.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Web\Widget\StateBall;
+
+class HostListItem extends BaseHostListItem
+{
+ use ListItemCommonLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostListItemDetailed.php b/library/Icingadb/Widget/ItemList/HostListItemDetailed.php
new file mode 100644
index 0000000..255bdcc
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostListItemDetailed.php
@@ -0,0 +1,108 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemDetailedLayout;
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class HostListItemDetailed extends BaseHostListItem
+{
+ use ListItemDetailedLayout;
+
+ /** @var int Max pie charts to be shown */
+ const PIE_CHART_LIMIT = 5;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer): void
+ {
+ $statusIcons = new HtmlElement('div', Attributes::create(['class' => 'status-icons']));
+
+ if ($this->item->state->last_comment->host_id === $this->item->id) {
+ $comment = $this->item->state->last_comment;
+ $comment->host = $this->item;
+ $comment = (new CommentList([$comment]))
+ ->setNoSubjectLink()
+ ->setObjectLinkDisabled()
+ ->setDetailActionsDisabled();
+
+ $statusIcons->addHtml(
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'comment-wrapper']),
+ new HtmlElement('div', Attributes::create(['class' => 'comment-popup']), $comment),
+ (new Icon('comments', ['class' => 'comment-icon']))
+ )
+ );
+ }
+
+ if ($this->item->state->is_flapping) {
+ $statusIcons->addHtml(new Icon(
+ 'random',
+ [
+ 'title' => sprintf(t('Host "%s" is in flapping state'), $this->item->display_name),
+ ]
+ ));
+ }
+
+ if (! $this->item->notifications_enabled) {
+ $statusIcons->addHtml(new Icon('bell-slash', ['title' => t('Notifications disabled')]));
+ }
+
+ if (! $this->item->active_checks_enabled) {
+ $statusIcons->addHtml(new Icon('eye-slash', ['title' => t('Active checks disabled')]));
+ }
+
+ $performanceData = new HtmlElement('div', Attributes::create(['class' => 'performance-data']));
+ if ($this->item->state->performance_data) {
+ $pieChartData = PerfDataSet::fromString($this->item->state->normalized_performance_data)->asArray();
+
+ $pies = [];
+ foreach ($pieChartData as $i => $perfdata) {
+ if ($perfdata->isVisualizable()) {
+ $pies[] = $perfdata->asInlinePie()->render();
+ }
+
+ // Check if number of visualizable pie charts is larger than PIE_CHART_LIMIT
+ if (count($pies) > HostListItemDetailed::PIE_CHART_LIMIT) {
+ break;
+ }
+ }
+
+ $maxVisiblePies = HostListItemDetailed::PIE_CHART_LIMIT - 2;
+ $numOfPies = count($pies);
+ foreach ($pies as $i => $pie) {
+ if (
+ // Show max. 5 elements: if there are more than 5, show 4 + `…`
+ $i > $maxVisiblePies && $numOfPies > HostListItemDetailed::PIE_CHART_LIMIT
+ ) {
+ $performanceData->addHtml(new HtmlElement('span', null, Text::create('…')));
+ break;
+ }
+
+ $performanceData->addHtml(HtmlString::create($pie));
+ }
+ }
+
+ if (! $statusIcons->isEmpty()) {
+ $footer->addHtml($statusIcons);
+ }
+
+ if (! $performanceData->isEmpty()) {
+ $footer->addHtml($performanceData);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostListItemMinimal.php b/library/Icingadb/Widget/ItemList/HostListItemMinimal.php
new file mode 100644
index 0000000..f04b991
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostListItemMinimal.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+use ipl\Web\Widget\StateBall;
+
+class HostListItemMinimal extends BaseHostListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_BIG;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/NotificationList.php b/library/Icingadb/Widget/ItemList/NotificationList.php
new file mode 100644
index 0000000..3a16b0b
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/NotificationList.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\CaptionDisabled;
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\LoadMore;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Orm\ResultSet;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Url;
+
+class NotificationList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ViewMode;
+ use LoadMore;
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'notification-list'];
+
+ protected function init(): void
+ {
+ /** @var ResultSet $data */
+ $data = $this->data;
+ $this->data = $this->getIterator($data);
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/event'));
+ }
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return NotificationListItemMinimal::class;
+ case 'detailed':
+ $this->removeAttribute('class', 'default-layout');
+
+ return NotificationListItemDetailed::class;
+ default:
+ return NotificationListItem::class;
+ }
+ }
+
+ protected function assemble(): void
+ {
+ $this->addAttributes(['class' => $this->getViewMode()]);
+
+ parent::assemble();
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/NotificationListItem.php b/library/Icingadb/Widget/ItemList/NotificationListItem.php
new file mode 100644
index 0000000..683762f
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/NotificationListItem.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Web\Widget\StateBall;
+
+class NotificationListItem extends BaseNotificationListItem
+{
+ use ListItemCommonLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php b/library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php
new file mode 100644
index 0000000..0a7449e
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemDetailedLayout;
+use ipl\Web\Widget\StateBall;
+
+class NotificationListItemDetailed extends BaseNotificationListItem
+{
+ use ListItemDetailedLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php b/library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php
new file mode 100644
index 0000000..dd6d226
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+use ipl\Web\Widget\StateBall;
+
+class NotificationListItemMinimal extends BaseNotificationListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->isCaptionDisabled()) {
+ $this->setCaptionDisabled();
+ }
+ }
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_BIG;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/PageSeparatorItem.php b/library/Icingadb/Widget/ItemList/PageSeparatorItem.php
new file mode 100644
index 0000000..3e252eb
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/PageSeparatorItem.php
@@ -0,0 +1,36 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class PageSeparatorItem extends BaseHtmlElement
+{
+ protected $defaultAttributes = ['class' => 'list-item page-separator'];
+
+ /** @var int */
+ protected $pageNumber;
+
+ /** @var string */
+ protected $tag = 'li';
+
+ public function __construct(int $pageNumber)
+ {
+ $this->pageNumber = $pageNumber;
+ }
+
+ protected function assemble()
+ {
+ $this->add(Html::tag(
+ 'a',
+ [
+ 'id' => 'page-' . $this->pageNumber,
+ 'data-icinga-no-scroll-on-focus' => true
+ ],
+ $this->pageNumber
+ ));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php b/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php
new file mode 100644
index 0000000..2f0dbbd
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php
@@ -0,0 +1,67 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\StateBall;
+
+class ServiceDetailHeader extends ServiceListItemMinimal
+{
+ protected function getStateBallSize(): string
+ {
+ return '';
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ if ($this->state->state_type === 'soft') {
+ $stateType = 'soft_state';
+ $previousStateType = 'previous_soft_state';
+
+ if ($this->state->previous_soft_state === 0) {
+ $previousStateType = 'hard_state';
+ }
+ } else {
+ $stateType = 'hard_state';
+ $previousStateType = 'previous_hard_state';
+
+ if ($this->state->hard_state === $this->state->previous_hard_state) {
+ $previousStateType = 'previous_soft_state';
+ }
+ }
+
+ $state = ServiceStates::text($this->state->$stateType);
+ $previousState = ServiceStates::text($this->state->$previousStateType);
+
+ $stateChange = new StateChange($state, $previousState);
+ if ($stateType === 'soft_state') {
+ $stateChange->setCurrentStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ }
+
+ if ($previousStateType === 'previous_soft_state') {
+ $stateChange->setPreviousStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ if ($stateType === 'soft_state') {
+ $visual->getAttributes()->add('class', 'small-state-change');
+ }
+ }
+
+ $stateChange->setIcon($this->state->getIcon());
+ $stateChange->setHandled($this->state->is_handled || ! $this->state->is_reachable);
+
+ $visual->addHtml($stateChange);
+ }
+
+ protected function assemble(): void
+ {
+ $attributes = $this->list->getAttributes();
+ if (! in_array('minimal', $attributes->get('class')->getValue())) {
+ $attributes->add('class', 'minimal');
+ }
+
+ parent::assemble();
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceList.php b/library/Icingadb/Widget/ItemList/ServiceList.php
new file mode 100644
index 0000000..8d41a70
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceList.php
@@ -0,0 +1,36 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Links;
+use ipl\Web\Url;
+
+class ServiceList extends StateList
+{
+ protected $defaultAttributes = ['class' => 'service-list'];
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return ServiceListItemMinimal::class;
+ case 'detailed':
+ $this->removeAttribute('class', 'default-layout');
+
+ return ServiceListItemDetailed::class;
+ case 'objectHeader':
+ return ServiceDetailHeader::class;
+ default:
+ return ServiceListItem::class;
+ }
+ }
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::servicesDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/service'));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceListItem.php b/library/Icingadb/Widget/ItemList/ServiceListItem.php
new file mode 100644
index 0000000..a974581
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceListItem.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Web\Widget\StateBall;
+
+class ServiceListItem extends BaseServiceListItem
+{
+ use ListItemCommonLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php b/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
new file mode 100644
index 0000000..1613599
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
@@ -0,0 +1,112 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemDetailedLayout;
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class ServiceListItemDetailed extends BaseServiceListItem
+{
+ use ListItemDetailedLayout;
+
+ /** @var int Max pie charts to be shown */
+ const PIE_CHART_LIMIT = 5;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer): void
+ {
+ $statusIcons = new HtmlElement('div', Attributes::create(['class' => 'status-icons']));
+
+ if ($this->item->state->last_comment->service_id === $this->item->id) {
+ $comment = $this->item->state->last_comment;
+ $comment->service = $this->item;
+ $comment = (new CommentList([$comment]))
+ ->setNoSubjectLink()
+ ->setObjectLinkDisabled()
+ ->setDetailActionsDisabled();
+
+ $statusIcons->addHtml(
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'comment-wrapper']),
+ new HtmlElement('div', Attributes::create(['class' => 'comment-popup']), $comment),
+ (new Icon('comments', ['class' => 'comment-icon']))
+ )
+ );
+ }
+
+ if ($this->item->state->is_flapping) {
+ $statusIcons->addHtml(new Icon(
+ 'random',
+ [
+ 'title' => sprintf(
+ t('Service "%s" on "%s" is in flapping state'),
+ $this->item->display_name,
+ $this->item->host->display_name
+ ),
+ ]
+ ));
+ }
+
+ if (! $this->item->notifications_enabled) {
+ $statusIcons->addHtml(new Icon('bell-slash', ['title' => t('Notifications disabled')]));
+ }
+
+ if (! $this->item->active_checks_enabled) {
+ $statusIcons->addHtml(new Icon('eye-slash', ['title' => t('Active checks disabled')]));
+ }
+
+ $performanceData = new HtmlElement('div', Attributes::create(['class' => 'performance-data']));
+ if ($this->item->state->performance_data) {
+ $pieChartData = PerfDataSet::fromString($this->item->state->normalized_performance_data)->asArray();
+
+ $pies = [];
+ foreach ($pieChartData as $i => $perfdata) {
+ if ($perfdata->isVisualizable()) {
+ $pies[] = $perfdata->asInlinePie()->render();
+ }
+
+ // Check if number of visualizable pie charts is larger than PIE_CHART_LIMIT
+ if (count($pies) > ServiceListItemDetailed::PIE_CHART_LIMIT) {
+ break;
+ }
+ }
+
+ $maxVisiblePies = ServiceListItemDetailed::PIE_CHART_LIMIT - 2;
+ $numOfPies = count($pies);
+ foreach ($pies as $i => $pie) {
+ if (
+ // Show max. 5 elements: if there are more than 5, show 4 + `…`
+ $i > $maxVisiblePies && $numOfPies > ServiceListItemDetailed::PIE_CHART_LIMIT
+ ) {
+ $performanceData->addHtml(new HtmlElement('span', null, Text::create('…')));
+ break;
+ }
+
+ $performanceData->addHtml(HtmlString::create($pie));
+ }
+ }
+
+ if (! $statusIcons->isEmpty()) {
+ $footer->addHtml($statusIcons);
+ }
+
+ if (! $performanceData->isEmpty()) {
+ $footer->addHtml($performanceData);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php b/library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php
new file mode 100644
index 0000000..e7a1bc6
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+use ipl\Web\Widget\StateBall;
+
+class ServiceListItemMinimal extends BaseServiceListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_BIG;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/StateList.php b/library/Icingadb/Widget/ItemList/StateList.php
new file mode 100644
index 0000000..1e6dcb9
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/StateList.php
@@ -0,0 +1,60 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Widget\Notice;
+use ipl\Html\HtmlDocument;
+use ipl\Web\Common\BaseItemList;
+
+abstract class StateList extends BaseItemList
+{
+ use ViewMode;
+ use NoSubjectLink;
+ use DetailActions;
+
+ /** @var bool Whether the list contains at least one item with an icon_image */
+ protected $hasIconImages = false;
+
+ /**
+ * Get whether the list contains at least one item with an icon_image
+ *
+ * @return bool
+ */
+ public function hasIconImages(): bool
+ {
+ return $this->hasIconImages;
+ }
+
+ /**
+ * Set whether the list contains at least one item with an icon_image
+ *
+ * @param bool $hasIconImages
+ *
+ * @return $this
+ */
+ public function setHasIconImages(bool $hasIconImages): self
+ {
+ $this->hasIconImages = $hasIconImages;
+
+ return $this;
+ }
+
+ protected function assemble(): void
+ {
+ $this->addAttributes(['class' => $this->getViewMode()]);
+
+ parent::assemble();
+
+ if ($this->data instanceof VolatileStateResults && $this->data->isRedisUnavailable()) {
+ $this->prependWrapper((new HtmlDocument())->addHtml(new Notice(
+ t('Icinga Redis is currently unavailable. The shown information might be outdated.')
+ )));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/StateListItem.php b/library/Icingadb/Widget/ItemList/StateListItem.php
new file mode 100644
index 0000000..d0b3363
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/StateListItem.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Model\State;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\CheckAttempt;
+use Icinga\Module\Icingadb\Widget\IconImage;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use ipl\Html\HtmlElement;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\TimeSince;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+/**
+ * Host or service item of a host or service list. Represents one database row.
+ */
+abstract class StateListItem extends BaseListItem
+{
+ /** @var StateList The list where the item is part of */
+ protected $list;
+
+ /** @var State The state of the item */
+ protected $state;
+
+ protected function init(): void
+ {
+ $this->state = $this->item->state;
+
+ if (isset($this->item->icon_image->icon_image)) {
+ $this->list->setHasIconImages(true);
+ }
+ }
+
+ abstract protected function createSubject();
+
+ abstract protected function getStateBallSize(): string;
+
+ /**
+ * @return ?BaseHtmlElement
+ */
+ protected function createIconImage(): ?BaseHtmlElement
+ {
+ if (! $this->list->hasIconImages()) {
+ return null;
+ }
+
+ $iconImage = HtmlElement::create('div', [
+ 'class' => 'icon-image',
+ ]);
+
+ $this->assembleIconImage($iconImage);
+
+ return $iconImage;
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ if ($this->state->soft_state === null && $this->state->output === null) {
+ $caption->addHtml(Text::create(t('Waiting for Icinga DB to synchronize the state.')));
+ } else {
+ if (empty($this->state->output)) {
+ $pluginOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->item));
+ }
+
+ $caption->addHtml($pluginOutput);
+ }
+ }
+
+ protected function assembleIconImage(BaseHtmlElement $iconImage): void
+ {
+ if (isset($this->item->icon_image->icon_image)) {
+ $iconImage->addHtml(new IconImage($this->item->icon_image->icon_image, $this->item->icon_image_alt));
+ } else {
+ $iconImage->addAttributes(['class' => 'placeholder']);
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(Html::sprintf(
+ t('%s is %s', '<hostname> is <state-text>'),
+ $this->createSubject(),
+ Html::tag('span', ['class' => 'state-text'], $this->state->getStateTextTranslated())
+ ));
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $stateBall = new StateBall($this->state->getStateText(), $this->getStateBallSize());
+ $stateBall->add($this->state->getIcon());
+ if ($this->state->is_handled || ! $this->state->is_reachable) {
+ $stateBall->getAttributes()->add('class', 'handled');
+ }
+
+ $visual->addHtml($stateBall);
+ if ($this->state->state_type === 'soft') {
+ $visual->addHtml(
+ new CheckAttempt((int) $this->state->check_attempt, (int) $this->item->max_check_attempts)
+ );
+ }
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ $since = null;
+ if ($this->state->is_overdue) {
+ $since = new TimeSince($this->state->next_update->getTimestamp());
+ $since->prepend(t('Overdue') . ' ');
+ $since->prependHtml(new Icon(Icons::WARNING));
+ } elseif ($this->state->last_state_change !== null && $this->state->last_state_change->getTimestamp() > 0) {
+ $since = new TimeSince($this->state->last_state_change->getTimestamp());
+ }
+
+ return $since;
+ }
+
+ protected function assemble(): void
+ {
+ if ($this->state->is_overdue) {
+ $this->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->add([
+ $this->createVisual(),
+ $this->createIconImage(),
+ $this->createMain()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php b/library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php
new file mode 100644
index 0000000..c56a1f8
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php
@@ -0,0 +1,60 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Hostgroup;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTableRowItem;
+use ipl\Web\Widget\Link;
+
+/**
+ * Hostgroup item of a hostgroup list. Represents one database row.
+ *
+ * @property Hostgroup $item
+ * @property HostgroupTable $table
+ */
+abstract class BaseHostGroupItem extends BaseTableRowItem
+{
+ use Translation;
+
+ protected function init(): void
+ {
+ if (isset($this->table)) {
+ $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+ }
+
+ protected function createSubject(): BaseHtmlElement
+ {
+ return isset($this->table)
+ ? new Link(
+ $this->item->display_name,
+ Links::hostgroup($this->item),
+ [
+ 'class' => 'subject',
+ 'title' => sprintf(
+ $this->translate('List all hosts in the group "%s"'),
+ $this->item->display_name
+ )
+ ]
+ )
+ : new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ );
+ }
+
+ protected function createCaption(): BaseHtmlElement
+ {
+ return new HtmlElement('span', null, Text::create($this->item->name));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php b/library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php
new file mode 100644
index 0000000..7bee532
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php
@@ -0,0 +1,60 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Servicegroup;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTableRowItem;
+use ipl\Web\Widget\Link;
+
+/**
+ * Servicegroup item of a servicegroup list. Represents one database row.
+ *
+ * @property Servicegroup $item
+ * @property ServicegroupTable $table
+ */
+abstract class BaseServiceGroupItem extends BaseTableRowItem
+{
+ use Translation;
+
+ protected function init(): void
+ {
+ if (isset($this->table)) {
+ $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+ }
+
+ protected function createSubject(): BaseHtmlElement
+ {
+ return isset($this->table)
+ ? new Link(
+ $this->item->display_name,
+ Links::servicegroup($this->item),
+ [
+ 'class' => 'subject',
+ 'title' => sprintf(
+ $this->translate('List all services in the group "%s"'),
+ $this->item->display_name
+ )
+ ]
+ )
+ : new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ );
+ }
+
+ protected function createCaption(): BaseHtmlElement
+ {
+ return new HtmlElement('span', null, Text::create($this->item->name));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php b/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php
new file mode 100644
index 0000000..642d6b3
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php
@@ -0,0 +1,107 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Orm\Model;
+
+/** @todo Figure out what this might (should) have in common with the new BaseTableRowItem implementation */
+abstract class BaseStateRowItem extends BaseHtmlElement
+{
+ protected $defaultAttributes = ['class' => 'row-item'];
+
+ /** @var Model */
+ protected $item;
+
+ /** @var StateItemTable */
+ protected $list;
+
+ protected $tag = 'tr';
+
+ /**
+ * Create a new row item
+ *
+ * @param Model $item
+ * @param StateItemTable $list
+ */
+ public function __construct(Model $item, StateItemTable $list)
+ {
+ $this->item = $item;
+ $this->list = $list;
+
+ $this->init();
+ }
+
+ /**
+ * Initialize the row item
+ *
+ * If you want to adjust the row item after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ abstract protected function assembleVisual(BaseHtmlElement $visual);
+
+ abstract protected function assembleCell(BaseHtmlElement $cell, string $path, $value);
+
+ protected function createVisual(): BaseHtmlElement
+ {
+ $visual = new HtmlElement('td', Attributes::create(['class' => 'visual']));
+
+ $this->assembleVisual($visual);
+
+ return $visual;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml($this->createVisual());
+
+ foreach ($this->list->getColumns() as $columnPath => $_) {
+ $steps = explode('.', $columnPath);
+ if ($steps[0] === $this->item->getTableName()) {
+ array_shift($steps);
+ $columnPath = implode('.', $steps);
+ }
+
+ $column = null;
+ $subject = $this->item;
+ foreach ($steps as $i => $step) {
+ if (isset($subject->$step)) {
+ if ($subject->$step instanceof Model) {
+ $subject = $subject->$step;
+ } else {
+ $column = $step;
+ }
+ } else {
+ $columnCandidate = implode('.', array_slice($steps, $i));
+ if (isset($subject->$columnCandidate)) {
+ $column = $columnCandidate;
+ } else {
+ break;
+ }
+ }
+ }
+
+ $value = null;
+ if ($column !== null) {
+ $value = $subject->$column;
+ if (is_array($value)) {
+ $value = empty($value) ? null : implode(',', $value);
+ }
+ }
+
+ $cell = new HtmlElement('td');
+ if ($value !== null) {
+ $this->assembleCell($cell, $columnPath, $value);
+ }
+
+ $this->addHtml($cell);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/GridCellLayout.php b/library/Icingadb/Widget/ItemTable/GridCellLayout.php
new file mode 100644
index 0000000..95b1a0a
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/GridCellLayout.php
@@ -0,0 +1,39 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\Link;
+
+trait GridCellLayout
+{
+ /**
+ * Creates a state badge for the Host / Service group with the highest severity that an object in the group has,
+ * along with the count of the objects with this severity belonging to the corresponding group.
+ *
+ * @return Link
+ */
+ abstract public function createGroupBadge(): Link;
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $visual->add($this->createGroupBadge());
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ $this->createSubject(),
+ $this->createCaption()
+ );
+ }
+
+ protected function assemble(): void
+ {
+ $this->add([
+ $this->createTitle()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostItemTable.php b/library/Icingadb/Widget/ItemTable/HostItemTable.php
new file mode 100644
index 0000000..e303746
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostItemTable.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\Links;
+use ipl\Web\Url;
+
+class HostItemTable extends StateItemTable
+{
+ use DetailActions;
+
+ protected function init()
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::hostsDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/host'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return HostRowItem::class;
+ }
+
+ protected function getVisualColumn(): string
+ {
+ return 'host.state.severity';
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostRowItem.php b/library/Icingadb/Widget/ItemTable/HostRowItem.php
new file mode 100644
index 0000000..cff70dd
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostRowItem.php
@@ -0,0 +1,51 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Link;
+
+class HostRowItem extends StateRowItem
+{
+ /** @var HostItemTable */
+ protected $list;
+
+ /** @var Host */
+ protected $item;
+
+ protected function init()
+ {
+ parent::init();
+
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name))
+ ->addMultiselectFilterAttribute($this, Filter::equal('host.name', $this->item->name));
+ }
+
+ protected function assembleCell(BaseHtmlElement $cell, string $path, $value)
+ {
+ switch ($path) {
+ case 'name':
+ case 'display_name':
+ $cell->addHtml(new Link($this->item->$path, Links::host($this->item), [
+ 'class' => 'subject',
+ 'title' => $this->item->$path
+ ]));
+ break;
+ case 'service.name':
+ case 'service.display_name':
+ $column = substr($path, 8);
+ $cell->addHtml(new Link(
+ $this->item->service->$column,
+ Links::service($this->item->service, $this->item)
+ ));
+ break;
+ default:
+ parent::assembleCell($cell, $path, $value);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostgroupGridCell.php b/library/Icingadb/Widget/ItemTable/HostgroupGridCell.php
new file mode 100644
index 0000000..5396747
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostgroupGridCell.php
@@ -0,0 +1,114 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+class HostgroupGridCell extends BaseHostGroupItem
+{
+ use GridCellLayout;
+
+ protected $defaultAttributes = ['class' => ['group-grid-cell', 'hostgroup-grid-cell']];
+
+ protected function createGroupBadge(): Link
+ {
+ $url = Url::fromPath('icingadb/hosts');
+ $urlFilter = Filter::all(Filter::equal('hostgroup.name', $this->item->name));
+
+ if ($this->item->hosts_down_unhandled > 0) {
+ $urlFilter->add(Filter::equal('host.state.soft_state', 1))
+ ->add(Filter::equal('host.state.is_handled', 'n'))
+ ->add(Filter::equal('host.state.is_reachable', 'y'));
+
+ return new Link(
+ new StateBadge($this->item->hosts_down_unhandled, 'down'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d host that is currently in DOWN state in host group "%s"',
+ 'List %d hosts which are currently in DOWN state in host group "%s"',
+ $this->item->hosts_down_unhandled
+ ),
+ $this->item->hosts_down_unhandled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->hosts_down_handled > 0) {
+ $urlFilter->add(Filter::equal('host.state.soft_state', 1))
+ ->add(Filter::any(
+ Filter::equal('host.state.is_handled', 'y'),
+ Filter::equal('host.state.is_reachable', 'n')
+ ));
+
+ return new Link(
+ new StateBadge($this->item->hosts_down_handled, 'down', true),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d host that is currently in DOWN (Acknowledged) state in host group "%s"',
+ 'List %d hosts which are currently in DOWN (Acknowledged) state in host group "%s"',
+ $this->item->hosts_down_handled
+ ),
+ $this->item->hosts_down_handled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->hosts_pending > 0) {
+ $urlFilter->add(Filter::equal('host.state.soft_state', 99));
+
+ return new Link(
+ new StateBadge($this->item->hosts_pending, 'pending'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d host that is currently in PENDING state in host group "%s"',
+ 'List %d hosts which are currently in PENDING state in host group "%s"',
+ $this->item->hosts_pending
+ ),
+ $this->item->hosts_pending,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->hosts_up > 0) {
+ $urlFilter->add(Filter::equal('host.state.soft_state', 0));
+
+ return new Link(
+ new StateBadge($this->item->hosts_up, 'up'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d host that is currently in UP state in host group "%s"',
+ 'List %d hosts which are currently in UP state in host group "%s"',
+ $this->item->hosts_up
+ ),
+ $this->item->hosts_up,
+ $this->item->display_name
+ )
+ ]
+ );
+ }
+
+ return new Link(
+ new StateBadge(0, 'none'),
+ $url,
+ [
+ 'title' => sprintf(
+ $this->translate('There are no hosts in host group "%s"'),
+ $this->item->display_name
+ )
+ ]
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostgroupTable.php b/library/Icingadb/Widget/ItemTable/HostgroupTable.php
new file mode 100644
index 0000000..6b40f76
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostgroupTable.php
@@ -0,0 +1,38 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Url;
+
+class HostgroupTable extends BaseItemTable
+{
+ use DetailActions;
+ use ViewMode;
+
+ protected $defaultAttributes = ['class' => 'hostgroup-table'];
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/hostgroup'));
+ }
+
+ protected function getLayout(): string
+ {
+ return $this->getViewMode() === 'grid'
+ ? 'group-grid'
+ : parent::getLayout();
+ }
+
+ protected function getItemClass(): string
+ {
+ return $this->getViewMode() === 'grid'
+ ? HostgroupGridCell::class
+ : HostgroupTableRow::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostgroupTableRow.php b/library/Icingadb/Widget/ItemTable/HostgroupTableRow.php
new file mode 100644
index 0000000..6aa61c2
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostgroupTableRow.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Model\Hostgroup;
+use Icinga\Module\Icingadb\Widget\Detail\HostStatistics;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceStatistics;
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+
+/**
+ * Hostgroup table row of a hostgroup table. Represents one database row.
+ *
+ * @property Hostgroup $item
+ * @property HostgroupTable $table
+ */
+class HostgroupTableRow extends BaseHostGroupItem
+{
+ use TableRowLayout;
+
+ protected $defaultAttributes = ['class' => 'hostgroup-table-row'];
+
+ /**
+ * Create Host and service statistics columns
+ *
+ * @return BaseHtmlElement[]
+ */
+ protected function createStatistics(): array
+ {
+ $hostStats = new HostStatistics($this->item);
+
+ $hostStats->setBaseFilter(Filter::equal('hostgroup.name', $this->item->name));
+ if (isset($this->table) && $this->table->hasBaseFilter()) {
+ $hostStats->setBaseFilter(
+ Filter::all($hostStats->getBaseFilter(), $this->table->getBaseFilter())
+ );
+ }
+
+ $serviceStats = new ServiceStatistics($this->item);
+
+ $serviceStats->setBaseFilter(Filter::equal('hostgroup.name', $this->item->name));
+ if (isset($this->table) && $this->table->hasBaseFilter()) {
+ $serviceStats->setBaseFilter(
+ Filter::all($serviceStats->getBaseFilter(), $this->table->getBaseFilter())
+ );
+ }
+
+ return [
+ $this->createColumn($hostStats),
+ $this->createColumn($serviceStats)
+ ];
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServiceItemTable.php b/library/Icingadb/Widget/ItemTable/ServiceItemTable.php
new file mode 100644
index 0000000..60872d8
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServiceItemTable.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\Links;
+use ipl\Web\Url;
+
+class ServiceItemTable extends StateItemTable
+{
+ use DetailActions;
+
+ protected function init()
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::servicesDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/service'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return ServiceRowItem::class;
+ }
+
+ protected function getVisualColumn(): string
+ {
+ return 'service.state.severity';
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServiceRowItem.php b/library/Icingadb/Widget/ItemTable/ServiceRowItem.php
new file mode 100644
index 0000000..0fb95d0
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServiceRowItem.php
@@ -0,0 +1,64 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Link;
+
+class ServiceRowItem extends StateRowItem
+{
+ /** @var ServiceItemTable */
+ protected $list;
+
+ /** @var Service */
+ protected $item;
+
+ protected function init()
+ {
+ parent::init();
+
+ $this->list->addMultiselectFilterAttribute(
+ $this,
+ Filter::all(
+ Filter::equal('service.name', $this->item->name),
+ Filter::equal('host.name', $this->item->host->name)
+ )
+ );
+ $this->list->addDetailFilterAttribute(
+ $this,
+ Filter::all(
+ Filter::equal('name', $this->item->name),
+ Filter::equal('host.name', $this->item->host->name)
+ )
+ );
+ }
+
+ protected function assembleCell(BaseHtmlElement $cell, string $path, $value)
+ {
+ switch ($path) {
+ case 'name':
+ case 'display_name':
+ $cell->addHtml(new Link(
+ $this->item->$path,
+ Links::service($this->item, $this->item->host),
+ [
+ 'class' => 'subject',
+ 'title' => $this->item->$path
+ ]
+ ));
+ break;
+ case 'host.name':
+ case 'host.display_name':
+ $column = substr($path, 5);
+ $cell->addHtml(new Link($this->item->host->$column, Links::host($this->item->host)));
+ break;
+ default:
+ parent::assembleCell($cell, $path, $value);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php b/library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php
new file mode 100644
index 0000000..16e50e1
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php
@@ -0,0 +1,204 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+class ServicegroupGridCell extends BaseServiceGroupItem
+{
+ use GridCellLayout;
+
+ protected $defaultAttributes = ['class' => ['group-grid-cell', 'servicegroup-grid-cell']];
+
+ protected function createGroupBadge(): Link
+ {
+ $url = Url::fromPath('icingadb/services/grid');
+ $urlFilter = Filter::all(Filter::equal('servicegroup.name', $this->item->name));
+
+ if ($this->item->services_critical_unhandled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 2))
+ ->add(Filter::equal('service.state.is_handled', 'n'))
+ ->add(Filter::equal('service.state.is_reachable', 'y'));
+
+ return new Link(
+ new StateBadge($this->item->services_critical_unhandled, 'critical'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in CRITICAL state in service group "%s"',
+ 'List %d services which are currently in CRITICAL state in service group "%s"',
+ $this->item->services_critical_unhandled
+ ),
+ $this->item->services_critical_unhandled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_critical_handled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 2))
+ ->add(Filter::any(
+ Filter::equal('service.state.is_handled', 'y'),
+ Filter::equal('service.state.is_reachable', 'n')
+ ));
+
+ return new Link(
+ new StateBadge($this->item->services_critical_handled, 'critical', true),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in CRITICAL (Acknowledged) state in service group'
+ . ' "%s"',
+ 'List %d services which are currently in CRITICAL (Acknowledged) state in service group'
+ . ' "%s"',
+ $this->item->services_critical_handled
+ ),
+ $this->item->services_critical_handled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_warning_unhandled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 1))
+ ->add(Filter::equal('service.state.is_handled', 'n'))
+ ->add(Filter::equal('service.state.is_reachable', 'y'));
+
+ return new Link(
+ new StateBadge($this->item->services_warning_unhandled, 'warning'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in WARNING state in service group "%s"',
+ 'List %d services which are currently in WARNING state in service group "%s"',
+ $this->item->services_warning_unhandled
+ ),
+ $this->item->services_warning_unhandled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_warning_handled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 1))
+ ->add(Filter::any(
+ Filter::equal('service.state.is_handled', 'y'),
+ Filter::equal('service.state.is_reachable', 'n')
+ ));
+
+ return new Link(
+ new StateBadge($this->item->services_warning_handled, 'warning', true),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in WARNING (Acknowledged) state in service group'
+ . ' "%s"',
+ 'List %d services which are currently in WARNING (Acknowledged) state in service group'
+ . ' "%s"',
+ $this->item->services_warning_handled
+ ),
+ $this->item->services_warning_handled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_unknown_unhandled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 3))
+ ->add(Filter::equal('service.state.is_handled', 'n'))
+ ->add(Filter::equal('service.state.is_reachable', 'y'));
+
+ return new Link(
+ new StateBadge($this->item->services_unknown_unhandled, 'unknown'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in UNKNOWN state in service group "%s"',
+ 'List %d services which are currently in UNKNOWN state in service group "%s"',
+ $this->item->services_unknown_unhandled
+ ),
+ $this->item->services_unknown_unhandled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_unknown_handled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 3))
+ ->add(Filter::any(
+ Filter::equal('service.state.is_handled', 'y'),
+ Filter::equal('service.state.is_reachable', 'n')
+ ));
+
+ return new Link(
+ new StateBadge($this->item->services_unknown_handled, 'unknown', true),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in UNKNOWN (Acknowledged) state in service group'
+ . ' "%s"',
+ 'List %d services which are currently in UNKNOWN (Acknowledged) state in service group'
+ . ' "%s"',
+ $this->item->services_unknown_handled
+ ),
+ $this->item->services_unknown_handled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_pending > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 99));
+
+ return new Link(
+ new StateBadge($this->item->services_pending, 'pending'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in PENDING state in service group "%s"',
+ 'List %d services which are currently in PENDING state in service group "%s"',
+ $this->item->services_pending
+ ),
+ $this->item->services_pending,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_ok > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 0));
+
+ return new Link(
+ new StateBadge($this->item->services_ok, 'ok'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in OK state in service group "%s"',
+ 'List %d services which are currently in OK state in service group "%s"',
+ $this->item->services_ok
+ ),
+ $this->item->services_ok,
+ $this->item->display_name
+ )
+ ]
+ );
+ }
+
+ return new Link(
+ new StateBadge(0, 'none'),
+ $url,
+ [
+ 'title' => sprintf(
+ $this->translate('There are no services in service group "%s"'),
+ $this->item->display_name
+ )
+ ]
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServicegroupTable.php b/library/Icingadb/Widget/ItemTable/ServicegroupTable.php
new file mode 100644
index 0000000..2378a77
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServicegroupTable.php
@@ -0,0 +1,38 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Url;
+
+class ServicegroupTable extends BaseItemTable
+{
+ use DetailActions;
+ use ViewMode;
+
+ protected $defaultAttributes = ['class' => 'servicegroup-table'];
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/servicegroup'));
+ }
+
+ protected function getLayout(): string
+ {
+ return $this->getViewMode() === 'grid'
+ ? 'group-grid'
+ : parent::getLayout();
+ }
+
+ protected function getItemClass(): string
+ {
+ return $this->getViewMode() === 'grid'
+ ? ServicegroupGridCell::class
+ : ServicegroupTableRow::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php b/library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php
new file mode 100644
index 0000000..3dea4c1
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php
@@ -0,0 +1,42 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Model\Servicegroup;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceStatistics;
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+
+/**
+ * Servicegroup item of a servicegroup list. Represents one database row.
+ *
+ * @property Servicegroup $item
+ * @property ServicegroupTable $table
+ */
+class ServicegroupTableRow extends BaseServiceGroupItem
+{
+ use TableRowLayout;
+
+ protected $defaultAttributes = ['class' => 'servicegroup-table-row'];
+
+ /**
+ * Create Service statistics cell
+ *
+ * @return BaseHtmlElement[]
+ */
+ protected function createStatistics(): array
+ {
+ $serviceStats = new ServiceStatistics($this->item);
+
+ $serviceStats->setBaseFilter(Filter::equal('servicegroup.name', $this->item->name));
+ if (isset($this->table) && $this->table->hasBaseFilter()) {
+ $serviceStats->setBaseFilter(
+ Filter::all($serviceStats->getBaseFilter(), $this->table->getBaseFilter())
+ );
+ }
+
+ return [$this->createColumn($serviceStats)];
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/StateItemTable.php b/library/Icingadb/Widget/ItemTable/StateItemTable.php
new file mode 100644
index 0000000..f392322
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/StateItemTable.php
@@ -0,0 +1,216 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Form;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Orm\Common\SortUtil;
+use ipl\Orm\Query;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Widget\EmptyStateBar;
+use ipl\Web\Widget\Icon;
+
+/** @todo Figure out what this might (should) have in common with the new BaseItemTable implementation */
+abstract class StateItemTable extends BaseHtmlElement
+{
+ protected $baseAttributes = [
+ 'class' => 'state-item-table'
+ ];
+
+ /** @var array<string, string> The columns to render */
+ protected $columns;
+
+ /** @var iterable The datasource */
+ protected $data;
+
+ /** @var string The sort rules */
+ protected $sort;
+
+ protected $tag = 'table';
+
+ /**
+ * Create a new item table
+ *
+ * @param iterable $data Datasource of the table
+ * @param array<string, string> $columns The columns to render, keys are labels
+ */
+ public function __construct(iterable $data, array $columns)
+ {
+ $this->data = $data;
+ $this->columns = array_flip($columns);
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ /**
+ * Initialize the item table
+ *
+ * If you want to adjust the item table after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Get the columns being rendered
+ *
+ * @return array<string, string>
+ */
+ public function getColumns(): array
+ {
+ return $this->columns;
+ }
+
+ /**
+ * Set sort rules (as returned by {@see SortControl::getSort()})
+ *
+ * @param ?string $sort
+ *
+ * @return $this
+ */
+ public function setSort(?string $sort): self
+ {
+ $this->sort = $sort;
+
+ return $this;
+ }
+
+ abstract protected function getItemClass(): string;
+
+ abstract protected function getVisualColumn(): string;
+
+ protected function getVisualLabel()
+ {
+ return new Icon('heartbeat', ['title' => t('Severity')]);
+ }
+
+ protected function assembleColumnHeader(BaseHtmlElement $header, string $name, $label): void
+ {
+ $sortRules = [];
+ if ($this->sort !== null) {
+ $sortRules = SortUtil::createOrderBy($this->sort);
+ }
+
+ $active = false;
+ $sortDirection = null;
+ foreach ($sortRules as $rule) {
+ if ($rule[0] === $name) {
+ $sortDirection = $rule[1];
+ $active = true;
+ break;
+ }
+ }
+
+ if ($sortDirection === 'desc') {
+ $value = "$name asc";
+ } else {
+ $value = "$name desc";
+ }
+
+ $icon = 'sort';
+ if ($active) {
+ $icon = $sortDirection === 'desc' ? 'sort-up' : 'sort-down';
+ }
+
+ $form = new Form();
+ $form->setAttribute('method', 'GET');
+
+ $button = $form->createElement('button', 'sort', [
+ 'value' => $value,
+ 'type' => 'submit',
+ 'title' => is_string($label) ? $label : null,
+ 'class' => $active ? 'active' : null
+ ]);
+ $button->addHtml(
+ Html::tag(
+ 'span',
+ null,
+ // With &nbsp; to have the height sized the same as the others
+ $label ?? HtmlString::create('&nbsp;')
+ ),
+ new Icon($icon)
+ );
+ $form->addElement($button);
+
+ $header->add($form);
+
+ switch (true) {
+ case substr($name, -7) === '.output':
+ case substr($name, -12) === '.long_output':
+ $header->getAttributes()->add('class', 'has-plugin-output');
+ break;
+ case substr($name, -22) === '.icon_image.icon_image':
+ $header->getAttributes()->add('class', 'has-icon-images');
+ break;
+ case substr($name, -17) === '.performance_data':
+ case substr($name, -28) === '.normalized_performance_data':
+ $header->getAttributes()->add('class', 'has-performance-data');
+ break;
+ }
+ }
+
+ protected function assemble()
+ {
+ $itemClass = $this->getItemClass();
+
+ $headerRow = new HtmlElement('tr');
+
+ $visualCell = new HtmlElement('th', Attributes::create(['class' => 'has-visual']));
+ $this->assembleColumnHeader($visualCell, $this->getVisualColumn(), $this->getVisualLabel());
+ $headerRow->addHtml($visualCell);
+
+ foreach ($this->columns as $name => $label) {
+ $headerCell = new HtmlElement('th');
+ $this->assembleColumnHeader($headerCell, $name, is_int($label) ? $name : $label);
+ $headerRow->addHtml($headerCell);
+ }
+
+ $this->addHtml(new HtmlElement('thead', null, $headerRow));
+
+ $body = new HtmlElement('tbody', Attributes::create(['data-base-target' => '_next']));
+ foreach ($this->data as $item) {
+ $body->addHtml(new $itemClass($item, $this));
+ }
+
+ if ($body->isEmpty()) {
+ $body->addHtml(new HtmlElement(
+ 'tr',
+ null,
+ new HtmlElement(
+ 'td',
+ Attributes::create(['colspan' => count($this->columns)]),
+ new EmptyStateBar(t('No items found.'))
+ )
+ ));
+ }
+
+ $this->addHtml($body);
+ }
+
+ /**
+ * Enrich the given list of column names with appropriate labels
+ *
+ * @param Query $query
+ * @param array $columns
+ *
+ * @return array
+ */
+ public static function applyColumnMetaData(Query $query, array $columns): array
+ {
+ $newColumns = [];
+ foreach ($columns as $columnPath) {
+ $label = $query->getResolver()->getColumnDefinition($columnPath)->getLabel();
+ $newColumns[$label ?? $columnPath] = $columnPath;
+ }
+
+ return $newColumns;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/StateRowItem.php b/library/Icingadb/Widget/ItemTable/StateRowItem.php
new file mode 100644
index 0000000..f62286b
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/StateRowItem.php
@@ -0,0 +1,124 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\CheckAttempt;
+use Icinga\Module\Icingadb\Widget\IconImage;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeSince;
+use ipl\Web\Widget\TimeUntil;
+
+abstract class StateRowItem extends BaseStateRowItem
+{
+ /** @var StateItemTable */
+ protected $list;
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ $stateBall = new StateBall($this->item->state->getStateText(), StateBall::SIZE_LARGE);
+ $stateBall->add($this->item->state->getIcon());
+
+ if ($this->item->state->is_handled) {
+ $stateBall->getAttributes()->add('class', 'handled');
+ }
+
+ $visual->addHtml($stateBall);
+ if ($this->item->state->state_type === 'soft') {
+ $visual->addHtml(new CheckAttempt(
+ (int) $this->item->state->check_attempt,
+ (int) $this->item->max_check_attempts
+ ));
+ }
+ }
+
+ protected function assembleCell(BaseHtmlElement $cell, string $path, $value)
+ {
+ switch (true) {
+ case $path === 'state.output':
+ case $path === 'state.long_output':
+ if (empty($value)) {
+ $pluginOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->item));
+ }
+
+ $cell->addHtml($pluginOutput)
+ ->getAttributes()
+ ->add('class', 'has-plugin-output');
+ break;
+ case $path === 'state.soft_state':
+ case $path === 'state.hard_state':
+ case $path === 'state.previous_soft_state':
+ case $path === 'state.previous_hard_state':
+ $stateType = substr($path, 6);
+ if ($this->item instanceof Host) {
+ $stateName = HostStates::translated($this->item->state->$stateType);
+ } else {
+ $stateName = ServiceStates::translated($this->item->state->$stateType);
+ }
+
+ $cell->addHtml(Text::create($stateName));
+ break;
+ case $path === 'state.last_update':
+ case $path === 'state.last_state_change':
+ $column = substr($path, 6);
+ $cell->addHtml(new TimeSince($this->item->state->$column->getTimestamp()));
+ break;
+ case $path === 'state.next_check':
+ case $path === 'state.next_update':
+ $column = substr($path, 6);
+ $cell->addHtml(new TimeUntil($this->item->state->$column->getTimestamp()));
+ break;
+ case $path === 'state.performance_data':
+ case $path === 'state.normalized_performance_data':
+ $perfdataContainer = new HtmlElement('div', Attributes::create(['class' => 'performance-data']));
+
+ $pieChartData = PerfDataSet::fromString($this->item->state->normalized_performance_data)->asArray();
+ foreach ($pieChartData as $perfdata) {
+ if ($perfdata->isVisualizable()) {
+ $perfdataContainer->addHtml(new HtmlString($perfdata->asInlinePie()->render()));
+ }
+ }
+
+ $cell->addHtml($perfdataContainer)
+ ->getAttributes()
+ ->add('class', 'has-performance-data');
+ break;
+ case $path === 'is_volatile':
+ case $path === 'host.is_volatile':
+ case substr($path, -8) == '_enabled':
+ case (bool) preg_match('/state\.(is_|in_)/', $path):
+ if ($value) {
+ $cell->addHtml(new Icon('check'));
+ }
+
+ break;
+ case $path === 'icon_image.icon_image':
+ $cell->addHtml(new IconImage($value, $this->item->icon_image_alt))
+ ->getAttributes()
+ ->add('class', 'has-icon-images');
+ break;
+ default:
+ if (preg_match('/(^id|_id|.id|_checksum|_bin)$/', $path)) {
+ $value = bin2hex($value);
+ }
+
+ $cell->addHtml(Text::create($value));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/TableRowLayout.php b/library/Icingadb/Widget/ItemTable/TableRowLayout.php
new file mode 100644
index 0000000..b9ce022
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/TableRowLayout.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+
+trait TableRowLayout
+{
+ protected function assembleColumns(HtmlDocument $columns): void
+ {
+ foreach ($this->createStatistics() as $objectStatistic) {
+ $columns->addHtml($objectStatistic);
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ $this->createSubject(),
+ $this->createCaption()
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/UserTable.php b/library/Icingadb/Widget/ItemTable/UserTable.php
new file mode 100644
index 0000000..432817b
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/UserTable.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Url;
+
+class UserTable extends BaseItemTable
+{
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'user-table'];
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/user'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return UserTableRow::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/UserTableRow.php b/library/Icingadb/Widget/ItemTable/UserTableRow.php
new file mode 100644
index 0000000..c10851e
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/UserTableRow.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\User;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTableRowItem;
+use ipl\Web\Widget\Link;
+
+/**
+ * User item of a user list. Represents one database row.
+ *
+ * @property User $item
+ * @property UserTable $table
+ */
+class UserTableRow extends BaseTableRowItem
+{
+ protected $defaultAttributes = ['class' => 'user-table-row'];
+
+ protected function init(): void
+ {
+ if (isset($this->table)) {
+ $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $visual->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'user-ball']),
+ Text::create($this->item->display_name[0])
+ ));
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ isset($this->table)
+ ? new Link($this->item->display_name, Links::user($this->item), ['class' => 'subject'])
+ : new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ ),
+ new HtmlElement('span', null, Text::create($this->item->name))
+ );
+ }
+
+ protected function assembleColumns(HtmlDocument $columns): void
+ {
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/UsergroupTable.php b/library/Icingadb/Widget/ItemTable/UsergroupTable.php
new file mode 100644
index 0000000..77d3ba9
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/UsergroupTable.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Url;
+
+class UsergroupTable extends BaseItemTable
+{
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'usergroup-table'];
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/usergroup'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return UsergroupTableRow::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/UsergroupTableRow.php b/library/Icingadb/Widget/ItemTable/UsergroupTableRow.php
new file mode 100644
index 0000000..c3cbf74
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/UsergroupTableRow.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTableRowItem;
+use ipl\Web\Widget\Link;
+
+/**
+ * Usergroup item of a usergroup list. Represents one database row.
+ *
+ * @property Usergroup $item
+ * @property UsergroupTable $table
+ */
+class UsergroupTableRow extends BaseTableRowItem
+{
+ protected $defaultAttributes = ['class' => 'usergroup-table-row'];
+
+ protected function init(): void
+ {
+ if (isset($this->table)) {
+ $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $visual->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'usergroup-ball']),
+ Text::create($this->item->display_name[0])
+ ));
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ isset($this->table)
+ ? new Link($this->item->display_name, Links::usergroup($this->item), ['class' => 'subject'])
+ : new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ ),
+ new HtmlElement('span', null, Text::create($this->item->name))
+ );
+ }
+
+ protected function assembleColumns(HtmlDocument $columns): void
+ {
+ }
+}
diff --git a/library/Icingadb/Widget/MarkdownLine.php b/library/Icingadb/Widget/MarkdownLine.php
new file mode 100644
index 0000000..74c413d
--- /dev/null
+++ b/library/Icingadb/Widget/MarkdownLine.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Web\Helper\Markdown;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\DeferredText;
+
+class MarkdownLine extends BaseHtmlElement
+{
+ protected $tag = 'section';
+
+ protected $defaultAttributes = ['class' => ['markdown', 'inline']];
+
+ /**
+ * MarkdownLine constructor.
+ *
+ * @param string $line
+ */
+ public function __construct(string $line)
+ {
+ $this->add((new DeferredText(function () use ($line) {
+ return Markdown::line($line);
+ }))->setEscaped(true));
+ }
+}
diff --git a/library/Icingadb/Widget/MarkdownText.php b/library/Icingadb/Widget/MarkdownText.php
new file mode 100644
index 0000000..43db03e
--- /dev/null
+++ b/library/Icingadb/Widget/MarkdownText.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Web\Helper\Markdown;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\DeferredText;
+
+class MarkdownText extends BaseHtmlElement
+{
+ protected $tag = 'section';
+
+ protected $defaultAttributes = ['class' => 'markdown'];
+
+ /**
+ * MarkdownText constructor.
+ *
+ * @param string $text
+ */
+ public function __construct(string $text)
+ {
+ $this->add((new DeferredText(function () use ($text) {
+ return Markdown::text($text);
+ }))->setEscaped(true));
+ }
+}
diff --git a/library/Icingadb/Widget/Notice.php b/library/Icingadb/Widget/Notice.php
new file mode 100644
index 0000000..3ed6dad
--- /dev/null
+++ b/library/Icingadb/Widget/Notice.php
@@ -0,0 +1,36 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Web\Widget\Icon;
+
+class Notice extends BaseHtmlElement
+{
+ /** @var mixed */
+ protected $content;
+
+ protected $tag = 'p';
+
+ protected $defaultAttributes = ['class' => 'notice'];
+
+ /**
+ * Create a html notice
+ *
+ * @param mixed $content
+ */
+ public function __construct($content)
+ {
+ $this->content = $content;
+ }
+
+ protected function assemble(): void
+ {
+ $this->addHtml(new Icon('triangle-exclamation'));
+ $this->addHtml((new HtmlElement('span'))->add($this->content));
+ $this->addHtml(new Icon('triangle-exclamation'));
+ }
+}
diff --git a/library/Icingadb/Widget/PluginOutputContainer.php b/library/Icingadb/Widget/PluginOutputContainer.php
new file mode 100644
index 0000000..a8ff578
--- /dev/null
+++ b/library/Icingadb/Widget/PluginOutputContainer.php
@@ -0,0 +1,22 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use ipl\Html\BaseHtmlElement;
+
+class PluginOutputContainer extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ public function __construct(PluginOutput $output)
+ {
+ $this->setHtmlContent($output);
+
+ $this->getAttributes()->registerAttributeCallback('class', function () use ($output) {
+ return $output->isHtml() ? 'plugin-output' : 'plugin-output preformatted';
+ });
+ }
+}
diff --git a/library/Icingadb/Widget/ServiceStateBadges.php b/library/Icingadb/Widget/ServiceStateBadges.php
new file mode 100644
index 0000000..fee2586
--- /dev/null
+++ b/library/Icingadb/Widget/ServiceStateBadges.php
@@ -0,0 +1,46 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Common\StateBadges;
+use ipl\Web\Url;
+
+class ServiceStateBadges extends StateBadges
+{
+ protected function getBaseUrl(): Url
+ {
+ return Links::services();
+ }
+
+ protected function getType(): string
+ {
+ return 'service';
+ }
+
+ protected function getPrefix(): string
+ {
+ return 'services';
+ }
+
+ protected function getStateInt(string $state): int
+ {
+ return ServiceStates::int($state);
+ }
+
+ protected function assemble()
+ {
+ $this->addAttributes(['class' => 'service-state-badges']);
+
+ $this->add(array_filter([
+ $this->createGroup('critical'),
+ $this->createGroup('warning'),
+ $this->createGroup('unknown'),
+ $this->createBadge('ok'),
+ $this->createBadge('pending')
+ ]));
+ }
+}
diff --git a/library/Icingadb/Widget/ServiceStatusBar.php b/library/Icingadb/Widget/ServiceStatusBar.php
new file mode 100644
index 0000000..56f47aa
--- /dev/null
+++ b/library/Icingadb/Widget/ServiceStatusBar.php
@@ -0,0 +1,24 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Common\BaseStatusBar;
+use ipl\Html\BaseHtmlElement;
+
+class ServiceStatusBar extends BaseStatusBar
+{
+ protected function assembleTotal(BaseHtmlElement $total): void
+ {
+ $total->add(sprintf(
+ tp('%d Service', '%d Services', $this->summary->services_total),
+ $this->summary->services_total
+ ));
+ }
+
+ protected function createStateBadges(): BaseHtmlElement
+ {
+ return (new ServiceStateBadges($this->summary))->setBaseFilter($this->getBaseFilter());
+ }
+}
diff --git a/library/Icingadb/Widget/ServiceSummaryDonut.php b/library/Icingadb/Widget/ServiceSummaryDonut.php
new file mode 100644
index 0000000..e806fba
--- /dev/null
+++ b/library/Icingadb/Widget/ServiceSummaryDonut.php
@@ -0,0 +1,81 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\BaseFilter;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\Card;
+
+class ServiceSummaryDonut extends Card
+{
+ use BaseFilter;
+
+ protected $defaultAttributes = ['class' => 'donut-container', 'data-base-target' => '_next'];
+
+ /** @var ServicestateSummary */
+ protected $summary;
+
+ public function __construct(ServicestateSummary $summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function assembleBody(BaseHtmlElement $body)
+ {
+ $labelBigUrlFilter = Filter::all(
+ Filter::equal('service.state.soft_state', 2),
+ Filter::equal('service.state.is_handled', 'n')
+ );
+ if ($this->hasBaseFilter()) {
+ $labelBigUrlFilter->add($this->getBaseFilter());
+ }
+
+ $donut = (new Donut())
+ ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled'])
+ ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning'])
+ ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled'])
+ ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown'])
+ ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending'])
+ ->setLabelBig($this->summary->services_critical_unhandled)
+ ->setLabelBigUrl(Links::services()->setFilter($labelBigUrlFilter)->addParams([
+ 'sort' => 'service.state.last_state_change'
+ ]))
+ ->setLabelBigEyeCatching($this->summary->services_critical_unhandled > 0)
+ ->setLabelSmall(t('Critical'));
+
+ $body->addHtml(
+ new HtmlElement('div', Attributes::create(['class' => 'donut']), new HtmlString($donut->render()))
+ );
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer)
+ {
+ $footer->addHtml((new ServiceStateBadges($this->summary))->setBaseFilter($this->getBaseFilter()));
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $header->addHtml(
+ new HtmlElement('h2', null, Text::create(t('Services'))),
+ new HtmlElement('span', Attributes::create(['class' => 'meta']), TemplateString::create(
+ t('{{#total}}Total{{/total}} %d'),
+ ['total' => new HtmlElement('span')],
+ (int) $this->summary->services_total
+ ))
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ShowMore.php b/library/Icingadb/Widget/ShowMore.php
new file mode 100644
index 0000000..d7fc7fb
--- /dev/null
+++ b/library/Icingadb/Widget/ShowMore.php
@@ -0,0 +1,64 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Orm\ResultSet;
+use ipl\Web\Common\BaseTarget;
+use ipl\Web\Url;
+use ipl\Web\Widget\ActionLink;
+
+class ShowMore extends BaseHtmlElement
+{
+ use BaseTarget;
+
+ protected $defaultAttributes = ['class' => 'show-more'];
+
+ protected $tag = 'div';
+
+ /** @var ResultSet */
+ protected $resultSet;
+
+ /** @var Url */
+ protected $url;
+
+ /** @var ?string */
+ protected $label;
+
+ public function __construct(ResultSet $resultSet, Url $url, string $label = null)
+ {
+ $this->label = $label;
+ $this->resultSet = $resultSet;
+ $this->url = $url;
+ }
+
+ public function setLabel(string $label): self
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function getLabel(): string
+ {
+ return $this->label ?: t('Show More');
+ }
+
+ public function renderUnwrapped(): string
+ {
+ if ($this->resultSet->hasMore()) {
+ return parent::renderUnwrapped();
+ }
+
+ return '';
+ }
+
+ protected function assemble(): void
+ {
+ if ($this->resultSet->hasMore()) {
+ $this->addHtml(new ActionLink($this->getLabel(), $this->url));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/StateBadge.php b/library/Icingadb/Widget/StateBadge.php
new file mode 100644
index 0000000..d947590
--- /dev/null
+++ b/library/Icingadb/Widget/StateBadge.php
@@ -0,0 +1,10 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+/** @deprecated Use {@see \ipl\Web\Widget\StateBadge} instead */
+class StateBadge extends \ipl\Web\Widget\StateBadge
+{
+}
diff --git a/library/Icingadb/Widget/StateChange.php b/library/Icingadb/Widget/StateChange.php
new file mode 100644
index 0000000..a9987be
--- /dev/null
+++ b/library/Icingadb/Widget/StateChange.php
@@ -0,0 +1,133 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class StateChange extends BaseHtmlElement
+{
+ protected $previousState;
+
+ protected $state;
+
+ protected $previousStateBallSize = StateBall::SIZE_BIG;
+
+ protected $currentStateBallSize = StateBall::SIZE_BIG;
+
+ protected $defaultAttributes = ['class' => 'state-change'];
+
+ protected $tag = 'div';
+
+ /** @var ?Icon Current state ball icon */
+ protected $icon;
+
+ /** @var bool Whether the state is handled */
+ protected $isHandled = false;
+
+ public function __construct(string $state, string $previousState)
+ {
+ $this->previousState = $previousState;
+ $this->state = $state;
+ }
+
+ /**
+ * Set the state ball size for the previous state
+ *
+ * @param string $size
+ *
+ * @return $this
+ */
+ public function setPreviousStateBallSize(string $size): self
+ {
+ $this->previousStateBallSize = $size;
+
+ return $this;
+ }
+
+ /**
+ * Set the state ball size for the current state
+ *
+ * @param string $size
+ *
+ * @return $this
+ */
+ public function setCurrentStateBallSize(string $size): self
+ {
+ $this->currentStateBallSize = $size;
+
+ return $this;
+ }
+
+ /**
+ * Set the current state ball icon
+ *
+ * @param $icon
+ *
+ * @return $this
+ */
+ public function setIcon($icon): self
+ {
+ $this->icon = $icon;
+
+ return $this;
+ }
+
+ /**
+ * Set whether the current state is handled
+ *
+ * @return $this
+ */
+ public function setHandled($isHandled = true): self
+ {
+ $this->isHandled = $isHandled;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $currentStateBall = (new StateBall($this->state, $this->currentStateBallSize))
+ ->add($this->icon);
+
+ if ($this->isHandled) {
+ $currentStateBall->getAttributes()->add('class', 'handled');
+ }
+
+ $previousStateBall = new StateBall($this->previousState, $this->previousStateBallSize);
+ if ($this->isRightBiggerThanLeft()) {
+ $this->getAttributes()->add('class', 'reversed-state-balls');
+
+ $this->addHtml($currentStateBall, $previousStateBall);
+ } else {
+ $this->addHtml($previousStateBall, $currentStateBall);
+ }
+ }
+
+ protected function isRightBiggerThanLeft(): bool
+ {
+ $left = $this->previousStateBallSize;
+ $right = $this->currentStateBallSize;
+
+ if ($left === $right) {
+ return false;
+ } elseif ($left === StateBall::SIZE_LARGE) {
+ return false;
+ }
+
+ $map = [
+ StateBall::SIZE_BIG => [false, [StateBall::SIZE_LARGE]],
+ StateBall::SIZE_MEDIUM_LARGE => [false, [StateBall::SIZE_BIG, StateBall::SIZE_LARGE]],
+ StateBall::SIZE_MEDIUM => [true, [StateBall::SIZE_TINY, StateBall::SIZE_SMALL]],
+ StateBall::SIZE_SMALL => [true, [StateBall::SIZE_TINY]]
+ ];
+
+ list($negate, $sizes) = $map[$left];
+ $found = in_array($right, $sizes, true);
+
+ return ($negate && ! $found) || (! $negate && $found);
+ }
+}
diff --git a/library/Icingadb/Widget/TagList.php b/library/Icingadb/Widget/TagList.php
new file mode 100644
index 0000000..6a28a9c
--- /dev/null
+++ b/library/Icingadb/Widget/TagList.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\Link;
+
+class TagList extends BaseHtmlElement
+{
+ protected $content = [];
+
+ protected $defaultAttributes = ['class' => 'tag-list'];
+
+ protected $tag = 'div';
+
+ public function addLink($content, $url): self
+ {
+ $this->content[] = new Link($content, $url);
+
+ return $this;
+ }
+
+ public function hasContent(): bool
+ {
+ return ! empty($this->content);
+ }
+
+ protected function assemble()
+ {
+ $this->add(Html::wrapEach($this->content, 'li'));
+ }
+}
diff --git a/module.info b/module.info
new file mode 100644
index 0000000..c86c744
--- /dev/null
+++ b/module.info
@@ -0,0 +1,6 @@
+Module: icingadb
+Version: 1.1.1
+Requires:
+ Libraries: icinga-php-library (>=0.13.0), icinga-php-thirdparty (>=0.12.0)
+Description: Icinga DB Web
+ UI for Icinga DB – Provides a graphical interface to your Icinga monitoring
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 0000000..262245d
--- /dev/null
+++ b/phpstan-baseline.neon
@@ -0,0 +1,8346 @@
+parameters:
+ ignoreErrors:
+ -
+ message: "#^Cannot cast mixed to int\\.$#"
+ count: 1
+ path: application/controllers/CommandTransportController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommandTransportController\\:\\:addAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommandTransportController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommandTransportController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommandTransportController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommandTransportController\\:\\:removeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommandTransportController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommandTransportController\\:\\:showAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommandTransportController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommandTransportController\\:\\:sortAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommandTransportController.php
+
+ -
+ message: "#^Parameter \\#2 \\$filter of static method Icinga\\\\Data\\\\Filter\\\\Filter\\:\\:where\\(\\) expects string, mixed given\\.$#"
+ count: 2
+ path: application/controllers/CommandTransportController.php
+
+ -
+ message: "#^Parameter \\#2 \\$offset of function array_splice expects int, int\\|false given\\.$#"
+ count: 1
+ path: application/controllers/CommandTransportController.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 2
+ path: application/controllers/CommandTransportController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:acknowledgeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:addCommentAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:assertIsGrantedOnCommandTargets\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:checkNowAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:fetchCommandTargets\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:processCheckresultAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:removeAcknowledgementAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:scheduleCheckAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:scheduleDowntimeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:sendCustomNotificationAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:toggleFeaturesAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Parameter \\#1 \\$form of method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:handleCommandForm\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\|string, Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ToggleObjectFeaturesForm\\|null given\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:\\$commandTargetModel \\(ipl\\\\Orm\\\\Model\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) does not accept array\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentController\\:\\:\\$comment \\(Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Comment\\) does not accept ipl\\\\Orm\\\\Model\\.$#"
+ count: 1
+ path: application/controllers/CommentController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentsController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentsController\\:\\:deleteAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentsController\\:\\:detailsAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentsController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\CommentsController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/CommentsController.php
+
+ -
+ message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#"
+ count: 1
+ path: application/controllers/CommentsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ConfigController\\:\\:addFormToContent\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ConfigController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ConfigController\\:\\:databaseAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ConfigController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ConfigController\\:\\:redisAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ConfigController.php
+
+ -
+ message: "#^Parameter \\#1 \\$name of method ipl\\\\Web\\\\Widget\\\\Tabs\\:\\:add\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/ConfigController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:acknowledgeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:addCommentAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:assertIsGrantedOnCommandTargets\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:checkNowAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:fetchCommandTargets\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:processCheckresultAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:removeAcknowledgementAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:scheduleCheckAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:scheduleDowntimeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:sendCustomNotificationAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:toggleFeaturesAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Parameter \\#1 \\$form of method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:handleCommandForm\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\|string, Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ToggleObjectFeaturesForm\\|null given\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:\\$commandTargetModel \\(ipl\\\\Orm\\\\Model\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) does not accept array\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimeController\\:\\:\\$downtime \\(Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Downtime\\) does not accept ipl\\\\Orm\\\\Model\\.$#"
+ count: 1
+ path: application/controllers/DowntimeController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimesController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimesController\\:\\:deleteAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimesController\\:\\:detailsAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimesController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\DowntimesController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/DowntimesController.php
+
+ -
+ message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#"
+ count: 1
+ path: application/controllers/DowntimesController.php
+
+ -
+ message: "#^Cannot access property \\$exception on mixed\\.$#"
+ count: 1
+ path: application/controllers/ErrorController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ErrorController\\:\\:errorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ErrorController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ErrorController\\:\\:postDispatchXhr\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ErrorController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ErrorController\\:\\:prepareInit\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ErrorController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\EventController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/EventController.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function hex2bin expects string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/EventController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\EventController\\:\\:\\$event \\(Icinga\\\\Module\\\\Icingadb\\\\Model\\\\History\\) does not accept ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: application/controllers/EventController.php
+
+ -
+ message: "#^Cannot access property \\$hosts_active_checks_enabled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: application/controllers/HealthController.php
+
+ -
+ message: "#^Cannot access property \\$hosts_passive_checks_enabled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: application/controllers/HealthController.php
+
+ -
+ message: "#^Cannot access property \\$services_active_checks_enabled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: application/controllers/HealthController.php
+
+ -
+ message: "#^Cannot access property \\$services_passive_checks_enabled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: application/controllers/HealthController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HealthController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HealthController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HistoryController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HistoryController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HistoryController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HistoryController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HistoryController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HistoryController.php
+
+ -
+ message: "#^Parameter \\#2 \\$default of method Icinga\\\\Web\\\\UrlParams\\:\\:shift\\(\\) expects string\\|null, int\\<1, max\\> given\\.$#"
+ count: 1
+ path: application/controllers/HistoryController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of method Icinga\\\\Web\\\\Url\\:\\:setParam\\(\\) expects array\\|bool\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/HistoryController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:lessThanOrEqual\\(\\) expects float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/HistoryController.php
+
+ -
+ message: "#^Cannot access property \\$is_overdue on mixed\\.$#"
+ count: 4
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:acknowledgeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:addCommentAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:assertIsGrantedOnCommandTargets\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:checkNowAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:fetchCommandTargets\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:getDefaultTabControls\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:historyAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:processCheckresultAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:removeAcknowledgementAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:scheduleCheckAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:scheduleDowntimeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:sendCustomNotificationAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:servicesAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:setTitleTab\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:sourceAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:toggleFeaturesAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Parameter \\#1 \\$array of function reset expects array\\|object, mixed given\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Parameter \\#1 \\$form of method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:handleCommandForm\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\|string, Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ToggleObjectFeaturesForm\\|null given\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Parameter \\#1 \\$title of method ipl\\\\Web\\\\Compat\\\\CompatController\\:\\:setTitle\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Parameter \\#2 \\$apiResult of class Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\HostInspectionDetail constructor expects array, mixed given\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Parameter \\#2 \\$default of method Icinga\\\\Web\\\\UrlParams\\:\\:shift\\(\\) expects string\\|null, int\\<1, max\\> given\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Parameter \\#2 \\$serviceSummary of class Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\HostDetail constructor expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicestateSummary, ipl\\\\Orm\\\\Model\\|null given\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of method Icinga\\\\Web\\\\Url\\:\\:setParam\\(\\) expects array\\|bool\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 4
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:lessThanOrEqual\\(\\) expects float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:\\$commandTargetModel \\(ipl\\\\Orm\\\\Model\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) does not accept array\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/HostController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostgroupController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostgroupController.php
+
+ -
+ message: "#^Parameter \\#1 \\$title of method ipl\\\\Web\\\\Compat\\\\CompatController\\:\\:setTitle\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/HostgroupController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: application/controllers/HostgroupController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostgroupController\\:\\:\\$hostgroup \\(Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroupsummary\\) does not accept ipl\\\\Orm\\\\Model\\.$#"
+ count: 1
+ path: application/controllers/HostgroupController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostgroupsController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostgroupsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostgroupsController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostgroupsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostgroupsController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostgroupsController.php
+
+ -
+ message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#"
+ count: 1
+ path: application/controllers/HostgroupsController.php
+
+ -
+ message: "#^Cannot access property \\$comments_total on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Cannot access property \\$downtimes_total on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Cannot call method first\\(\\) on ipl\\\\Orm\\\\Query\\|null\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:acknowledgeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:addCommentAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:assertIsGrantedOnCommandTargets\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:checkNowAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:detailsAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:getFeatureStatus\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:processCheckresultAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:removeAcknowledgementAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:scheduleCheckAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:scheduleDowntimeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:sendCustomNotificationAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:toggleFeaturesAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Parameter \\#1 \\$form of method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:handleCommandForm\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\|string, Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ToggleObjectFeaturesForm\\|null given\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Parameter \\#1 \\$query of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:filter\\(\\) expects ipl\\\\Orm\\\\Query, ipl\\\\Orm\\\\Query\\|null given\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Parameter \\#2 \\$summary of class Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail constructor expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HoststateSummary\\|Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicestateSummary, ipl\\\\Orm\\\\Model\\|null given\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$queries of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:export\\(\\) expects ipl\\\\Orm\\\\Query, ipl\\\\Orm\\\\Query\\|null given\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:\\$commandTargetModel \\(ipl\\\\Orm\\\\Model\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\HostsController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/HostsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\MigrateController\\:\\:backendSupportAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/MigrateController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\MigrateController\\:\\:checkboxStateAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/MigrateController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\MigrateController\\:\\:checkboxSubmitAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/MigrateController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\MigrateController\\:\\:monitoringUrlAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/MigrateController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\MigrateController\\:\\:searchUrlAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/MigrateController.php
+
+ -
+ message: "#^Parameter \\#1 \\$url of static method Icinga\\\\Web\\\\Url\\:\\:fromPath\\(\\) expects string, mixed given\\.$#"
+ count: 2
+ path: application/controllers/MigrateController.php
+
+ -
+ message: "#^Parameter \\#2 \\$subject of function preg_match expects string, string\\|false given\\.$#"
+ count: 3
+ path: application/controllers/MigrateController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\NotificationsController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/NotificationsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\NotificationsController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/NotificationsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\NotificationsController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/NotificationsController.php
+
+ -
+ message: "#^Parameter \\#2 \\$default of method Icinga\\\\Web\\\\UrlParams\\:\\:shift\\(\\) expects string\\|null, int\\<1, max\\> given\\.$#"
+ count: 1
+ path: application/controllers/NotificationsController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of method Icinga\\\\Web\\\\Url\\:\\:setParam\\(\\) expects array\\|bool\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/NotificationsController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:lessThanOrEqual\\(\\) expects float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/NotificationsController.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Cannot access property \\$is_overdue on mixed\\.$#"
+ count: 3
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:acknowledgeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:addCommentAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:assertIsGrantedOnCommandTargets\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:checkNowAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:createTabs\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:fetchCommandTargets\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:getDefaultTabControls\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:historyAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:processCheckresultAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:removeAcknowledgementAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:scheduleCheckAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:scheduleDowntimeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:sendCustomNotificationAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:setTitleTab\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:sourceAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:toggleFeaturesAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#1 \\$array of function reset expects array\\|object, mixed given\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#1 \\$form of method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:handleCommandForm\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\|string, Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ToggleObjectFeaturesForm\\|null given\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#2 \\$apiResult of class Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ServiceInspectionDetail constructor expects array, mixed given\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#2 \\$default of method Icinga\\\\Web\\\\UrlParams\\:\\:shift\\(\\) expects string\\|null, int\\<1, max\\> given\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#2 \\$host of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:service\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, mixed given\\.$#"
+ count: 2
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#2 \\$host of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:serviceSource\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, mixed given\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#2 \\$host of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ServiceLinks\\:\\:history\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, mixed given\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of method Icinga\\\\Web\\\\Url\\:\\:setParam\\(\\) expects array\\|bool\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 4
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:lessThanOrEqual\\(\\) expects float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:\\$commandTargetModel \\(ipl\\\\Orm\\\\Model\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) does not accept array\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServiceController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/ServiceController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicegroupController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicegroupController.php
+
+ -
+ message: "#^Parameter \\#1 \\$title of method ipl\\\\Web\\\\Compat\\\\CompatController\\:\\:setTitle\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/ServicegroupController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: application/controllers/ServicegroupController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicegroupController\\:\\:\\$servicegroup \\(Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupSummary\\) does not accept ipl\\\\Orm\\\\Model\\.$#"
+ count: 1
+ path: application/controllers/ServicegroupController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicegroupsController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicegroupsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicegroupsController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicegroupsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicegroupsController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicegroupsController.php
+
+ -
+ message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#"
+ count: 1
+ path: application/controllers/ServicegroupsController.php
+
+ -
+ message: "#^Call to an undefined method Zend_Controller_Action_Helper_Abstract\\:\\:getResponseSegment\\(\\)\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Call to an undefined method Zend_Controller_Action_Helper_Abstract\\:\\:setNoRender\\(\\)\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Call to an undefined method Zend_Controller_Action_Helper_Abstract\\:\\:setScriptAction\\(\\)\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Cannot access property \\$comments_total on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Cannot access property \\$downtimes_total on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Cannot call method first\\(\\) on ipl\\\\Orm\\\\Query\\|null\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:acknowledgeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:addCommentAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:assertIsGrantedOnCommandTargets\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:checkNowAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:detailsAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:getFeatureStatus\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:gridAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:gridSearchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:prepareSearchFilter\\(\\) has parameter \\$additionalColumns with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:processCheckresultAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:removeAcknowledgementAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:scheduleCheckAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:scheduleDowntimeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:sendCustomNotificationAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:toggleFeaturesAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Parameter \\#1 \\$form of method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:handleCommandForm\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\|string, Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ToggleObjectFeaturesForm\\|null given\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Parameter \\#1 \\$peekAhead of method ipl\\\\Orm\\\\Query\\:\\:peekAhead\\(\\) expects bool, null given\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Parameter \\#1 \\$query of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:filter\\(\\) expects ipl\\\\Orm\\\\Query, ipl\\\\Orm\\\\Query\\|null given\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Parameter \\#2 \\$default of method Icinga\\\\Web\\\\UrlParams\\:\\:shift\\(\\) expects string\\|null, false given\\.$#"
+ count: 2
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Parameter \\#2 \\$summary of class Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail constructor expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HoststateSummary\\|Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicestateSummary, ipl\\\\Orm\\\\Model\\|null given\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$queries of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:export\\(\\) expects ipl\\\\Orm\\\\Query, ipl\\\\Orm\\\\Query\\|null given\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:\\$commandTargetModel \\(ipl\\\\Orm\\\\Model\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\ServicesController\\:\\:\\$commandTargets \\(ipl\\\\Orm\\\\Query\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: application/controllers/ServicesController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\TacticalController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/TacticalController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\TacticalController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/TacticalController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\TacticalController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/TacticalController.php
+
+ -
+ message: "#^Parameter \\#1 \\$summary of class Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\HostSummaryDonut constructor expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HoststateSummary, ipl\\\\Orm\\\\Model\\|null given\\.$#"
+ count: 1
+ path: application/controllers/TacticalController.php
+
+ -
+ message: "#^Parameter \\#1 \\$summary of class Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ServiceSummaryDonut constructor expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicestateSummary, ipl\\\\Orm\\\\Model\\|null given\\.$#"
+ count: 1
+ path: application/controllers/TacticalController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UserController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UserController.php
+
+ -
+ message: "#^Parameter \\#1 \\$title of method ipl\\\\Web\\\\Compat\\\\CompatController\\:\\:setTitle\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/UserController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/UserController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UserController\\:\\:\\$user \\(Icinga\\\\Module\\\\Icingadb\\\\Model\\\\User\\) does not accept ipl\\\\Orm\\\\Model\\.$#"
+ count: 1
+ path: application/controllers/UserController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UsergroupController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsergroupController.php
+
+ -
+ message: "#^Parameter \\#1 \\$title of method ipl\\\\Web\\\\Compat\\\\CompatController\\:\\:setTitle\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/UsergroupController.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: application/controllers/UsergroupController.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UsergroupController\\:\\:\\$usergroup \\(Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Usergroup\\) does not accept ipl\\\\Orm\\\\Model\\.$#"
+ count: 1
+ path: application/controllers/UsergroupController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UsergroupsController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsergroupsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UsergroupsController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsergroupsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UsergroupsController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsergroupsController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UsersController\\:\\:completeAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsersController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UsersController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsersController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Controllers\\\\UsersController\\:\\:searchEditorAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/controllers/UsersController.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\ApiTransportForm\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/ApiTransportForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\:\\:filterGrantedOn\\(\\) has parameter \\$objects with no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\:\\:onSuccess\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$objects of method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\:\\:getCommands\\(\\) expects Traversable\\<mixed, ipl\\\\Orm\\\\Model\\>, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\CommandForm\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: application/forms/Command/CommandForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Instance\\\\ToggleInstanceFeaturesForm\\:\\:__construct\\(\\) has parameter \\$featureStatus with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Instance\\\\ToggleInstanceFeaturesForm\\:\\:\\$featureStatus has no type specified\\.$#"
+ count: 1
+ path: application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Instance\\\\ToggleInstanceFeaturesForm\\:\\:\\$features has no type specified\\.$#"
+ count: 1
+ path: application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 4
+ path: application/forms/Command/Object/AcknowledgeProblemForm.php
+
+ -
+ message: "#^Cannot call method getUsername\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/AcknowledgeProblemForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$comment of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\WithCommentCommand\\:\\:setComment\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/AcknowledgeProblemForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$duration of class DateInterval constructor expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/AcknowledgeProblemForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$iterable of function ipl\\\\Stdlib\\\\iterable_value_first expects iterable, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/AcknowledgeProblemForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/AcknowledgeProblemForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: application/forms/Command/Object/AddCommentForm.php
+
+ -
+ message: "#^Cannot call method getUsername\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/AddCommentForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$comment of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\WithCommentCommand\\:\\:setComment\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/AddCommentForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$duration of class DateInterval constructor expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/AddCommentForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$iterable of function ipl\\\\Stdlib\\\\iterable_value_first expects iterable, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/AddCommentForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/AddCommentForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/CheckNowForm.php
+
+ -
+ message: "#^Cannot call method getUsername\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/DeleteCommentForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/DeleteCommentForm.php
+
+ -
+ message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: application/forms/Command/Object/DeleteDowntimeForm.php
+
+ -
+ message: "#^Cannot access property \\$scheduled_by on mixed\\.$#"
+ count: 1
+ path: application/forms/Command/Object/DeleteDowntimeForm.php
+
+ -
+ message: "#^Cannot call method getUsername\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/DeleteDowntimeForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/DeleteDowntimeForm.php
+
+ -
+ message: "#^Cannot access property \\$passive_checks_enabled on mixed\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ProcessCheckResultForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$iterable of function ipl\\\\Stdlib\\\\iterable_value_first expects iterable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/ProcessCheckResultForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$objects of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\ObjectsCommand\\:\\:setObjects\\(\\) expects Traversable\\<mixed, ipl\\\\Orm\\\\Model\\>, Generator\\<int, mixed, mixed, void\\> given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ProcessCheckResultForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$output of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\ProcessCheckResultCommand\\:\\:setOutput\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ProcessCheckResultForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$performanceData of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\ProcessCheckResultCommand\\:\\:setPerformanceData\\(\\) expects string\\|null, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ProcessCheckResultForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$status of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\ProcessCheckResultCommand\\:\\:setStatus\\(\\) expects int, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ProcessCheckResultForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/ProcessCheckResultForm.php
+
+ -
+ message: "#^Cannot call method getUsername\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/RemoveAcknowledgementForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$iterable of function ipl\\\\Stdlib\\\\iterable_value_first expects iterable, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/RemoveAcknowledgementForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/RemoveAcknowledgementForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleCheckForm.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleCheckForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$iterable of function ipl\\\\Stdlib\\\\iterable_value_first expects iterable, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleCheckForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/ScheduleCheckForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 2
+ path: application/forms/Command/Object/ScheduleHostDowntimeForm.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 2
+ path: application/forms/Command/Object/ScheduleHostDowntimeForm.php
+
+ -
+ message: "#^Cannot call method getUsername\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleHostDowntimeForm.php
+
+ -
+ message: "#^Cannot cast mixed to int\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleHostDowntimeForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$comment of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\WithCommentCommand\\:\\:setComment\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleHostDowntimeForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$duration of class DateInterval constructor expects string, mixed given\\.$#"
+ count: 3
+ path: application/forms/Command/Object/ScheduleHostDowntimeForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleHostDowntimeForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleServiceDowntimeForm.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 2
+ path: application/forms/Command/Object/ScheduleServiceDowntimeForm.php
+
+ -
+ message: "#^Cannot call method getUsername\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleServiceDowntimeForm.php
+
+ -
+ message: "#^Cannot call method on\\(\\) on ipl\\\\Html\\\\Contract\\\\Wrappable\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleServiceDowntimeForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$comment of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\WithCommentCommand\\:\\:setComment\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleServiceDowntimeForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$duration of class DateInterval constructor expects string, mixed given\\.$#"
+ count: 3
+ path: application/forms/Command/Object/ScheduleServiceDowntimeForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/ScheduleServiceDowntimeForm.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ScheduleServiceDowntimeForm\\:\\:\\$flexibleEnd has no type specified\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ScheduleServiceDowntimeForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: application/forms/Command/Object/SendCustomNotificationForm.php
+
+ -
+ message: "#^Cannot call method getUsername\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/SendCustomNotificationForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$comment of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\WithCommentCommand\\:\\:setComment\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/SendCustomNotificationForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$iterable of function ipl\\\\Stdlib\\\\iterable_value_first expects iterable, mixed given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/SendCustomNotificationForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
+ count: 2
+ path: application/forms/Command/Object/SendCustomNotificationForm.php
+
+ -
+ message: "#^Cannot call method getAttributes\\(\\) on ipl\\\\Html\\\\Contract\\\\Wrappable\\|null\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ToggleObjectFeaturesForm.php
+
+ -
+ message: "#^Cannot cast mixed to int\\.$#"
+ count: 2
+ path: application/forms/Command/Object/ToggleObjectFeaturesForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ToggleObjectFeaturesForm\\:\\:__construct\\(\\) has parameter \\$featureStatus with no type specified\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ToggleObjectFeaturesForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$enabled of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\ToggleObjectFeatureCommand\\:\\:setEnabled\\(\\) expects bool, int given\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ToggleObjectFeaturesForm.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ToggleObjectFeaturesForm\\:\\:\\$featureStatus has no type specified\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ToggleObjectFeaturesForm.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Command\\\\Object\\\\ToggleObjectFeaturesForm\\:\\:\\$features has no type specified\\.$#"
+ count: 1
+ path: application/forms/Command/Object/ToggleObjectFeaturesForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\DatabaseConfigForm\\:\\:createElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/DatabaseConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\DatabaseConfigForm\\:\\:createElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/DatabaseConfigForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Cannot call method addError\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Navigation\\\\ActionForm\\:\\:createElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Navigation\\\\ActionForm\\:\\:createElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Navigation\\\\ActionForm\\:\\:isValid\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Navigation\\\\ActionForm\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Navigation\\\\ActionForm\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Navigation\\\\ActionForm\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\Navigation\\\\ActionForm\\:\\:parseRestriction\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: application/forms/Navigation/ActionForm.php
+
+ -
+ message: "#^Call to an undefined method Zend_Form_Element\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Call to an undefined method object\\:\\:getMessages\\(\\)\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Call to an undefined method object\\:\\:isValid\\(\\)\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Cannot call method addError\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 3
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Cannot call method getElements\\(\\) on Zend_Form_DisplayGroup\\|null\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Cannot call method getValidator\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Cannot call method getValue\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 3
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Cannot call method isChecked\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 5
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Cannot call method setDecorators\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Cannot call method setElements\\(\\) on Zend_Form_DisplayGroup\\|null\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Cannot call method setValue\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 11
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\RedisConfigForm\\:\\:addInsecureCheckboxIfTls\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\RedisConfigForm\\:\\:addSkipValidationCheckbox\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\RedisConfigForm\\:\\:createElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\RedisConfigForm\\:\\:createElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\RedisConfigForm\\:\\:isValid\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\RedisConfigForm\\:\\:isValidPartial\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\RedisConfigForm\\:\\:onRequest\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\RedisConfigForm\\:\\:wrapIplValidator\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$filename of function file_exists expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$filename of function file_get_contents expects string, mixed given\\.$#"
+ count: 2
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$path of function basename expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function md5 expects string, mixed given\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: application/forms/RedisConfigForm.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: application/forms/SetAsBackendForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\SetAsBackendForm\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/SetAsBackendForm.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Forms\\\\SetAsBackendForm\\:\\:onSuccess\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: application/forms/SetAsBackendForm.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Cannot access property \\$id on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 3
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Authentication\\\\ObjectAuthorization\\:\\:checkGrants\\(\\) has parameter \\$roles with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Authentication\\\\ObjectAuthorization\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Parameter \\#1 \\$column of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects string, array\\<string\\>\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 2
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Authentication\\\\ObjectAuthorization\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Authentication\\\\ObjectAuthorization\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 6
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Authentication\\\\ObjectAuthorization\\:\\:\\$knownGrants type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Authentication\\\\ObjectAuthorization\\:\\:\\$matchedFilters type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Authentication/ObjectAuthorization.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\IcingaApiCommand\\:\\:create\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/IcingaApiCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\IcingaApiCommand\\:\\:getData\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/IcingaApiCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\IcingaApiCommand\\:\\:setData\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/IcingaApiCommand.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Command\\\\IcingaApiCommand\\:\\:\\$data type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/IcingaApiCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\GetObjectCommand\\:\\:getAttributes\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Object/GetObjectCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\GetObjectCommand\\:\\:setAttributes\\(\\) has parameter \\$attributes with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Object/GetObjectCommand.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\GetObjectCommand\\:\\:\\$attributes type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Object/GetObjectCommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\ObjectsCommand\\:\\:getObjects\\(\\) return type has no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Object/ObjectsCommand.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Object\\\\ProcessCheckResultCommand\\:\\:\\$performanceData \\(string\\) does not accept string\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Object/ProcessCheckResultCommand.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Renderer\\\\IcingaApiCommandRenderer\\:\\:applyFilter\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php
+
+ -
+ message: "#^Parameter \\#1 \\$object of method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Renderer\\\\IcingaApiCommandRenderer\\:\\:getObjectPluralType\\(\\) expects ipl\\\\Orm\\\\Model, ipl\\\\Orm\\\\Model\\|null given\\.$#"
+ count: 2
+ path: library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php
+
+ -
+ message: "#^Parameter \\#3 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php
+
+ -
+ message: "#^Cannot access offset 'code' on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Transport/ApiCommandTransport.php
+
+ -
+ message: "#^Cannot access offset 'error' on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Transport/ApiCommandTransport.php
+
+ -
+ message: "#^Cannot access offset 'results' on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Command/Transport/ApiCommandTransport.php
+
+ -
+ message: "#^Cannot access offset 'status' on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Command/Transport/ApiCommandTransport.php
+
+ -
+ message: "#^Cannot access offset 'user' on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Transport/ApiCommandTransport.php
+
+ -
+ message: "#^Cannot access offset 0 on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Transport/ApiCommandTransport.php
+
+ -
+ message: "#^Parameter \\#1 \\$array of function array_pop expects array, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Transport/ApiCommandTransport.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function strtolower expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Transport/CommandTransport.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Transport\\\\CommandTransportConfig\\:\\:\\$configs type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Transport/CommandTransportConfig.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Transport\\\\CommandTransportConfig\\:\\:\\$queryColumns type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Transport/CommandTransportConfig.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Command\\\\Transport\\\\CommandTransportInterface\\:\\:send\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Command/Transport/CommandTransportInterface.php
+
+ -
+ message: "#^Cannot access offset 'in_downtime' on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Cannot access offset 'is_acknowledged' on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Cannot access offset 'state_type' on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\IcingaRedis\\:\\:fetchHostState\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\IcingaRedis\\:\\:fetchHostState\\(\\) has parameter \\$ids with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\IcingaRedis\\:\\:fetchServiceState\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\IcingaRedis\\:\\:fetchServiceState\\(\\) has parameter \\$ids with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\IcingaRedis\\:\\:fetchState\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\IcingaRedis\\:\\:fetchState\\(\\) has parameter \\$ids with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\IcingaRedis\\:\\:getTlsParams\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Common/IcingaRedis.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:hostgroup\\(\\) has parameter \\$hostgroup with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Common/Links.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:servicegroup\\(\\) has parameter \\$servicegroup with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Common/Links.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function bin2hex expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Common/Links.php
+
+ -
+ message: "#^Call to an undefined method Predis\\\\Client\\:\\:hGet\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Cannot call method columns\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Cannot call method setTimezone\\(\\) on DateTime\\|false\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:__construct\\(\\) has parameter \\$apiResult with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:createAttributes\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:createCustomVariables\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:createLastCheckResult\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:createLastCheckResult\\(\\) should return array\\|null but empty return statement found\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:createNameValueTable\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:createNameValueTable\\(\\) has parameter \\$formatters with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:createRedisInfo\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:createSourceLocation\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:createSourceLocation\\(\\) should return array\\|null but empty return statement found\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:formatMilliseconds\\(\\) has parameter \\$ms with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:formatSeconds\\(\\) has parameter \\$s with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:formatState\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$array of function array_diff_key expects array, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Table\\:\\:td\\(\\) expects array\\|ipl\\\\Html\\\\Html\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function bin2hex expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:\\$attrs type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ObjectInspectionDetail\\:\\:\\$joins type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Common/ObjectInspectionDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$rule of method ipl\\\\Stdlib\\\\Filter\\\\Chain\\:\\:add\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Common/StateBadges.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$customvar on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$hostgroup on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 5
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$services_critical_handled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$services_critical_unhandled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$services_ok on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$services_pending on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$services_total on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$services_unknown_handled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$services_unknown_unhandled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$services_warning_handled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot access property \\$services_warning_unhandled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot call method columns\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot call method execute\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot call method filter\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Cannot clone mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:__get\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:__get\\(\\) has parameter \\$name with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:__isset\\(\\) has parameter \\$name with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:fromModel\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:getBoolType\\(\\) never returns null so it can be removed from the return type\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:getName\\(\\) should return string but returns mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Parameter \\#1 \\$flattenedVars of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CustomvarFlat\\:\\:unFlattenVars\\(\\) expects Traversable, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 3
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:\\$legacyColumns has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:\\$rawCustomvars type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost\\:\\:\\$rawHostCustomvars type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Monitoring\\\\Object\\\\MonitoredObject\\:\\:\\$eventhistory \\(Icinga\\\\Module\\\\Monitoring\\\\DataView\\\\EventHistory\\) does not accept array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Monitoring\\\\Object\\\\MonitoredObject\\:\\:\\$hostVariables \\(array\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Monitoring\\\\Object\\\\MonitoredObject\\:\\:\\$serviceVariables \\(array\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Unreachable statement \\- code above always terminates\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatHost.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$customvar on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$hostgroup on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 5
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$services_critical_handled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$services_critical_unhandled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$services_ok on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$services_pending on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$services_total on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$services_unknown_handled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$services_unknown_unhandled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$services_warning_handled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot access property \\$services_warning_unhandled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot call method columns\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot call method execute\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot call method filter\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Cannot clone mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:__get\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:__get\\(\\) has parameter \\$name with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:__isset\\(\\) has parameter \\$name with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:fetchHost\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:fromModel\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:getBoolType\\(\\) never returns null so it can be removed from the return type\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:getHost\\(\\) should return Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost but returns Icinga\\\\Module\\\\Monitoring\\\\Object\\\\Host\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:getName\\(\\) should return string but returns mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Parameter \\#1 \\$flattenedVars of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CustomvarFlat\\:\\:unFlattenVars\\(\\) expects Traversable, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Parameter \\#1 \\$object of class Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatHost constructor expects ipl\\\\Orm\\\\Model, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 3
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:\\$legacyColumns has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:\\$rawCustomvars type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\CompatService\\:\\:\\$rawHostCustomvars type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Monitoring\\\\Object\\\\MonitoredObject\\:\\:\\$eventhistory \\(Icinga\\\\Module\\\\Monitoring\\\\DataView\\\\EventHistory\\) does not accept array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Monitoring\\\\Object\\\\MonitoredObject\\:\\:\\$hostVariables \\(array\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Monitoring\\\\Object\\\\MonitoredObject\\:\\:\\$serviceVariables \\(array\\) in isset\\(\\) is not nullable\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Unreachable statement \\- code above always terminates\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/CompatService.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:commentsColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:commonColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:commonParameters\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:contactgroupsColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:contactsColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:downtimesColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:historyColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:hostColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:hostgroupsColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:hostsColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:hostsParameters\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:multipleHostsColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:multipleServicesColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:notificationHistoryColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:rewrite\\(\\) has parameter \\$legacyColumns with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:serviceColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:servicegridColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:servicegroupsColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:servicesColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:servicesParameters\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:transformParams\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Compat\\\\UrlMigrator\\:\\:transformWildcardFilter\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Parameter \\#1 \\$column of method ipl\\\\Stdlib\\\\Filter\\\\Condition\\:\\:setColumn\\(\\) expects string, int\\|string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function strtolower expects string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Compat/UrlMigrator.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\CsvResultSet\\:\\:extractKeysAndValues\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Data/CsvResultSet.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\CsvResultSet\\:\\:formatValue\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Data/CsvResultSet.php
+
+ -
+ message: "#^Parameter \\#1 \\$array of function array_keys expects array, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Data/CsvResultSet.php
+
+ -
+ message: "#^Parameter \\#1 \\$array of function array_values expects array, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Data/CsvResultSet.php
+
+ -
+ message: "#^Parameter \\#1 \\$key of method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\CsvResultSet\\:\\:formatValue\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Data/CsvResultSet.php
+
+ -
+ message: "#^Parameter \\#1 \\$model of method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\CsvResultSet\\:\\:extractKeysAndValues\\(\\) expects ipl\\\\Orm\\\\Model, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Data/CsvResultSet.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\JsonResultSet\\:\\:createObject\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Data/JsonResultSet.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\JsonResultSet\\:\\:formatValue\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Data/JsonResultSet.php
+
+ -
+ message: "#^Parameter \\#1 \\$key of method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\JsonResultSet\\:\\:formatValue\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Data/JsonResultSet.php
+
+ -
+ message: "#^Parameter \\#1 \\$model of method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\JsonResultSet\\:\\:createObject\\(\\) expects ipl\\\\Orm\\\\Model, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Data/JsonResultSet.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Data/PivotTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\PivotTable\\:\\:__construct\\(\\) has parameter \\$gridcols with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Data/PivotTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Data\\\\PivotTable\\:\\:toArray\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Data/PivotTable.php
+
+ -
+ message: "#^Parameter \\#1 \\$haystack of function strpos expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Data/PivotTable.php
+
+ -
+ message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Data/PivotTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Data\\\\PivotTable\\:\\:\\$gridcols type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Data/PivotTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Data\\\\PivotTable\\:\\:\\$order type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Data/PivotTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Data\\\\PivotTable\\:\\:\\$xAxisFilter \\(ipl\\\\Stdlib\\\\Filter\\\\Rule\\) does not accept ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Data/PivotTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Data\\\\PivotTable\\:\\:\\$yAxisFilter \\(ipl\\\\Stdlib\\\\Filter\\\\Rule\\) does not accept ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Data/PivotTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\ActionsHook\\\\ObjectActionsHook\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\CustomVarRendererHook\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/CustomVarRendererHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\ExtensionHook\\\\BaseExtensionHook\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\ExtensionHook\\\\BaseExtensionHook\\:\\:injectExtensions\\(\\) has parameter \\$coreElements with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\ExtensionHook\\\\BaseExtensionHook\\:\\:injectExtensions\\(\\) has parameter \\$extensions with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\ExtensionHook\\\\BaseExtensionHook\\:\\:injectExtensions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\ExtensionHook\\\\ObjectDetailExtensionHook\\:\\:loadExtensions\\(\\) should return array\\<int, ipl\\\\Html\\\\ValidHtml\\> but returns array\\<int\\|string, ipl\\\\Html\\\\HtmlElement\\|ipl\\\\Html\\\\Text\\>\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
+
+ -
+ message: "#^PHPDoc tag @var has invalid value \\(\\$hook EventDetailExtensionHook\\)\\: Unexpected token \"\\$hook\", expected type at offset 212$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
+
+ -
+ message: "#^PHPDoc tag @var has invalid value \\(\\$hook HostDetailExtensionHook\\)\\: Unexpected token \"\\$hook\", expected type at offset 20$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
+
+ -
+ message: "#^PHPDoc tag @var has invalid value \\(\\$hook ServiceDetailExtensionHook\\)\\: Unexpected token \"\\$hook\", expected type at offset 66$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
+
+ -
+ message: "#^PHPDoc tag @var has invalid value \\(\\$hook UserDetailExtensionHook\\)\\: Unexpected token \"\\$hook\", expected type at offset 115$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
+
+ -
+ message: "#^PHPDoc tag @var has invalid value \\(\\$hook UsergroupDetailExtensionHook\\)\\: Unexpected token \"\\$hook\", expected type at offset 161$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
+
+ -
+ message: "#^Cannot cast ipl\\\\Html\\\\ValidHtml to string\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\IcingadbSupportHook\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/IcingadbSupportHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\PluginOutputHook\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/PluginOutputHook.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\TabHook\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\TabHook\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\TabHook\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\TabHook\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Hook/TabHook.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\AcknowledgementHistory\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/AcknowledgementHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\AcknowledgementHistory\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/AcknowledgementHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\AcknowledgementHistory\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/AcknowledgementHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ActionUrl\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ActionUrl.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ActionUrl\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ActionUrl.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ActionUrl\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ActionUrl.php
+
+ -
+ message: "#^Parameter \\#1 \\$haystack of function strpos expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ActionAndNoteUrl.php
+
+ -
+ message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ActionAndNoteUrl.php
+
+ -
+ message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/Bitmask.php
+
+ -
+ message: "#^Cannot access offset mixed on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Behavior/Bitmask.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Behavior\\\\Bitmask\\:\\:rewriteCondition\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null but empty return statement found\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Behavior/Bitmask.php
+
+ -
+ message: "#^Cannot cast mixed to string\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/BoolCast.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Behavior\\\\FlattenedObjectVars\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Behavior\\\\FlattenedObjectVars\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Behavior\\\\FlattenedObjectVars\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#2 \\$targetPath of method ipl\\\\Orm\\\\Query\\:\\:createSubQuery\\(\\) expects string, string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:like\\(\\) expects array\\<string\\>\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Part \\$column \\(mixed\\) of encapsed string cannot be cast to string\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/FlattenedObjectVars.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Behavior\\\\ReRoute\\:\\:__construct\\(\\) has parameter \\$routes with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ReRoute.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Behavior\\\\ReRoute\\:\\:getRoutes\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ReRoute.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Behavior\\\\ReRoute\\:\\:rewriteCondition\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null but empty return statement found\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ReRoute.php
+
+ -
+ message: "#^Parameter \\#1 \\$haystack of function strpos expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ReRoute.php
+
+ -
+ message: "#^Parameter \\#1 \\$path of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Behavior\\\\ReRoute\\:\\:rewritePath\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ReRoute.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function substr expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ReRoute.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ReRoute.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Behavior\\\\ReRoute\\:\\:\\$routes has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Behavior/ReRoute.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Checkcommand\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Checkcommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Checkcommand\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Checkcommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Checkcommand\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Checkcommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CheckcommandArgument\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CheckcommandArgument.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CheckcommandArgument\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CheckcommandArgument.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CheckcommandArgument\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CheckcommandArgument.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CheckcommandCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CheckcommandCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CheckcommandCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CheckcommandCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CheckcommandEnvvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CheckcommandEnvvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CheckcommandEnvvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CheckcommandEnvvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CheckcommandEnvvar\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CheckcommandEnvvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Comment\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Comment.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Comment\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Comment.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Comment\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Comment.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Comment\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Comment.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Comment\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Comment.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CommentHistory\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CommentHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CommentHistory\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CommentHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CommentHistory\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CommentHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Customvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Customvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Customvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Customvar.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$flatname\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CustomvarFlat.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$value\\.$#"
+ count: 3
+ path: library/Icingadb/Model/CustomvarFlat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CustomvarFlat\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CustomvarFlat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CustomvarFlat\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CustomvarFlat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CustomvarFlat\\:\\:unFlattenVars\\(\\) has parameter \\$flattenedVars with no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CustomvarFlat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CustomvarFlat\\:\\:unFlattenVars\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CustomvarFlat.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, array\\<int, string\\>\\|false given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/CustomvarFlat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Downtime\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Downtime.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Downtime\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Downtime.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Downtime\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Downtime.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Downtime\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Downtime.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Downtime\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Downtime.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\DowntimeHistory\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/DowntimeHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\DowntimeHistory\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/DowntimeHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\DowntimeHistory\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/DowntimeHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Endpoint\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Endpoint.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Endpoint\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Endpoint.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Endpoint\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Endpoint.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Environment\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Environment.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Environment\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Environment.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Environment\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Environment.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Eventcommand\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Eventcommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Eventcommand\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Eventcommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Eventcommand\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Eventcommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\EventcommandArgument\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/EventcommandArgument.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\EventcommandArgument\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/EventcommandArgument.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\EventcommandArgument\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/EventcommandArgument.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\EventcommandCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/EventcommandCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\EventcommandCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/EventcommandCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\EventcommandEnvvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/EventcommandEnvvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\EventcommandEnvvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/EventcommandEnvvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\EventcommandEnvvar\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/EventcommandEnvvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\FlappingHistory\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/FlappingHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\FlappingHistory\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/FlappingHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\FlappingHistory\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/FlappingHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\History\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/History.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\History\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/History.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\History\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/History.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\History\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/History.php
+
+ -
+ message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Cannot access property \\$flatname on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Cannot access property \\$flatvalue on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Cannot access property \\$value on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:createDefaults\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:createDefaults\\(\\) has parameter \\$defaults with no value type specified in iterable type ipl\\\\Orm\\\\Defaults\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Parameter \\#1 \\$query of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:applyRestrictions\\(\\) expects ipl\\\\Orm\\\\Query, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Host.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HostCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HostCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HostCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HostCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HostState\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HostState.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HostState\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HostState.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroup\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroup\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroup\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroup\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroup\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HostgroupCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HostgroupCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HostgroupCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HostgroupCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HostgroupMember\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HostgroupMember.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HostgroupMember\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HostgroupMember.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroupsummary\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroupsummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroupsummary\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroupsummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroupsummary\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroupsummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroupsummary\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroupsummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroupsummary\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroupsummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Hostgroupsummary\\:\\:getUnions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Hostgroupsummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HoststateSummary\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HoststateSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HoststateSummary\\:\\:getDefaultSort\\(\\) should return array\\|string but returns null\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HoststateSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\HoststateSummary\\:\\:getSummaryColumns\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/HoststateSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\IconImage\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/IconImage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\IconImage\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/IconImage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\IconImage\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/IconImage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Instance\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Instance.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Instance\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Instance.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Instance\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Instance.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotesUrl\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotesUrl.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotesUrl\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotesUrl.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotesUrl\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotesUrl.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Notification\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Notification.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Notification\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Notification.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Notification\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Notification.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationHistory\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationHistory\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationHistory\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationHistory\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationHistory\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationUser\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationUser.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationUser\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationUser.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationUsergroup\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationUsergroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationUsergroup\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationUsergroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Notificationcommand\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Notificationcommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Notificationcommand\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Notificationcommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Notificationcommand\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Notificationcommand.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationcommandArgument\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationcommandArgument.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationcommandArgument\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationcommandArgument.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationcommandArgument\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationcommandArgument.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationcommandCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationcommandCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationcommandCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationcommandCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationcommandEnvvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationcommandEnvvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationcommandEnvvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationcommandEnvvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationcommandEnvvar\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/NotificationcommandEnvvar.php
+
+ -
+ message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Cannot access property \\$flatname on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Cannot access property \\$flatvalue on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Cannot access property \\$value on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:createDefaults\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:createDefaults\\(\\) has parameter \\$defaults with no value type specified in iterable type ipl\\\\Orm\\\\Defaults\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Parameter \\#1 \\$query of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:applyRestrictions\\(\\) expects ipl\\\\Orm\\\\Query, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Model/Service.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServiceCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServiceCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServiceCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServiceCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServiceState\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServiceState.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServiceState\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServiceState.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Servicegroup\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Servicegroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Servicegroup\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Servicegroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Servicegroup\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Servicegroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Servicegroup\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Servicegroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Servicegroup\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Servicegroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupMember\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupMember.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupMember\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupMember.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupSummary\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupSummary\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupSummary\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupSummary\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupSummary\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicegroupSummary\\:\\:getUnions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicegroupSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicestateSummary\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicestateSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicestateSummary\\:\\:getDefaultSort\\(\\) should return array\\|string but returns null\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicestateSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicestateSummary\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicestateSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\ServicestateSummary\\:\\:getSummaryColumns\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/ServicestateSummary.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\State\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/State.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\StateHistory\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/StateHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\StateHistory\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/StateHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\StateHistory\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/StateHistory.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Timeperiod\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Timeperiod.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Timeperiod\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Timeperiod.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Timeperiod\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Timeperiod.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\TimeperiodCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/TimeperiodCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\TimeperiodCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/TimeperiodCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\TimeperiodOverrideExclude\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/TimeperiodOverrideExclude.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\TimeperiodOverrideExclude\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/TimeperiodOverrideExclude.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\TimeperiodOverrideInclude\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/TimeperiodOverrideInclude.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\TimeperiodOverrideInclude\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/TimeperiodOverrideInclude.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\TimeperiodRange\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/TimeperiodRange.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\TimeperiodRange\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/TimeperiodRange.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\TimeperiodRange\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/TimeperiodRange.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\User\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/User.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\User\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/User.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\User\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/User.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\User\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/User.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\User\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/User.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\UserCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/UserCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\UserCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/UserCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Usergroup\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Usergroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Usergroup\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Usergroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Usergroup\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Usergroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Usergroup\\:\\:getDefaultSort\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Usergroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Usergroup\\:\\:getSearchColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Usergroup.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\UsergroupCustomvar\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/UsergroupCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\UsergroupCustomvar\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/UsergroupCustomvar.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\UsergroupMember\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/UsergroupMember.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\UsergroupMember\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/UsergroupMember.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Vars\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Vars.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Zone\\:\\:createBehaviors\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Zone.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Zone\\:\\:createRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Zone.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Zone\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Model/Zone.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/ApplicationState.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/ProvidedHook/ApplicationState.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\ApplicationState\\:\\:collectMessages\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/ApplicationState.php
+
+ -
+ message: "#^Parameter \\#2 \\$timestamp of method Icinga\\\\Application\\\\Hook\\\\ApplicationStateHook\\:\\:addError\\(\\) expects int, mixed given\\.$#"
+ count: 3
+ path: library/Icingadb/ProvidedHook/ApplicationState.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/CreateHostSlaReport.php
+
+ -
+ message: "#^Parameter \\#1 \\$rule of static method ipl\\\\Web\\\\Filter\\\\QueryString\\:\\:render\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/CreateHostsSlaReport.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/CreateServiceSlaReport.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/CreateServiceSlaReport.php
+
+ -
+ message: "#^Parameter \\#1 \\$rule of static method ipl\\\\Web\\\\Filter\\\\QueryString\\:\\:render\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/CreateServicesSlaReport.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/IcingaHealth.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/IcingaHealth.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/ProvidedHook/IcingaHealth.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\IcingaHealth\\:\\:getInstance\\(\\) should return Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Instance\\|null but returns ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/IcingaHealth.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\IcingaHealth\\:\\:\\$instance \\(Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Instance\\) does not accept ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/IcingaHealth.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/RedisHealth.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/ProvidedHook/RedisHealth.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php
+
+ -
+ message: "#^Cannot access property \\$sla on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\HostSlaReport\\:\\:fetchSla\\(\\) return type has no value type specified in iterable type iterable\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php
+
+ -
+ message: "#^Cannot access property \\$host on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php
+
+ -
+ message: "#^Cannot access property \\$sla on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\ServiceSlaReport\\:\\:fetchSla\\(\\) return type has no value type specified in iterable type iterable\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Cannot call method count\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Cannot call method format\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Cannot call method getAverages\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Cannot call method getDimensions\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Cannot call method getRows\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Cannot call method getValues\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:fetchReportData\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:fetchReportData\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:fetchSla\\(\\) return type has no value type specified in iterable type iterable\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:getData\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:getHtml\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:initConfigForm\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Offset 'filter' does not exist on array\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#1 \\$start of class Icinga\\\\Module\\\\Reporting\\\\Timerange constructor expects DateTime, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#2 \\$end of class Icinga\\\\Module\\\\Reporting\\\\Timerange constructor expects DateTime, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#2 \\$interval of method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\SlaReport\\:\\:yieldTimerange\\(\\) expects DateInterval, DateInterval\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/ProvidedHook/Reporting/SlaReport.php
+
+ -
+ message: "#^Cannot call method count\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php
+
+ -
+ message: "#^Cannot call method getAverages\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php
+
+ -
+ message: "#^Cannot call method getDimensions\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\TotalHostSlaReport\\:\\:getHtml\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php
+
+ -
+ message: "#^Cannot call method count\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php
+
+ -
+ message: "#^Cannot call method getAverages\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php
+
+ -
+ message: "#^Cannot call method getDimensions\\(\\) on Icinga\\\\Module\\\\Reporting\\\\ReportData\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\Reporting\\\\TotalServiceSlaReport\\:\\:getHtml\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\X509\\\\Sni\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\X509\\\\Sni\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\ProvidedHook\\\\X509\\\\Sni\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/ProvidedHook/X509/Sni.php
+
+ -
+ message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
+ count: 2
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Cannot access offset mixed on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Cannot access property \\$host on mixed\\.$#"
+ count: 4
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Cannot access property \\$id on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Cannot access property \\$state on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Cannot call method getColumns\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Cannot call method retrieveProperty\\(\\) on ipl\\\\Orm\\\\Behaviors\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Cannot call method setProperties\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Redis\\\\VolatileStateResults\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Redis\\\\VolatileStateResults\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Redis\\\\VolatileStateResults\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function bin2hex expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Redis/VolatileStateResults.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\ApiTransportPage\\:\\:addSkipValidationCheckbox\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/ApiTransportPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\ApiTransportPage\\:\\:createElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/ApiTransportPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\ApiTransportPage\\:\\:createElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/ApiTransportPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\ApiTransportPage\\:\\:isValid\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/ApiTransportPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\ApiTransportPage\\:\\:isValidPartial\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/ApiTransportPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\ApiTransportStep\\:\\:__construct\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/ApiTransportStep.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\ApiTransportStep\\:\\:getReport\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/ApiTransportStep.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\ApiTransportStep\\:\\:\\$data type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/ApiTransportStep.php
+
+ -
+ message: "#^Cannot call method setMultiOptions\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/DbResourcePage.php
+
+ -
+ message: "#^Cannot call method setValue\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Setup/DbResourcePage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\DbResourcePage\\:\\:addSkipValidationCheckbox\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/DbResourcePage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\DbResourcePage\\:\\:createElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/DbResourcePage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\DbResourcePage\\:\\:createElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/DbResourcePage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\DbResourcePage\\:\\:isValid\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/DbResourcePage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\DbResourcePage\\:\\:isValidPartial\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/DbResourcePage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\DbResourceStep\\:\\:__construct\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/DbResourceStep.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\DbResourceStep\\:\\:getReport\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/DbResourceStep.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\DbResourceStep\\:\\:\\$data type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/DbResourceStep.php
+
+ -
+ message: "#^Call to an undefined method Icinga\\\\Web\\\\Form\\:\\:setSubjectTitle\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/IcingaDbWizard.php
+
+ -
+ message: "#^Call to an undefined method Icinga\\\\Web\\\\Form\\:\\:setSummary\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/IcingaDbWizard.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\IcingaDbWizard\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/IcingaDbWizard.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\IcingaDbWizard\\:\\:setupPage\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/IcingaDbWizard.php
+
+ -
+ message: "#^Call to an undefined method Zend_Form_Element\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/RedisPage.php
+
+ -
+ message: "#^Cannot call method setIgnore\\(\\) on Zend_Form_Element\\|null\\.$#"
+ count: 3
+ path: library/Icingadb/Setup/RedisPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\RedisPage\\:\\:createElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/RedisPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\RedisPage\\:\\:createElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/RedisPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\RedisPage\\:\\:isValid\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/RedisPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\RedisPage\\:\\:isValidPartial\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/RedisPage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\RedisStep\\:\\:__construct\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/RedisStep.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\RedisStep\\:\\:getReport\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/RedisStep.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\RedisStep\\:\\:\\$data type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/RedisStep.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\WelcomePage\\:\\:createElements\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/WelcomePage.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Setup\\\\WelcomePage\\:\\:createElements\\(\\) has parameter \\$formData with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Setup/WelcomePage.php
+
+ -
+ message: "#^Class Icinga\\\\Module\\\\Icingadb\\\\Util\\\\FeatureStatus extends generic class ArrayObject but does not specify its types\\: TKey, TValue$#"
+ count: 1
+ path: library/Icingadb/Util/FeatureStatus.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\FeatureStatus\\:\\:__construct\\(\\) has parameter \\$summary with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/FeatureStatus.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\FeatureStatus\\:\\:getFeatureStatus\\(\\) has parameter \\$summary with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/FeatureStatus.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfData\\:\\:calculatePieChartData\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfData.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfData\\:\\:format\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfData.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfData\\:\\:format\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfData.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfData\\:\\:parse\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfData.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfData\\:\\:toArray\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfData.php
+
+ -
+ message: "#^Parameter \\#1 \\$num of function number_format expects float, float\\|int\\|string given\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfData.php
+
+ -
+ message: "#^Parameter \\#1 \\$value of method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\ThresholdRange\\:\\:contains\\(\\) expects float, float\\|null given\\.$#"
+ count: 2
+ path: library/Icingadb/Util/PerfData.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:ampereSeconds\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:amperes\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:bits\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:bytes\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:formatForUnits\\(\\) has parameter \\$units with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:grams\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:liters\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:ohms\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:seconds\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:volts\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:wattHours\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:watts\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$ampSecondPrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$amperePrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$bitPrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$bytePrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$generalBase has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$gramPrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$instance has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$literPrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$ohmPrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$secondPrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$voltPrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$wattHourPrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataFormat\\:\\:\\$wattPrefix has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataFormat.php
+
+ -
+ message: "#^Class Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataSet implements generic interface IteratorAggregate but does not specify its types\\: TKey, TValue$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataSet.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataSet\\:\\:asArray\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataSet.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataSet\\:\\:getIterator\\(\\) return type with generic class ArrayIterator does not specify its types\\: TKey, TValue$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataSet.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataSet\\:\\:parse\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataSet.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataSet\\:\\:skipSpaces\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataSet.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PerfDataSet\\:\\:\\$perfdata type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PerfDataSet.php
+
+ -
+ message: "#^Cannot access property \\$long_output on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Cannot access property \\$output on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Cannot call method insertBefore\\(\\) on DOMNode\\|null\\.$#"
+ count: 3
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Cannot call method removeChild\\(\\) on DOMNode\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:render\\(\\) should return string but returns string\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Parameter \\#1 \\$html of method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:processHtml\\(\\) expects string, string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Parameter \\#1 \\$html of static method Icinga\\\\Web\\\\Helper\\\\HtmlPurifier\\:\\:process\\(\\) expects string, string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Parameter \\#1 \\$name of method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:setCommandName\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function strlen expects string, string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|false given\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
+ count: 2
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function trim expects string, string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Parameter \\#2 \\$subject of function preg_match expects string, string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:\\$renderedOutput \\(string\\) does not accept string\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Util/PluginOutput.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\GridViewModeSwitcher\\:\\:getTitle\\(\\) should return string but returns string\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/GridViewModeSwitcher.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\GridViewModeSwitcher\\:\\:\\$viewModes type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/GridViewModeSwitcher.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ProblemToggle.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ProblemToggle\\:\\:__construct\\(\\) has parameter \\$filter with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ProblemToggle.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ProblemToggle\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ProblemToggle.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ProblemToggle\\:\\:protectId\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ProblemToggle.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ProblemToggle\\:\\:protectId\\(\\) has parameter \\$id with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ProblemToggle.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ProblemToggle\\:\\:\\$filter has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ProblemToggle.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ProblemToggle\\:\\:\\$protector has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ProblemToggle.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Call to an undefined method object\\:\\:getTableName\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Cannot access property \\$flatname on array\\<int\\|string, mixed\\>\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Cannot use array destructuring on array\\<int, string\\>\\|false\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectRelations\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectRelations\\(\\) has parameter \\$models with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:collectRelations\\(\\) has parameter \\$path with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:fetchColumnSuggestions\\(\\) return type has no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:fetchValueSuggestions\\(\\) return type has no value type specified in iterable type Traversable\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#1 \\$path of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyPath\\(\\) expects string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#2 \\$subject of method ipl\\\\Orm\\\\Resolver\\:\\:resolveRelation\\(\\) expects ipl\\\\Orm\\\\Model\\|null, object given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Part \\$name \\(mixed\\) of encapsed string cannot be cast to string\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:\\$customVarSources type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\SearchBar\\\\ObjectSuggestions\\:\\:\\$model \\(ipl\\\\Orm\\\\Model\\) does not accept object\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ViewModeSwitcher\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ViewModeSwitcher.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ViewModeSwitcher\\:\\:getTitle\\(\\) should return string but returns string\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ViewModeSwitcher.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ViewModeSwitcher\\:\\:protectId\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ViewModeSwitcher.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ViewModeSwitcher\\:\\:protectId\\(\\) has parameter \\$id with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ViewModeSwitcher.php
+
+ -
+ message: "#^Parameter \\#1 \\$key of function array_key_exists expects int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ViewModeSwitcher.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ViewModeSwitcher\\:\\:\\$viewModes type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Control/ViewModeSwitcher.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Cannot access offset string on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Cannot call method add\\(\\) on ipl\\\\Html\\\\Contract\\\\Wrappable\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Cannot call method getAdditional\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Cannot call method getPreferences\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Cannot call method getUsername\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Cannot call method setAdditional\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Cannot call method setErrorHandlerModule\\(\\) on array\\|Zend_Controller_Plugin_Abstract\\|false\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:assertRouteAccess\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:createColumnControl\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:createSearchBar\\(\\) has parameter \\$params with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:export\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:fetchFilterColumns\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:handleSearchRequest\\(\\) has parameter \\$additionalColumns with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:moduleInit\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:prepareSearchFilter\\(\\) has parameter \\$additionalColumns with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:sendAsPdf\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^PHPDoc tag @param references unknown parameter\\: \\$preserveParams$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^PHPDoc tag @param references unknown parameter\\: \\$redirectUrl$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#1 \\$defaultViewMode of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Control\\\\ViewModeSwitcher\\:\\:setDefaultViewMode\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#1 \\$json of static method Icinga\\\\Util\\\\Json\\:\\:decode\\(\\) expects string, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#1 \\$limit of method ipl\\\\Orm\\\\Query\\:\\:limit\\(\\) expects int\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function trim expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#2 \\$user of static method Icinga\\\\User\\\\Preferences\\\\PreferencesStore\\:\\:create\\(\\) expects Icinga\\\\User, Icinga\\\\User\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of method Icinga\\\\Web\\\\Url\\:\\:setParam\\(\\) expects array\\|bool\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#3 \\$default of method Icinga\\\\User\\\\Preferences\\:\\:getValue\\(\\) expects null, string given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Parameter \\#3 \\$filter of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:prepareSearchFilter\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Any, ipl\\\\Stdlib\\\\Filter\\\\Chain given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Controller\\:\\:\\$format \\(string\\|null\\) does not accept mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Controller.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Action\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Action\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Action\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Navigation/Action.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Cannot access property \\$hosts_down_unhandled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Cannot cast mixed to int\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\HostProblemsBadge\\:\\:fetchProblemsCount\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\HostProblemsBadge\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\HostProblemsBadge\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\HostProblemsBadge\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ProblemsBadge\\:\\:createBadge\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ProblemsBadge\\:\\:disableLink\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ProblemsBadge\\:\\:fetchProblemsCount\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ProblemsBadge\\:\\:getProblemsCount\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ProblemsBadge\\:\\:getUrl\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ProblemsBadge\\:\\:round\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ProblemsBadge\\:\\:round\\(\\) has parameter \\$count with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ProblemsBadge\\:\\:\\$linkDisabled has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Cannot access property \\$services_critical_unhandled on ipl\\\\Orm\\\\Model\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Cannot cast mixed to int\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ServiceProblemsBadge\\:\\:fetchProblemsCount\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ServiceProblemsBadge\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ServiceProblemsBadge\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\ServiceProblemsBadge\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
+
+ -
+ message: "#^Cannot call method getRenderer\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\TotalProblemsBadge\\:\\:\\$severityStateMap type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Web\\\\Navigation\\\\Renderer\\\\TotalProblemsBadge\\:\\:\\$stateSeverityMap type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\CheckAttempt\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/CheckAttempt.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CheckStatistics\\:\\:__construct\\(\\) has parameter \\$object with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CheckStatistics.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CheckStatistics\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CheckStatistics.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CheckStatistics\\:\\:assembleBody\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CheckStatistics.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CheckStatistics\\:\\:assembleHeader\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CheckStatistics.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CheckStatistics\\:\\:\\$object has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CheckStatistics.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CommentDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CommentDetail\\:\\:createComment\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CommentDetail\\:\\:createDetails\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CommentDetail\\:\\:createRemoveCommentForm\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CommentDetail\\:\\:createTicketLinks\\(\\) has parameter \\$text with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CommentDetail\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CommentDetail\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CommentDetail\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CommentDetail\\:\\:\\$comment has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CommentDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\Wrappable\\:\\:addHtml\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CustomVarTable\\:\\:__construct\\(\\) has parameter \\$data with no value type specified in iterable type iterable\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CustomVarTable\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CustomVarTable\\:\\:renderArray\\(\\) has parameter \\$array with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CustomVarTable\\:\\:renderGroup\\(\\) has parameter \\$entries with no value type specified in iterable type iterable\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CustomVarTable\\:\\:renderObject\\(\\) has parameter \\$object with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Parameter \\#1 \\$name of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CustomVarTable\\:\\:renderArray\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of method ipl\\\\Html\\\\Attributes\\:\\:add\\(\\) expects array\\|bool\\|string\\|null, int given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CustomVarTable\\:\\:\\$data \\(array\\) does not accept iterable\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CustomVarTable\\:\\:\\$data type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\CustomVarTable\\:\\:\\$groups type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/CustomVarTable.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/DowntimeCard.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeCard\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeCard.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeCard\\:\\:calcRelativeLeft\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeCard.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeCard\\:\\:calcRelativeLeft\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeCard.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeCard\\:\\:\\$downtime has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeCard.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeCard\\:\\:\\$duration has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeCard.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeCard\\:\\:\\$end has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeCard.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeCard\\:\\:\\$start has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeCard.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot access property \\$host on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot access property \\$is_flexible on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot access property \\$object_type on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot access property \\$service on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot access property \\$state on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot call method getStateText\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 5
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeDetail\\:\\:createCancelDowntimeForm\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeDetail\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeDetail\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$downtime of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:downtime\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Downtime, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\DowntimeDetail\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$text of class Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\MarkdownText constructor expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 5
+ path: library/Icingadb/Widget/Detail/DowntimeDetail.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\BaseHtmlElement\\|ipl\\\\Html\\\\FormattedString\\:\\:addAttributes\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot access property \\$checkcommand_name on mixed\\.$#"
+ count: 6
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 18
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot access property \\$downtime_id on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot access property \\$host on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot access property \\$is_flexible on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot access property \\$object_type on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot access property \\$service on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot call method getStateText\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 21
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Cannot call method limit\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleAcknowledgeEvent\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleCommentEvent\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleDowntimeEvent\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleFlappingEvent\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleNotificationEvent\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleStateChangeEvent\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:createExtensions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:createTicketLinks\\(\\) has parameter \\$text with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$acknowledgement of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleAcknowledgeEvent\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\AcknowledgementHistory, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$comment of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleCommentEvent\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\CommentHistory, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of class Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput constructor expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$downtime of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleDowntimeEvent\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\DowntimeHistory, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$flapping of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleFlappingEvent\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\FlappingHistory, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$notification of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleNotificationEvent\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\NotificationHistory, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$state of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\HostStates\\:\\:text\\(\\) expects int\\|null, mixed given\\.$#"
+ count: 6
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$state of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\ServiceStates\\:\\:text\\(\\) expects int\\|null, mixed given\\.$#"
+ count: 6
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$stateChange of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\EventDetail\\:\\:assembleStateChangeEvent\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\StateHistory, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function bin2hex expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$subject of static method ipl\\\\Stdlib\\\\Str\\:\\:camel\\(\\) expects string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$attributes of class ipl\\\\Web\\\\Widget\\\\Icon constructor expects array\\|ipl\\\\Html\\\\Attributes\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 5
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#3 \\$number of function tp expects int\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Parameter \\#3 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/EventDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\HostDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/HostDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\HostDetail\\:\\:createServiceStatistics\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/HostDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\HostDetail\\:\\:\\$serviceSummary has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/HostDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\HostInspectionDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/HostInspectionDetail.php
+
+ -
+ message: "#^Cannot access property \\$last_state_change on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/HostMetaInfo.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\HostMetaInfo\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/HostMetaInfo.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\HostStatistics\\:\\:__construct\\(\\) has parameter \\$summary with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/HostStatistics.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\HostStatistics\\:\\:\\$summary has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/HostStatistics.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:__construct\\(\\) has parameter \\$summary with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:__construct\\(\\) has parameter \\$type with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:assembleAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Parameter \\#3 \\$filter of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:isGrantedOnType\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 10
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:\\$summary has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\MultiselectQuickActions\\:\\:\\$type has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/MultiselectQuickActions.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$checkcommand_name\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$normalized_performance_data\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:__construct\\(\\) has parameter \\$object with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:compatObject\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createActions\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createCheckStatistics\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createComments\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createCustomVars\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createDowntimes\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createExtensions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createFeatureToggles\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createGroups\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createNotes\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createNotifications\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createPerformanceData\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createPluginOutput\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:createPrintHeader\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:fetchCustomVars\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:getUsersAndUsergroups\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$object of static method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:fromObject\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\|Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service, object given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$view of method Icinga\\\\Module\\\\Monitoring\\\\Hook\\\\DetailviewExtensionHook\\:\\:setView\\(\\) expects Icinga\\\\Web\\\\View, null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Possibly invalid array key type array\\|bool\\|string\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:\\$compatObject has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:\\$object has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectDetail\\:\\:\\$objectType has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectStatistics\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectStatistics.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail\\:\\:createComments\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail\\:\\:createDowntimes\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail\\:\\:createExtensions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail\\:\\:createFeatureToggles\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail\\:\\:createSummary\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Parameter \\#3 \\$baseFilter of static method Icinga\\\\Module\\\\Icingadb\\\\Hook\\\\ExtensionHook\\\\ObjectsDetailExtensionHook\\:\\:loadExtensions\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail\\:\\:\\$query has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail\\:\\:\\$summary has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ObjectsDetail\\:\\:\\$type has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ObjectsDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\PerfDataTable\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/PerfDataTable.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Cannot access property \\$is_acknowledged on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Cannot access property \\$is_problem on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\QuickActions\\:\\:__construct\\(\\) has parameter \\$object with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\QuickActions\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\QuickActions\\:\\:assembleAction\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\QuickActions\\:\\:getLink\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\QuickActions\\:\\:getLink\\(\\) has parameter \\$action with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\QuickActions\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\QuickActions\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\QuickActions\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/QuickActions.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ServiceDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ServiceDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ServiceInspectionDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ServiceInspectionDetail.php
+
+ -
+ message: "#^Cannot access property \\$last_state_change on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ServiceMetaInfo.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ServiceMetaInfo\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ServiceMetaInfo.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ServiceStatistics\\:\\:__construct\\(\\) has parameter \\$summary with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ServiceStatistics.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\ServiceStatistics\\:\\:\\$summary has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/ServiceStatistics.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Cannot call method getModel\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Cannot call method limit\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:createCustomVars\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:createExtensions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:createUserDetail\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:createUsergroupList\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:localizeStates\\(\\) has parameter \\$states with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:localizeStates\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:localizeTypes\\(\\) has parameter \\$types with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:localizeTypes\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:separateStates\\(\\) has parameter \\$states with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:separateStates\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$query of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:applyRestrictions\\(\\) expects ipl\\\\Orm\\\\Query, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$states of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:separateStates\\(\\) expects array, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$types of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UserDetail\\:\\:localizeTypes\\(\\) expects array, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/UserDetail.php
+
+ -
+ message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:getColumn\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Stdlib\\\\Filter\\\\Rule\\:\\:metaData\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Cannot call method getModel\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Cannot call method getRoles\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Cannot call method isUnrestricted\\(\\) on Icinga\\\\User\\|null\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Cannot call method limit\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UsergroupDetail\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UsergroupDetail\\:\\:createCustomVars\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UsergroupDetail\\:\\:createExtensions\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UsergroupDetail\\:\\:createPrintHeader\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UsergroupDetail\\:\\:createUserList\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UsergroupDetail\\:\\:parseDenylist\\(\\) should return ipl\\\\Stdlib\\\\Filter\\\\None but returns ipl\\\\Stdlib\\\\Filter\\\\Chain\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$condition of method ipl\\\\Sql\\\\QueryBuilder\\:\\:buildCondition\\(\\) expects array, array\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$denylist of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UsergroupDetail\\:\\:parseDenylist\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$query of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UsergroupDetail\\:\\:applyRestrictions\\(\\) expects ipl\\\\Orm\\\\Query, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Parameter \\#1 \\$queryString of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Detail\\\\UsergroupDetail\\:\\:parseRestriction\\(\\) expects string, array\\<string\\> given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$array of function join expects array\\<string\\>, array\\<int, \\(Closure\\)\\|string\\> given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Parameter \\#2 \\$tableName of method ipl\\\\Orm\\\\Resolver\\:\\:qualifyColumn\\(\\) expects string, int\\|string given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/Detail/UsergroupDetail.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Health\\:\\:__construct\\(\\) has parameter \\$data with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Health.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Health\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Health.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\Health\\:\\:\\$data has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/Health.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\HostStateBadges\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/HostStateBadges.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/HostStatusBar.php
+
+ -
+ message: "#^Parameter \\#3 \\$number of function tp expects int\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/HostStatusBar.php
+
+ -
+ message: "#^Cannot cast mixed to int\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/HostSummaryDonut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\HostSummaryDonut\\:\\:assembleBody\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/HostSummaryDonut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\HostSummaryDonut\\:\\:assembleFooter\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/HostSummaryDonut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\HostSummaryDonut\\:\\:assembleHeader\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/HostSummaryDonut.php
+
+ -
+ message: "#^Parameter \\#1 \\$data of method Icinga\\\\Chart\\\\Donut\\:\\:addSlice\\(\\) expects int, mixed given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/HostSummaryDonut.php
+
+ -
+ message: "#^Parameter \\#1 \\$labelBig of method Icinga\\\\Chart\\\\Donut\\:\\:setLabelBig\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/HostSummaryDonut.php
+
+ -
+ message: "#^Parameter \\#1 \\$rule of method ipl\\\\Stdlib\\\\Filter\\\\Chain\\:\\:add\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/HostSummaryDonut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\IconImage\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/IconImage.php
+
+ -
+ message: "#^Cannot access offset 0 on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Cannot access property \\$host on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Cannot call method getStateText\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseCommentListItem\\:\\:createTicketLinks\\(\\) has parameter \\$text with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Text\\:\\:create\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$host of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseCommentListItem\\:\\:createHostLink\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$service of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseCommentListItem\\:\\:createServiceLink\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseCommentListItem.php
+
+ -
+ message: "#^Cannot access property \\$host on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
+
+ -
+ message: "#^Cannot call method getStateText\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 5
+ path: library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseDowntimeListItem\\:\\:createTicketLinks\\(\\) has parameter \\$text with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Text\\:\\:create\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$host of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseDowntimeListItem\\:\\:createHostLink\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$service of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseDowntimeListItem\\:\\:createServiceLink\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseDowntimeListItem\\:\\:\\$duration \\(int\\) does not accept string\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$author\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$text\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\|object\\:\\:\\$author\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\|object\\:\\:\\$comment\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$author on mixed\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$cancelled_by on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$check_attempt on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$checkcommand_name on mixed\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$cleared_by on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$comment on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$end_time on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$expire_time on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$flapping_threshold_high on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$flapping_threshold_low on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$hard_state on mixed\\.$#"
+ count: 6
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$has_been_cancelled on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$id on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$max_check_attempts on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$output on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$percent_state_change_end on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$percent_state_change_start on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$previous_hard_state on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$previous_soft_state on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$removed_by on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$soft_state on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$start_time on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$state_type on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$text on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot access property \\$type on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot call method getStateText\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseHistoryListItem\\:\\:createTicketLinks\\(\\) has parameter \\$text with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$host of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseHistoryListItem\\:\\:createHostLink\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, object given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$service of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseHistoryListItem\\:\\:createServiceLink\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service, object given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function bin2hex expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$string of function ucfirst expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$host of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseHistoryListItem\\:\\:createServiceLink\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, object given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Text\\:\\:create\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseHostListItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseHostListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$author\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$history\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$host\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$object_type\\.$#"
+ count: 5
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$previous_hard_state\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$send_time\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$service\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$state\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$text\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$type\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Cannot call method getStateText\\(\\) on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseNotificationListItem\\:\\:getStateBallSize\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseServiceListItem.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseServiceListItem.php
+
+ -
+ message: "#^Cannot access property \\$state on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseServiceListItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\BaseServiceListItem\\:\\:createSubject\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseServiceListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Text\\:\\:create\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseServiceListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$host of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:host\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseServiceListItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$host of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:service\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/BaseServiceListItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/BaseServiceListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$name\\.$#"
+ count: 7
+ path: library/Icingadb/Widget/ItemList/CommandTransportListItem.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Web\\\\Common\\\\BaseItemList\\:\\:addDetailFilterAttribute\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/CommandTransportListItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\CommentList\\:\\:createTicketLinks\\(\\) has parameter \\$text with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/CommentList.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\DowntimeList\\:\\:createTicketLinks\\(\\) has parameter \\$text with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/DowntimeList.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\HistoryList\\:\\:createTicketLinks\\(\\) has parameter \\$text with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/HistoryList.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of method Icinga\\\\Web\\\\Url\\:\\:setParam\\(\\) expects array\\|bool\\|string, int given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/HistoryList.php
+
+ -
+ message: "#^Parameter \\#2 \\$haystack of function in_array expects array, array\\|bool\\|string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/HostDetailHeader.php
+
+ -
+ message: "#^Cannot access property \\$is_flapping on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/HostListItemDetailed.php
+
+ -
+ message: "#^Cannot access property \\$last_comment on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/HostListItemDetailed.php
+
+ -
+ message: "#^Cannot access property \\$normalized_performance_data on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/HostListItemDetailed.php
+
+ -
+ message: "#^Cannot access property \\$performance_data on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/HostListItemDetailed.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/HostListItemDetailed.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of method Icinga\\\\Web\\\\Url\\:\\:setParam\\(\\) expects array\\|bool\\|string, int given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/NotificationList.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\PageSeparatorItem\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/PageSeparatorItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$haystack of function in_array expects array, array\\|bool\\|string\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/ServiceDetailHeader.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
+
+ -
+ message: "#^Cannot access property \\$is_flapping on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
+
+ -
+ message: "#^Cannot access property \\$last_comment on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
+
+ -
+ message: "#^Cannot access property \\$normalized_performance_data on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
+
+ -
+ message: "#^Cannot access property \\$performance_data on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$icon_image_alt\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/StateListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$max_check_attempts\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/StateListItem.php
+
+ -
+ message: "#^Access to an undefined property object\\:\\:\\$state\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/StateListItem.php
+
+ -
+ message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/StateListItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemList\\\\StateListItem\\:\\:createSubject\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/StateListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$object of static method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:fromObject\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\|Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service, object given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemList/StateListItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Text\\:\\:create\\(\\) expects string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Text\\:\\:create\\(\\) expects string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\BaseStateRowItem\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/BaseStateRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\BaseStateRowItem\\:\\:assembleCell\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/BaseStateRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\BaseStateRowItem\\:\\:assembleCell\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/BaseStateRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\BaseStateRowItem\\:\\:assembleVisual\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/BaseStateRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\BaseStateRowItem\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/BaseStateRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\HostItemTable\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/HostItemTable.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/HostRowItem.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/HostRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\HostRowItem\\:\\:assembleCell\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/HostRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\HostRowItem\\:\\:assembleCell\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/HostRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\HostRowItem\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/HostRowItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$service of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:service\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/HostRowItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/HostRowItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/HostgroupGridCell.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 5
+ path: library/Icingadb/Widget/ItemTable/HostgroupGridCell.php
+
+ -
+ message: "#^Parameter \\#3 \\$number of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\BaseHostGroupItem\\:\\:translatePlural\\(\\) expects int\\|null, mixed given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/ItemTable/HostgroupGridCell.php
+
+ -
+ message: "#^Parameter \\#3 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 4
+ path: library/Icingadb/Widget/ItemTable/HostgroupGridCell.php
+
+ -
+ message: "#^Parameter \\#1 \\.\\.\\.\\$rules of static method ipl\\\\Stdlib\\\\Filter\\:\\:all\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/HostgroupTableRow.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/HostgroupTableRow.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$rules of static method ipl\\\\Stdlib\\\\Filter\\:\\:all\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/HostgroupTableRow.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\ServiceItemTable\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServiceItemTable.php
+
+ -
+ message: "#^Cannot access property \\$display_name on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServiceRowItem.php
+
+ -
+ message: "#^Cannot access property \\$name on mixed\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemTable/ServiceRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\ServiceRowItem\\:\\:assembleCell\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServiceRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\ServiceRowItem\\:\\:assembleCell\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServiceRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\ServiceRowItem\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServiceRowItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$host of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:host\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServiceRowItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$host of static method Icinga\\\\Module\\\\Icingadb\\\\Common\\\\Links\\:\\:service\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServiceRowItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/ServiceRowItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 9
+ path: library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php
+
+ -
+ message: "#^Parameter \\#3 \\$number of method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\BaseServiceGroupItem\\:\\:translatePlural\\(\\) expects int\\|null, mixed given\\.$#"
+ count: 8
+ path: library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php
+
+ -
+ message: "#^Parameter \\#3 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 8
+ path: library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php
+
+ -
+ message: "#^Parameter \\#1 \\.\\.\\.\\$rules of static method ipl\\\\Stdlib\\\\Filter\\:\\:all\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$rules of static method ipl\\\\Stdlib\\\\Filter\\:\\:all\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php
+
+ -
+ message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:addHtml\\(\\)\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Cannot access offset 0 on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Cannot access offset 1 on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:__construct\\(\\) has parameter \\$data with no value type specified in iterable type iterable\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:applyColumnMetaData\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:applyColumnMetaData\\(\\) return type has no value type specified in iterable type array\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:assembleColumnHeader\\(\\) has parameter \\$label with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:getVisualLabel\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:init\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Parameter \\#1 \\.\\.\\.\\$content of method ipl\\\\Html\\\\BaseHtmlElement\\:\\:addHtml\\(\\) expects ipl\\\\Html\\\\ValidHtml, object given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:\\$baseAttributes has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:\\$data type has no value type specified in iterable type iterable\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateItemTable\\:\\:\\$sort \\(string\\) does not accept string\\|null\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateItemTable.php
+
+ -
+ message: "#^Cannot access property \\$check_attempt on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$hard_state on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$is_handled on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$last_state_change on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$last_update on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$next_check on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$next_update on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$normalized_performance_data on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$previous_hard_state on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$previous_soft_state on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$soft_state on mixed\\.$#"
+ count: 2
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access property \\$state_type on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot call method getIcon\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot call method getStateText\\(\\) on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot cast mixed to int\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateRowItem\\:\\:assembleCell\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateRowItem\\:\\:assembleCell\\(\\) has parameter \\$value with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ItemTable\\\\StateRowItem\\:\\:assembleVisual\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Parameter \\#1 \\$object of static method Icinga\\\\Module\\\\Icingadb\\\\Util\\\\PluginOutput\\:\\:fromObject\\(\\) expects Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Host\\|Icinga\\\\Module\\\\Icingadb\\\\Model\\\\Service, ipl\\\\Orm\\\\Model given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Parameter \\#2 \\$alt of class Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\IconImage constructor expects string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/StateRowItem.php
+
+ -
+ message: "#^Cannot access offset 0 on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/UserTableRow.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Text\\:\\:create\\(\\) expects string, mixed given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemTable/UserTableRow.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/UserTableRow.php
+
+ -
+ message: "#^Cannot access offset 0 on mixed\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/UsergroupTableRow.php
+
+ -
+ message: "#^Parameter \\#1 \\$content of static method ipl\\\\Html\\\\Text\\:\\:create\\(\\) expects string, mixed given\\.$#"
+ count: 3
+ path: library/Icingadb/Widget/ItemTable/UsergroupTableRow.php
+
+ -
+ message: "#^Parameter \\#2 \\$value of static method ipl\\\\Stdlib\\\\Filter\\:\\:equal\\(\\) expects array\\|bool\\|float\\|int\\|string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ItemTable/UsergroupTableRow.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ServiceStateBadges\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ServiceStateBadges.php
+
+ -
+ message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ServiceStatusBar.php
+
+ -
+ message: "#^Parameter \\#3 \\$number of function tp expects int\\|null, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ServiceStatusBar.php
+
+ -
+ message: "#^Cannot cast mixed to int\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ServiceSummaryDonut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ServiceSummaryDonut\\:\\:assembleBody\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ServiceSummaryDonut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ServiceSummaryDonut\\:\\:assembleFooter\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ServiceSummaryDonut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\ServiceSummaryDonut\\:\\:assembleHeader\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ServiceSummaryDonut.php
+
+ -
+ message: "#^Parameter \\#1 \\$data of method Icinga\\\\Chart\\\\Donut\\:\\:addSlice\\(\\) expects int, mixed given\\.$#"
+ count: 8
+ path: library/Icingadb/Widget/ServiceSummaryDonut.php
+
+ -
+ message: "#^Parameter \\#1 \\$labelBig of method Icinga\\\\Chart\\\\Donut\\:\\:setLabelBig\\(\\) expects string, mixed given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ServiceSummaryDonut.php
+
+ -
+ message: "#^Parameter \\#1 \\$rule of method ipl\\\\Stdlib\\\\Filter\\\\Chain\\:\\:add\\(\\) expects ipl\\\\Stdlib\\\\Filter\\\\Rule, ipl\\\\Stdlib\\\\Filter\\\\Rule\\|null given\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/ServiceSummaryDonut.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\StateChange\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/StateChange.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\StateChange\\:\\:setHandled\\(\\) has parameter \\$isHandled with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/StateChange.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\StateChange\\:\\:setIcon\\(\\) has parameter \\$icon with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/StateChange.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\StateChange\\:\\:\\$currentStateBallSize has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/StateChange.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\StateChange\\:\\:\\$previousState has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/StateChange.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\StateChange\\:\\:\\$previousStateBallSize has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/StateChange.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\StateChange\\:\\:\\$state has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/StateChange.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\TagList\\:\\:addLink\\(\\) has parameter \\$content with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/TagList.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\TagList\\:\\:addLink\\(\\) has parameter \\$url with no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/TagList.php
+
+ -
+ message: "#^Method Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\TagList\\:\\:assemble\\(\\) has no return type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/TagList.php
+
+ -
+ message: "#^Property Icinga\\\\Module\\\\Icingadb\\\\Widget\\\\TagList\\:\\:\\$content has no type specified\\.$#"
+ count: 1
+ path: library/Icingadb/Widget/TagList.php
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..4cfb7e5
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,28 @@
+includes:
+ - phpstan-baseline.neon
+
+parameters:
+ level: max
+
+ checkFunctionNameCase: true
+ checkInternalClassCaseSensitivity: true
+ treatPhpDocTypesAsCertain: false
+
+ paths:
+ - application
+ - library
+
+ scanDirectories:
+ - vendor
+
+ ignoreErrors:
+ -
+ messages:
+ - '#Unsafe usage of new static\(\)#'
+ - '#. but return statement is missing#'
+ reportUnmatched: false
+
+ universalObjectCratesClasses:
+ - ipl\Orm\Model
+ - Icinga\Web\View
+ - Icinga\Data\ConfigObject
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..58f58f2
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit backupGlobals="false"
+ backupStaticAttributes="false"
+ colors="true"
+ convertErrorsToExceptions="true"
+ convertNoticesToExceptions="true"
+ convertWarningsToExceptions="true"
+ processIsolation="false"
+ stopOnFailure="false"
+ >
+ <testsuites>
+ <testsuite name="Icinga DB Web PHP Unit Tests">
+ <directory suffix="Test.php">test/php</directory>
+ </testsuite>
+ </testsuites>
+</phpunit>
diff --git a/public/css/common.less b/public/css/common.less
new file mode 100644
index 0000000..4cf5cfc
--- /dev/null
+++ b/public/css/common.less
@@ -0,0 +1,405 @@
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+@exports: {
+ @iplWebAssets: "../lib/icinga/icinga-php-library";
+};
+
+& > .content.full-width {
+ padding-left: 0;
+ padding-right: 0;
+
+ .list-item,
+ .table-layout .table-row {
+ padding-left: 1em;
+ padding-right: 1em;
+ }
+}
+
+& > .content.full-height {
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.plugin-output {
+ .monospace();
+ word-break: break-word;
+}
+
+div.show-more {
+ .clearfix();
+ float: right;
+}
+
+.state-ball.state-not-available {
+ .ball-solid(@gray-light);
+ .animate(pulse 1.5s infinite both);
+}
+
+.icon-ball {
+ .ball();
+ .ball-outline(@gray);
+ text-align: center;
+
+ i:before {
+ margin-right: 0;
+ }
+}
+
+.user-ball {
+ .ball();
+ .ball-size-xl();
+ .ball-solid(@gray-semilight);
+ font-weight: bold;
+ line-height: 1.75;
+ text-transform: uppercase;
+}
+
+.usergroup-ball {
+ .ball();
+ .ball-outline(@gray);
+ .ball-size-xl();
+ line-height: 1.75;
+ text-transform: uppercase;
+}
+
+.ack-badge {
+ text-transform: uppercase;
+ line-height: 1;
+ margin-top: -.25em;
+ align-self: flex-start;
+
+ i {
+ vertical-align: baseline;
+ }
+}
+
+.controls {
+ .box-shadow(0, 0, 0, 1px, @gray-lighter);
+ flex-shrink: 0;
+ position: relative; // Required for the host meta info control
+ z-index: 1; // The content may clip, this ensures the separator is always visible
+
+ > :not(:only-child) {
+ margin-bottom: .5em;
+ }
+
+ #object-meta-info {
+ margin-bottom: 0;
+
+ .object-meta-info {
+ margin-bottom: .5em;
+ }
+ }
+
+ &.overdue,
+ &.overdue .tabs li.active a,
+ &.overdue .object-meta-info-control {
+ background-color: @gray-lighter;
+ }
+
+ .limit-control,
+ .view-mode-switcher,
+ .sort-control {
+ margin-left: .5em;
+ float: right;
+ }
+
+ .toggle-switch {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+
+ .item-list {
+ width: 100%;
+
+ .list-item .main {
+ border-top: none;
+ }
+
+ .list-item .visual {
+ width: auto;
+ margin-top: 0;
+ }
+
+ .list-item .visual,
+ .list-item .main {
+ padding-bottom: .25em;
+ padding-top: .25em;
+ }
+ }
+
+ .search-controls .continue-with {
+ margin-right: -.5em;
+ margin-left: .5em;
+ }
+
+ .show-more {
+ margin-top: .25em;
+ }
+
+ .notice {
+ display: none;
+ }
+
+ // TODO: Remove once ipl-web v0.7.0 is required
+ &:not(.default-layout) {
+ .pagination-control {
+ float: left;
+ }
+
+ .sort-control {
+ display: flex;
+ justify-content: flex-end;
+
+ :not(.form-element) > label {
+ margin-right: 0;
+ }
+
+ .control-button {
+ margin: 0;
+ }
+ }
+
+ > :not(:only-child) {
+ margin-bottom: 0.5em;
+ }
+
+ .search-suggestions {
+ margin-bottom: 2.5em;
+ }
+
+ .search-controls {
+ clear: both;
+ display: flex;
+ min-width: 100%;
+
+ .search-bar {
+ flex: 1 1 auto;
+
+ & ~ .control-button:last-child {
+ margin-right: -.5em;
+ }
+
+ & ~ .control-button {
+ margin-left: .5em;
+ }
+ }
+ }
+ }
+}
+
+.content > h2:first-child,
+.object-detail > h2:first-child {
+ margin-top: 0;
+}
+
+.content.full-width > h2 {
+ margin-left: 1em / 1.333em; // 1em / h2 font size
+}
+
+.object-detail .plugin-output {
+ .rounded-corners(.25em);
+ background-color: @gray-lighter;
+ padding: .5em;
+}
+
+.object-detail .item-list {
+ &.action-list .list-item {
+ margin-right: -1em;
+ margin-left: -1em;
+ padding-right: 1em;
+ padding-left: 1em;
+ }
+
+ .list-item:last-of-type .caption {
+ min-height: 1.5em;
+ max-height: 3em;
+ height: auto;
+ }
+}
+
+.perfdata-wrapper {
+ svg {
+ width: 100%;
+ }
+
+ svg:not(:last-child) {
+ margin-bottom: 1em;
+ }
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-muted {
+ color: @gray;
+}
+
+.accompanying-text {
+ color: @text-color-light;
+
+ .subject {
+ color: @text-color;
+ .user-select(all);
+ }
+}
+
+.comment-detail {
+ > form,
+ > h2:not(:first-child) {
+ margin-top: 1em;
+ }
+}
+
+.downtime-detail {
+ .downtime-progress {
+ margin-bottom: 1em;
+ }
+}
+
+.footer {
+ display: flex;
+ .box-shadow(0, -1px, 0, 0, @gray-lighter);
+ color: @text-color-light;
+
+ .selection-count {
+ flex: 1 1 auto;
+
+ .selected-items {
+ font-size: 1.25em;
+
+ &.hint {
+ opacity: 0.75;
+ }
+ }
+ }
+
+ .status-bar, .selection-count {
+ font-size: .857em;
+ padding: .25em 1em;
+ line-height: 1.7;
+ }
+}
+
+.status-bar {
+ margin-left: auto;
+
+ .item-count {
+ font-size: 1.25em;
+ }
+
+ .state-badges {
+ display: inline-block;
+ margin: 0 0 0 .417em;
+ }
+}
+
+.multiselect-summary {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+
+ // Donut
+ > div:first-child {
+ height: 4em;
+ width: 4em;
+
+ > svg {
+ height: auto;
+ max-width: 100%;
+ }
+ }
+
+ > .vertical-key-value {
+ padding: 0 .5em;
+ }
+}
+
+.item-table {
+ &.table-layout {
+ &.hostgroup-table {
+ --columns: 2;
+ }
+
+ &.servicegroup-table {
+ --columns: 1;
+ }
+
+ &.user-table, // TODO: make them lists.....
+ &.usergroup-table {
+ --columns: 0;
+ }
+ }
+}
+
+.hostgroup-table,
+.servicegroup-table,
+.usergroup-table,
+.user-table {
+ .title .content > * {
+ display: block;
+ max-width: fit-content;
+ }
+
+ .object-statistics-total {
+ width: 3.75em;
+ }
+}
+
+.controls .hostgroup-table-row,
+.controls .servicegroup-table-row,
+.controls .usergroup-table-row,
+.controls .user-table-row {
+ .title .content {
+ display: inline-flex;
+ align-items: center;
+
+ > :first-child {
+ flex: 0 1 auto;
+ }
+
+ > :last-child {
+ flex: 1 1 auto;
+ }
+
+ .subject {
+ margin-right: .5em;
+ }
+ }
+
+ .vertical-key-value {
+ br {
+ display: none;
+ }
+
+ .key {
+ padding-left: .417em;
+ }
+
+ .value {
+ vertical-align: middle;
+ }
+ }
+}
+
+.history-list,
+.objectHeader {
+ .visual.small-state-change .state-change {
+ padding-top: .25em;
+ }
+}
+
+.comment-popup {
+ .comment-list .main {
+ // This is necessary to limit the visible comment lines
+ // because the popup is shown in detailed list mode only
+ .caption {
+ height: 3em;
+ }
+ }
+}
+
+form[name="form_confirm_removal"] {
+ text-align: center;
+}
diff --git a/public/css/form/schedule-service-downtime-form.less b/public/css/form/schedule-service-downtime-form.less
new file mode 100644
index 0000000..a65264d
--- /dev/null
+++ b/public/css/form/schedule-service-downtime-form.less
@@ -0,0 +1,21 @@
+.downtime-duration {
+ > label {
+ display: flex;
+ flex: 1 1 auto;
+ flex-flow: row-reverse;
+
+ input {
+ flex: 1 1 auto;
+ }
+
+ span {
+ margin-left: .5em;
+ padding: .5625em 0;
+ line-height: 1.1em;
+ }
+
+ &:not(:last-child) {
+ margin-right: 1.5em;
+ }
+ }
+}
diff --git a/public/css/list/action-list.less b/public/css/list/action-list.less
new file mode 100644
index 0000000..5bb08f4
--- /dev/null
+++ b/public/css/list/action-list.less
@@ -0,0 +1,14 @@
+.action-list {
+ [data-action-item]:hover {
+ background-color: @tr-hover-color;
+ cursor: pointer;
+ }
+
+ [data-action-item].active {
+ background-color: @tr-active-color;
+ }
+
+ &[data-icinga-multiselect-url] * {
+ user-select: none;
+ }
+}
diff --git a/public/css/list/comment-list.less b/public/css/list/comment-list.less
new file mode 100644
index 0000000..46b194d
--- /dev/null
+++ b/public/css/list/comment-list.less
@@ -0,0 +1,50 @@
+// Style
+
+// Layout
+
+.comment-list:not(.detailed) .list-item {
+ .title > i:first-child {
+ margin-right: 0;
+ }
+
+ .title > .subject + .badge,
+ .title > .badge + .subject,
+ .title > .badge:last-of-type {
+ margin-left: 0;
+ }
+
+ .title a {
+ &:not(.subject) {
+ .text-ellipsis();
+ }
+ }
+
+ .title .subject:not(:last-child) {
+ margin-left: 0;
+ }
+
+ .title .subject:nth-child(3):last-child {
+ margin-left: 0;
+ }
+}
+
+.comment-list.minimal .list-item {
+ .user-ball {
+ font-size: .857em;
+ height: 1.75em;
+ line-height: 1.5em;
+ width: 1.75em;
+ }
+}
+
+.comment-list.detailed .list-item {
+ .title > .subject:nth-child(3),
+ .title > .badge + .subject:last-child {
+ margin-left: .3em;
+ }
+
+ .caption {
+ max-height: 4.5em;
+ white-space: normal;
+ }
+}
diff --git a/public/css/list/downtime-list.less b/public/css/list/downtime-list.less
new file mode 100644
index 0000000..8768097
--- /dev/null
+++ b/public/css/list/downtime-list.less
@@ -0,0 +1,93 @@
+// Style
+
+.downtime-list .list-item,
+.downtime-detail .list-item {
+ .progress {
+ > .bar {
+ background-color: @color-ok;
+ }
+ }
+
+ .visual {
+ background-color: @gray-lighter;
+ }
+
+ .main {
+ border-top: 1px solid @gray-light;
+ }
+
+ &:first-child .main {
+ border-top: none;
+
+ .progress > .bar {
+ border-top: 1px solid @gray-light;
+ }
+ }
+
+ &.in-effect {
+ .visual {
+ background-color: @color-ok;
+ color: @text-color-on-icinga-blue;
+ }
+
+ .main {
+ padding-top: 0; // If active the progress bar represents the padding top
+ }
+ }
+}
+
+// Layout
+
+.downtime-list .list-item {
+ .caption > * {
+ display: inline;
+ }
+}
+
+.downtime-list .list-item,
+.downtime-detail .list-item {
+ .progress {
+ height: 2px;
+ margin-bottom: ~"calc(.5em - 2px)";
+ min-width: 100%;
+ position: relative;
+
+ > .bar {
+ height: 100%;
+ max-width: 100%;
+ }
+ }
+
+ &:first-child .main .progress > .bar {
+ height: ~"calc(100% + 1px)"; // +1px due to the border added exclusively for the first item
+ }
+
+ .visual {
+ justify-content: center;
+ flex-shrink: 0;
+ line-height: 1em;
+ margin-right: .5em;
+ padding: .5em .25em;
+ text-align: center;
+ width: 6em;
+
+ strong {
+ font-size: 1.5em;
+ line-height: 1em;
+ }
+ }
+}
+
+.item-list.downtime-list.minimal .list-item {
+ .visual {
+ display: block;
+ line-height: 1.5;
+ width: 8em;
+ white-space: nowrap;
+
+ strong {
+ display: inline-block;
+ font-size: 1em;
+ }
+ }
+}
diff --git a/public/css/list/item-list.less b/public/css/list/item-list.less
new file mode 100644
index 0000000..251eec3
--- /dev/null
+++ b/public/css/list/item-list.less
@@ -0,0 +1,154 @@
+// Style
+
+.item-list {
+ .load-more:hover,
+ .page-separator:hover {
+ background: none;
+ }
+
+ > .load-more a {
+ .rounded-corners(.25em);
+ background: @low-sat-blue;
+ text-align: center;
+
+ &:hover {
+ opacity: .8;
+ text-decoration: none;
+ }
+ }
+
+ > .page-separator:after {
+ content: "";
+ display: block;
+ width: 100%;
+ height: 1px;
+ background: @gray;
+ align-self: center;
+ margin-left: .25em;
+ }
+
+ > .page-separator a {
+ color: @gray;
+ font-weight: bold;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ > .page-separator + .list-item .main {
+ border-top: none;
+ }
+}
+
+// Layout
+
+.item-list .list-item {
+ &.load-more a {
+ flex: 1;
+ margin: 1.5em 0;
+ padding: .5em 0;
+ }
+}
+
+.item-list.minimal {
+ > .empty-state {
+ padding: .25em;
+ }
+
+ .list-item {
+ header {
+ max-width: 100%;
+ }
+
+ .visual {
+ width: 2.2em;
+ }
+
+ .check-attempt {
+ display: none;
+ }
+
+ .title {
+ p {
+ display: inline;
+
+ & + p {
+ margin-left: .417em;
+ }
+ }
+ }
+
+ .caption {
+ flex: 1 1 auto;
+ height: 1.5em;
+ margin-right: 1em;
+ width: 0;
+
+ .line-clamp("reset");
+ }
+
+ .caption,
+ .caption .plugin-output {
+ .text-ellipsis();
+ }
+ }
+}
+
+.item-list.detailed .list-item {
+ .title {
+ word-break: break-word;
+ -webkit-hyphens: auto;
+ -ms-hyphens: auto;
+ hyphens: auto;
+ }
+
+ .caption {
+ display: block;
+ height: auto;
+ max-height: 7.5em; /* 5 lines */
+ position: relative;
+
+ .line-clamp(4)
+ }
+}
+
+.item-list {
+ .icon-image {
+ width: 3em;
+ height: 3em;
+ text-align: center;
+ margin-top: .5em;
+ margin-left: .5em;
+ overflow: hidden;
+
+ img {
+ max-height: 100%;
+ max-width: 100%;
+ height: auto;
+ width: auto;
+ }
+ }
+
+ &.minimal {
+ .icon-image {
+ height: 2em;
+ width: 2em;
+ line-height: 2;
+ }
+ }
+}
+
+.controls .item-list:not(.detailed):not(.minimal) .list-item {
+ .plugin-output {
+ line-height: 1.5
+ }
+
+ .caption {
+ height: 2.5em;
+ }
+}
+
+.controls .item-list.minimal .icon-image {
+ margin-top: 0;
+}
diff --git a/public/css/list/list-item.less b/public/css/list/list-item.less
new file mode 100644
index 0000000..2e67e3d
--- /dev/null
+++ b/public/css/list/list-item.less
@@ -0,0 +1,77 @@
+// Style
+
+.list-item {
+ &.overdue {
+ background-color: @gray-lighter;
+ }
+
+ &.overdue header > *:not(time),
+ &.overdue .caption {
+ opacity: 0.6;
+ }
+
+ &.overdue time {
+ .rounded-corners();
+ background-color: @color-critical;
+ color: @text-color-on-icinga-blue;
+ }
+
+ .title {
+ .state-text {
+ color: @text-color;
+ text-transform: uppercase;
+ }
+ }
+
+ footer {
+ .status-icons {
+ color: @text-color-light;
+
+ .icon {
+ opacity: .5;
+ }
+ }
+ }
+}
+
+// Layout
+
+.list-item {
+ &.overdue time {
+ margin-right: -.5em;
+ padding: 0 0.5em;
+ }
+
+ .visual .check-attempt {
+ margin-top: .5em;
+ }
+
+ .caption {
+ &.plugin-output, .plugin-output {
+ font-size: 11/12em;
+ line-height: 1.5*12/11em;
+ }
+ }
+
+ footer {
+ .status-icons {
+ display: flex;
+ align-items: center;
+ }
+
+ .performance-data {
+ margin-left: auto;
+
+ .inline-pie {
+ display: inline-block;
+ line-height: 1.5*.857em;
+ height: 1em;
+ width: 1em;
+
+ &:not(:last-child) {
+ margin-right: .209em;
+ }
+ }
+ }
+ }
+}
diff --git a/public/css/list/state-item-table.less b/public/css/list/state-item-table.less
new file mode 100644
index 0000000..9c2ee62
--- /dev/null
+++ b/public/css/list/state-item-table.less
@@ -0,0 +1,201 @@
+// Style
+
+.state-item-table {
+ padding: 0;
+
+ thead {
+ th {
+ font-weight: normal;
+
+ // Border styles start
+ form {
+ padding: 0 0 0 1px;
+ border-bottom: 1px solid @gray-light;
+ background: linear-gradient(to top, @gray-light, @body-bg-color);
+
+ button {
+ background: @body-bg-color;
+ }
+ }
+ &:first-child form {
+ padding-left: 0;
+ }
+ // Border styles end
+ }
+
+ button {
+ .appearance(none);
+ border: none;
+ background: none;
+ padding: .1em .5em;
+
+ text-align: left;
+ color: @text-color-light;
+
+ > .icon {
+ opacity: 0;
+ width: 0;
+ transition: opacity .25s linear, width .25s ease;
+ }
+ &:hover .icon,
+ &:focus .icon,
+ &.active .icon {
+ opacity: 1;
+ width: 1em;
+ }
+
+ &.active {
+ font-weight: bold;
+ }
+ }
+ }
+
+ .list-item:not(:last-child) > *:not(.visual),
+ .row-item:not(:last-child) {
+ border-bottom: 1px solid @gray-light;
+ }
+}
+
+@media print {
+ .list-item.page-break-follows {
+ &:not(:last-child) > *:not(.visual) {
+ border-bottom: none;
+ }
+ }
+}
+
+// Layout
+
+table.state-item-table {
+ table-layout: fixed;
+}
+
+.state-item-table {
+ display: table;
+ width: 100%;
+ margin: 0;
+
+ thead {
+ position: sticky;
+ top: 0;
+
+ th {
+ // That's layout, yes, controls overflow when scrolling
+ padding: 1em 0 0 0;
+ background: @body-bg-color;
+ }
+
+ th button {
+ width: 100%;
+ display: inline-flex;
+ align-items: baseline;
+ justify-content: space-between;
+
+ span {
+ .text-ellipsis();
+ }
+ }
+ }
+
+ th.has-visual {
+ width: 3em;
+ }
+
+ tbody td {
+ .text-ellipsis();
+ vertical-align: top;
+ }
+
+ .list-item {
+ display: table-row;
+ }
+
+ .list-item > .col {
+ display: table-cell;
+ vertical-align: middle;
+ white-space: nowrap;
+
+ &:not(:last-child) {
+ padding-right: 1em;
+ }
+
+ &.title {
+ .text-ellipsis();
+ width: 100%;
+ }
+
+ > * {
+ display: inline-block;
+
+ &:not(:last-child) {
+ margin-right: .5em;
+ }
+ }
+ }
+
+ .list-item > *:not(.visual) {
+ padding: .5em 0;
+ }
+
+ .list-item > .visual {
+ display: table-cell;
+ padding: .5em 1em 0 0;
+ }
+
+ > .empty-state-bar,
+ > tbody > tr:first-child .empty-state-bar {
+ margin: 0 1em;
+ }
+}
+
+.content.full-width .state-item-table .list-item {
+ // The .list-item itself can't have padding because of `display:table-row`
+ &:before, &:after {
+ display: inline-block;
+ content: '\00a0';
+ width: 1em;
+ }
+}
+
+#layout.twocols table.state-item-table {
+ > thead > tr > th,
+ > tbody > tr > td {
+ &:nth-child(n+6) {
+ display: none;
+ width: 0;
+ }
+ }
+}
+
+#layout.wide-layout .item-table th.has-plugin-output {
+ width: 50em;
+}
+#layout.default-layout .item-table th.has-plugin-output {
+ width: 30em;
+}
+#layout.compact-layout .item-table th.has-plugin-output {
+ width: 10em;
+}
+
+#layout.twocols table.item-table {
+ .has-plugin-output {
+ width: auto;
+ }
+}
+
+table.item-table {
+ th.has-visual {
+ button {
+ display: inline-flex;
+ justify-content: center;
+ }
+
+ span > .icon:before {
+ margin: 0;
+ }
+ }
+
+ .visual {
+ text-align: center;
+ }
+}
diff --git a/public/css/list/state-row-item.less b/public/css/list/state-row-item.less
new file mode 100644
index 0000000..31c7e5a
--- /dev/null
+++ b/public/css/list/state-row-item.less
@@ -0,0 +1,42 @@
+// Style
+
+.row-item {
+ .plugin-output {
+ overflow: hidden;
+ .line-clamp();
+ }
+
+ .subject {
+ font-weight: bold;
+ }
+}
+
+// Layout
+
+.row-item {
+ .performance-data {
+ overflow: hidden;
+ .line-clamp(2);
+ white-space: normal;
+ margin-left: -.25em;
+
+ .inline-pie {
+ display: inline-block;
+ width: 1em;
+ height: 1em;
+ margin-left: .25em;
+ }
+ }
+
+ .has-icon-images {
+ height: 2.5em;
+ vertical-align: middle;
+
+ img {
+ max-height: 100%;
+ max-width: 100%;
+ height: auto;
+ width: auto;
+ }
+ }
+}
diff --git a/public/css/list/user-list.less b/public/css/list/user-list.less
new file mode 100644
index 0000000..078d1b9
--- /dev/null
+++ b/public/css/list/user-list.less
@@ -0,0 +1,26 @@
+// Layout
+
+.controls .user-list,
+.controls .usergroup-list {
+ .usergroup-ball,
+ .user-ball {
+ height: 1.5em;
+ width: 1.5em;
+ line-height: ~"calc(1.5em - 4px)";
+ display: block;
+ }
+
+ .title br {
+ display: none;
+ }
+
+ .list-item {
+ & > .visual {
+ padding-top: 0.25em;
+ }
+
+ & > .col {
+ padding: .25em 0;
+ }
+ }
+}
diff --git a/public/css/markdown.less b/public/css/markdown.less
new file mode 100644
index 0000000..bebede5
--- /dev/null
+++ b/public/css/markdown.less
@@ -0,0 +1,80 @@
+.markdown {
+ > p,
+ > hr,
+ > ul,
+ > ol,
+ > table,
+ > pre,
+ > blockquote,
+ li > ul,
+ li > ol {
+ margin: 1em 0 1em 0;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ p {
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ a {
+ border-bottom: 1px dotted @text-color-light;
+
+ &:hover, &:focus {
+ border-bottom: 1px solid @text-color;
+ text-decoration: none;
+ }
+
+ img {
+ max-width: 32em;
+ }
+
+ &.with-thumbnail {
+ img {
+ padding: 1px;
+ }
+
+ &:hover, &:focus {
+ img {
+ padding: 0;
+ }
+ }
+ }
+ }
+
+ table {
+ border-collapse: collapse;
+
+ th {
+ text-align: left;
+ background-color: @gray-lighter;
+ }
+
+ &, th, td {
+ border: 1px solid;
+ border-color: @gray-light;
+ }
+ }
+}
+
+.markdown.inline {
+ img {
+ max-height: 100%;
+ vertical-align: middle;
+ }
+
+ a.with-thumbnail {
+ &, &:hover, &:focus {
+ border-bottom: none;
+ }
+ }
+}
diff --git a/public/css/mixin/progress-bar.less b/public/css/mixin/progress-bar.less
new file mode 100644
index 0000000..d15b4c3
--- /dev/null
+++ b/public/css/mixin/progress-bar.less
@@ -0,0 +1,217 @@
+.progress-bar() {
+ &.progress-bar {
+ --hPadding: 10%;
+ --duration-scale: 80%;
+
+ .above,
+ .below {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+
+ position: relative;
+ height: ~"calc(2em + 2px)";
+ }
+
+ .below {
+ > .left {
+ position: absolute;
+ left: var(--hPadding);
+ top: 0;
+ }
+
+ > .right {
+ position: absolute;
+ left: ~"calc(var(--hPadding) + var(--duration-scale))";
+ top: 0;
+ }
+ }
+
+ .positioned {
+ position: absolute;
+ }
+
+ .bubble {
+ .rounded-corners(.25em);
+ background-color: @body-bg-color;
+ border: 1px solid;
+ border-color: @gray-light;
+ position: relative;
+ box-shadow: 0 0 1em 0 rgba(0, 0, 0, .1);
+ padding: .25em .5em;
+ text-align: center;
+ width: auto;
+ // The wrapper of .bubble is dynamically moved to the left based on the value of the progress bar
+ // This moves the center of the bubble to the beginning of the wrapper regardless of the size of the content.
+ transform: translate(-50%, 0);
+ z-index: 1;
+
+ > * {
+ position: relative;
+ z-index: 2;
+ }
+
+ &:hover {
+ z-index: 5;
+ }
+
+ &::before {
+ background-color: @body-bg-color;
+ border-bottom: 1px solid @gray-light;
+ border-right: 1px solid @gray-light;
+ content: "";
+ display: block;
+ height: 1em;
+ margin-left: -.5em;
+ transform: rotate(45deg);
+ width: 1em;
+ z-index: 1;
+
+ position: absolute;
+ bottom: -.5em;
+ left: 50%;
+ }
+
+ &.upwards::before {
+ bottom: auto;
+ top: -7/12em;
+ transform: rotate(225deg);
+ }
+
+ &.right-aligned {
+ // This is (.675em (:before placement) + .5em (half :before width)) + 1px (:before border)
+ transform: translate(~"calc(-1.175em - 1px)", 0);
+
+ &::before {
+ top: auto;
+ left: 1.175em;
+ bottom: -.5em;
+ }
+ }
+
+ &.left-aligned {
+ // entire width (moves the right border in place of the left) + (.675em (:before placement) + .5em (half :before width)) + 1px (:before border)
+ transform: translate(~"calc(-100% + 1.175em + 1px)", 0);
+
+ &::before {
+ top: auto;
+ left: auto;
+ right: .675em;
+ bottom: -.5em;
+ }
+ }
+ }
+
+ .above .positioned {
+ bottom: 0;
+ }
+
+ .below .positioned {
+ top: 0;
+ }
+
+ .vertical-key-value {
+ .key {
+ white-space: nowrap;
+ }
+
+ .value {
+ white-space: nowrap;
+ font-size: 1em;
+ line-height: 1;
+ }
+ }
+
+ .timeline {
+ @marker-gap: 1/12em;
+
+ .rounded-corners(.5em);
+ background-color: @gray-lighter;
+ height: 1em;
+ margin: 1em 0;
+ position: relative;
+ width: 100%;
+ z-index: 1;
+
+ .marker {
+ .rounded-corners(50%);
+ background-color: @gray-light;
+ height: .857em;
+ margin-left: -.857/2em;
+ width: .857em;
+ z-index: 2;
+
+ position: absolute;
+ top: @marker-gap;
+
+ &.highlighted {
+ background-color: @icinga-blue;
+ }
+
+ &.left {
+ left: var(--hPadding);
+ }
+
+ &.right {
+ left: ~"calc(var(--hPadding) + var(--duration-scale))";
+ }
+ }
+
+ .progress {
+ position: absolute;
+ left: var(--hPadding);
+ width: var(--duration-scale);
+
+ &[data-animate-progress]::before {
+ content: "";
+ display: block;
+ width: .5em + @marker-gap;
+ height: 1em + (@marker-gap * 2);
+ margin-top: -@marker-gap;
+
+ .rounded-corners(.5em);
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+
+ position: absolute;
+ left: -.5em - @marker-gap;
+ }
+
+ > .bar {
+ width: 0; // set by progress-bar.js
+ height: 1em + (@marker-gap * 2);
+ margin-top: -@marker-gap;
+ }
+
+ &::before,
+ > .bar {
+ background-color: @gray-light;
+ }
+ }
+
+ .timeline-overlay {
+ position: absolute;
+ left: ~"calc(var(--hPadding) + var(--duration-scale))";
+ width: var(--overlay-scale);
+ height: 1em + (@marker-gap * 2);
+ margin-top: -@marker-gap;
+
+ opacity: .6;
+ }
+
+ .progress > .bar,
+ .timeline-overlay {
+ display: flex;
+ justify-content: flex-end;
+
+ .now {
+ width: .25em;
+
+ border: solid @default-bg;
+ border-width: 1px 0 1px 0;
+ background-color: red;
+ }
+ }
+ }
+ }
+}
diff --git a/public/css/mixin/state-badges.less b/public/css/mixin/state-badges.less
new file mode 100644
index 0000000..4be2d07
--- /dev/null
+++ b/public/css/mixin/state-badges.less
@@ -0,0 +1,31 @@
+.state-badges() {
+ &.state-badges {
+ padding: 0;
+
+ ul {
+ padding: 0;
+ }
+
+ li {
+ display: inline-block;
+ }
+
+ li > ul > li:first-child:not(:last-child) .state-badge {
+ border-bottom-right-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ li > ul > li:last-child:not(:first-child) .state-badge {
+ border-bottom-left-radius: 0;
+ border-top-left-radius: 0;
+ }
+
+ > li:not(:last-child) {
+ margin-right: .25em;
+ }
+
+ li > ul > li + li {
+ margin-left: 1px;
+ }
+ }
+}
diff --git a/public/css/mixins.less b/public/css/mixins.less
new file mode 100644
index 0000000..326bf46
--- /dev/null
+++ b/public/css/mixins.less
@@ -0,0 +1,5 @@
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+.monospace() {
+ font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
+}
diff --git a/public/css/view/service-grid.less b/public/css/view/service-grid.less
new file mode 100644
index 0000000..41400e5
--- /dev/null
+++ b/public/css/view/service-grid.less
@@ -0,0 +1,60 @@
+.service-grid-table {
+ width: 0;
+ white-space: nowrap;
+
+ td {
+ color: @gray-light;
+ padding: 0.2em;
+ text-align: center;
+ width: 1em;
+ }
+
+ .rotate-45 {
+ height: 10em;
+
+ div {
+ .transform(translate(0.4em, 2.8em) rotate(315deg));
+ width: 1.5em;
+ }
+ }
+
+ .service-grid-table-more {
+ text-align: center;
+ a {
+ display: inline;
+ }
+ }
+}
+
+.joystick-pagination {
+ margin: 0 auto;
+ font-size: 130%;
+
+ a {
+ color: @text-color;
+ outline: none;
+
+ &:hover {
+ color: @text-color-light;
+ }
+ &:focus, &:active {
+ color: @icinga-blue;
+ }
+ }
+
+ i {
+ display: block;
+ height: 1.5em;
+ width: 1.5em;
+ }
+}
+
+.service-grid-link {
+ .bg-stateful();
+ .rounded-corners();
+
+ display: inline-block;
+ height: 1.5em;
+ vertical-align: middle;
+ width: 1.5em;
+}
diff --git a/public/css/widget/actions.less b/public/css/widget/actions.less
new file mode 100644
index 0000000..796fc38
--- /dev/null
+++ b/public/css/widget/actions.less
@@ -0,0 +1,20 @@
+.object-detail-actions a {
+ border-bottom: 1px solid @gray-light;
+ display: inline-block;
+ margin-bottom: .25em;
+
+ .text-ellipsis();
+ max-width: 32em;
+
+ &:hover {
+ border-color: @icinga-blue-light;
+ color: @icinga-blue;
+ text-decoration: none;
+ }
+}
+
+ul.object-detail-actions {
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+}
diff --git a/public/css/widget/check-attempt.less b/public/css/widget/check-attempt.less
new file mode 100644
index 0000000..1042a08
--- /dev/null
+++ b/public/css/widget/check-attempt.less
@@ -0,0 +1,17 @@
+.check-attempt {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.check-attempt .ball {
+ background-color: @gray-light;
+
+ &:not(:last-child) {
+ margin-right: 1/6em;
+ }
+
+ &.taken {
+ background-color: @gray-semilight;
+ }
+}
diff --git a/public/css/widget/check-statistics.less b/public/css/widget/check-statistics.less
new file mode 100644
index 0000000..4bd34c2
--- /dev/null
+++ b/public/css/widget/check-statistics.less
@@ -0,0 +1,192 @@
+.check-statistics {
+ position: relative;
+ .card();
+ .progress-bar();
+
+ .check-attempt {
+ display: inline-flex;
+ }
+
+ .bubble {
+ &.top-left-aligned,
+ &.top-right-aligned {
+ &::before {
+ visibility: hidden;
+ }
+
+ svg {
+ position: absolute;
+ top: -1em;
+ width: 1em;
+ height: 1em;
+
+ .bg {
+ fill: @body-bg-color;
+ }
+
+ .border {
+ fill: @gray-light;
+ }
+ }
+ }
+
+ &.top-left-aligned {
+ transform: unset;
+ border-top-left-radius: 0;
+
+ svg {
+ left: -1px;
+ }
+ }
+
+ &.top-right-aligned {
+ transform: translate(-100%);
+ border-top-right-radius: 0;
+
+ svg {
+ right: -1px;
+ }
+ }
+ }
+
+ // ATTENTION!: `&.progress-bar {` must not be used here, seems to confuse the less parser!!!!111
+
+ &.progress-bar .timeline .progress.running {
+ &::before,
+ > .bar {
+ background: @state-ok;
+ }
+ }
+
+ &.progress-bar .check-timeline {
+ margin-top: .5em;
+ }
+ &.progress-bar .above {
+ margin-top: .5em;
+ }
+
+ .interval-line {
+ position: absolute;
+ height: 100%;
+
+ &::before {
+ position: absolute;
+ top: ~"calc(50% - .125em)";
+ display: block;
+ height: .25em;
+ width: 100%;
+ content: "";
+
+ background-color: @gray-light;
+ }
+
+ .vertical-key-value {
+ position: absolute;
+ left: 50%;
+ transform: translate(-50%, 0);
+
+ padding: 0 .2em;
+ background-color: @body-bg-color;
+ }
+
+ .start,
+ .end {
+ position: absolute;
+ top: 50%;
+ width: .25em;
+ height: 1em;
+ background-color: @gray;
+ }
+
+ .start {
+ left: 0;
+ transform: translate(-50%, -50%);
+ }
+
+ .end {
+ right: 0;
+ transform: translate(50%, -50%);
+ }
+ }
+
+ .execution-line .vertical-key-value {
+ z-index: 1;
+ }
+
+ &.check-overdue {
+ --duration-scale: 60%;
+ --overlay-scale: 20%;
+
+ .above {
+ .now {
+ position: absolute;
+ right: var(--hPadding);
+ bottom: 0;
+
+ .bubble {
+ // to move the center of the bubble to the end of the wrapper.
+ transform: translate(50%, 0);
+ }
+ }
+ }
+
+ .timeline-overlay {
+ background: linear-gradient(90deg, @gray-light 0, @color-down 2em);
+ opacity: 1;
+
+ &::after {
+ background-color: @color-down;
+ }
+ }
+ }
+
+ &.checks-disabled.progress-bar {
+ .timeline {
+ .marker {
+ &.highlighted {
+ background-color: @gray;
+ }
+ }
+ }
+ }
+
+ .checks-disabled-overlay {
+ border-radius: 0.4em;
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ background-color: ~"@{disabled-gray}20";
+ z-index: 1;
+
+ .notes {
+ color: @text-color-light;
+ margin-top: -4em;
+ text-shadow: 0 0 1px rgba(0, 0, 0, 0.25);
+ }
+ }
+}
+
+#layout.twocols &#col1,
+#layout.minimal-layout,
+#layout.poor-layout,
+#layout.twocols.compact-layout,
+#layout.twocols.default-layout {
+ .check-statistics .bubble.top-right-aligned {
+ transform: translate(-50%, 0); // default what progress-bar() defined
+ border-top-right-radius: .25em; // default what progress-bar() defined
+
+ &::before {
+ visibility: visible;
+ }
+
+ svg {
+ display: none;
+ }
+ }
+}
diff --git a/public/css/widget/comment-popup.less b/public/css/widget/comment-popup.less
new file mode 100644
index 0000000..4012697
--- /dev/null
+++ b/public/css/widget/comment-popup.less
@@ -0,0 +1,74 @@
+.comment-popup {
+ font-size: 1em / .857em; // // default font size / footer font size = 12px
+ height: 6em;
+ border-radius: 0.25em;
+ border: 1px solid;
+ border-color: @gray-light;
+ background-color: @body-bg-color;
+}
+
+.comment-wrapper {
+ position: relative;
+
+ .comment-popup {
+ position: absolute;
+ top: 2.5em;
+ left: -1.6em;
+ z-index: 1;
+ display: none;
+ width: 50em;
+ }
+
+ .comment-popup:before {
+ content: '';
+ position: absolute;
+ display: inline-block;
+ width: 1.5em;
+ height: 1.5em;
+ left: 1.5em;
+ top: ~"calc(-0.75em - 1px)";
+ border-left: 1px solid @gray-light;
+ border-top: 1px solid @gray-light;
+ background-color: @body-bg-color;
+ z-index: -1;
+ transform: rotate(45deg);
+ }
+}
+
+ul.item-list li:last-child .comment-wrapper {
+ .comment-popup {
+ top: -7em;
+ }
+
+ .comment-popup:before {
+ bottom: ~"calc(-0.75em - 1px)";
+ top: unset;
+ transform: rotate(225deg);
+ }
+}
+
+.comment-wrapper:hover .comment-popup {
+ display: block
+}
+
+#layout {
+ &.twocols {
+ .comment-popup {
+ width: 35em;
+ }
+
+ &.poor-layout,
+ &.compact-layout {
+ .comment-popup {
+ width: 25em;
+ }
+ }
+ }
+
+ &.poor-layout,
+ &.minimal-layout {
+ .comment-popup {
+ width: 25em;
+ }
+ }
+}
diff --git a/public/css/widget/custom-var-table.less b/public/css/widget/custom-var-table.less
new file mode 100644
index 0000000..46f1984
--- /dev/null
+++ b/public/css/widget/custom-var-table.less
@@ -0,0 +1,60 @@
+.custom-var-table {
+ .level-1 th {
+ padding-left: .5em;
+ }
+
+ .level-2 th {
+ padding-left: 1em;
+ }
+
+ .level-3 th {
+ padding-left: 1.5em;
+ }
+
+ .level-4 th {
+ padding-left: 2em;
+ }
+
+ .level-5 th {
+ padding-left: 2.5em;
+ }
+
+ .level-6 th {
+ padding-left: 3em;
+ }
+
+ thead th {
+ padding-left: 0;
+ text-align: left;
+ font-weight: bold;
+ font-size: 1.167em;
+
+ > span {
+ :nth-child(1),
+ :nth-child(2) {
+ display: none;
+ }
+ }
+ }
+
+ &.can-collapse thead th > span, // Icinga Web 2 < 2.12
+ &[data-can-collapse] thead th > span { // >= 2.12
+ :nth-child(1) {
+ display: none;
+ }
+
+ :nth-child(2) {
+ display: inline-block;
+ }
+ }
+
+ &.collapsed thead th > span {
+ :nth-child(1) {
+ display: inline-block;
+ }
+
+ :nth-child(2) {
+ display: none;
+ }
+ }
+}
diff --git a/public/css/widget/donut-container.less b/public/css/widget/donut-container.less
new file mode 100644
index 0000000..6fc4466
--- /dev/null
+++ b/public/css/widget/donut-container.less
@@ -0,0 +1,24 @@
+.donut-container {
+ .card();
+
+ h2 {
+ margin: 0;
+ }
+
+ .state-badges {
+ text-align: center;
+ }
+
+ &:not(:last-of-type) {
+ margin-right: 1em;
+ margin-bottom: 1em;
+ }
+
+ .donut {
+ margin: 0 auto;
+ }
+}
+
+#layout.minimal-layout .donut-container {
+ width: 100%;
+}
diff --git a/public/css/widget/downtime-card.less b/public/css/widget/downtime-card.less
new file mode 100644
index 0000000..37dd8a5
--- /dev/null
+++ b/public/css/widget/downtime-card.less
@@ -0,0 +1,10 @@
+.downtime-progress {
+ .progress-bar();
+
+ &.progress-bar .timeline .downtime-elapsed {
+ &::before,
+ > .bar {
+ background-color: @state-ok;
+ }
+ }
+}
diff --git a/public/css/widget/group-grid.less b/public/css/widget/group-grid.less
new file mode 100644
index 0000000..94b5b13
--- /dev/null
+++ b/public/css/widget/group-grid.less
@@ -0,0 +1,42 @@
+// HostGroup- and -ServiceGroupGrid styles
+
+ul.item-table.group-grid {
+ grid-template-columns: repeat(auto-fit, 15em);
+ grid-gap: 1em 2em;
+
+ .table-row {
+ margin: -.25em;
+ padding: .25em;
+ border-radius: .5em;
+ }
+
+ li.group-grid-cell {
+ .title {
+ align-items: center;
+ }
+
+ .visual {
+ margin-right: 1em;
+ }
+
+ .content {
+ line-height: 1;
+
+ a {
+ display: inline-block;
+ max-width: 10em;
+ text-align: center;
+ }
+ }
+
+ .state-badge {
+ width: 2.5em;
+ height: 2.5em;
+ line-height: 2;
+ }
+ }
+}
+
+.content.full-width ul.item-table.group-grid {
+ margin: 0 1em;
+}
diff --git a/public/css/widget/host-state-badges.less b/public/css/widget/host-state-badges.less
new file mode 100644
index 0000000..d55a45c
--- /dev/null
+++ b/public/css/widget/host-state-badges.less
@@ -0,0 +1,3 @@
+.host-state-badges {
+ .state-badges();
+}
diff --git a/public/css/widget/key-value-list.less b/public/css/widget/key-value-list.less
new file mode 100644
index 0000000..cd99f5f
--- /dev/null
+++ b/public/css/widget/key-value-list.less
@@ -0,0 +1,19 @@
+.key-value-list {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+
+ li {
+ display: flex;
+ }
+
+ li > span {
+ padding: .25em;
+
+ &.label {
+ display: block;
+ padding-left: 0;
+ width: 12em;
+ }
+ }
+}
diff --git a/public/css/widget/migrate-popup.less b/public/css/widget/migrate-popup.less
new file mode 100644
index 0000000..8f9586b
--- /dev/null
+++ b/public/css/widget/migrate-popup.less
@@ -0,0 +1,181 @@
+#migrate-popup {
+ @transitionLength: 350ms;
+
+ display: flex;
+ min-width: 16em;
+ z-index: 1000;
+ position: fixed;
+ top: 0;
+ right: 4em;
+ pointer-events: none;
+ line-height: 1.5em;
+
+ .transform(translateY(-100%));
+ .transition(transform @transitionLength ease-in);
+
+ &.active {
+ .transform(translateY(0%));
+ .transition(transform @transitionLength ease-out);
+ }
+
+ .suggestion-area {
+ .transform(translateY(0%));
+ .transition(transform 0s linear @transitionLength);
+ }
+
+ &.active .suggestion-area {
+ .transition(transform @transitionLength ease-out);
+ }
+
+ &.minimized .suggestion-area {
+ .transform(translateY(-100%));
+ .transition(transform @transitionLength ease-in);
+ }
+
+ &.hidden .suggestion-area {
+ .transition(none);
+ }
+
+ .minimizer {
+ width: 1.25em;
+ height: 1.5em;
+ margin-left: -1px;
+ z-index: 1;
+ pointer-events: auto;
+
+ border-bottom-right-radius: 4px;
+ background-color: @body-bg-color;
+
+ .transition(none);
+
+ i:before {
+ width: 1em;
+ margin: .1em 0 0 0;
+ content: '\f102';
+ font-size: 1.25em;
+ cursor: pointer;
+ color: @gray-light;
+ }
+
+ i:hover:before {
+ color: @menu-highlight-color;
+ }
+ }
+
+ &.minimized .minimizer {
+ border-bottom-left-radius: 4px;
+ .transition(border-bottom-left-radius 0s linear @transitionLength);
+ }
+
+ &.hidden .minimizer i:before {
+ content: '\f103';
+ }
+
+ &:not(.active) .suggestion-area, &.hidden .suggestion-area {
+ box-shadow: none;
+ }
+
+ .suggestion-area {
+ display: flex;
+ flex-direction: column-reverse;
+
+ padding: .75em;
+ flex-grow: 1;
+ pointer-events: auto;
+ font-size: .75em;
+
+ background: @body-bg-color;
+ border-radius: 0 0 4px 4px;
+ box-shadow: 0 0 1em 0 rgba(0, 0, 0, 0.3);
+
+ button {
+ .link-button();
+ }
+
+ p {
+ display: none;
+ margin-bottom: .5em;
+ color: @text-color-light;
+ }
+
+ form ~ .monitoring-migration-hint,
+ .search-migration-suggestions:not(:empty) + .search-migration-hint,
+ .monitoring-migration-suggestions:not(:empty) + .monitoring-migration-hint {
+ display: block;
+ }
+
+ & > button.close {
+ margin-left: auto;
+ margin-top: 1em;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ ul {
+ padding: 0;
+ margin: 0;
+ list-style-type: none;
+ }
+
+ li {
+ margin: .5em 0;
+ display: flex;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+
+ &:first-of-type {
+ margin-top: 0;
+ }
+ }
+
+ li {
+ :not(:last-child) {
+ margin-right: .5em;
+ }
+
+ button:hover{
+ opacity: 0.8;
+ }
+
+ button[value="1"] {
+ flex-grow: 1;
+
+ color: @text-color;
+ text-decoration: underline;
+ }
+
+ button[value="0"] {
+ i:before {
+ margin: 0;
+ content: '\e804';
+ }
+ }
+ }
+
+ form {
+ width: 100%;
+
+ .control-group {
+ display: flex;
+ align-items: center;
+
+ .control-label-group {
+ margin-right: .5em;
+ }
+
+ label {
+ margin-left: auto;
+ }
+ }
+ }
+
+ .search-migration-suggestions:not(:empty) ~ form,
+ .search-migration-suggestions:not(:empty) ~ .monitoring-migration-suggestions:not(:empty) {
+ margin-bottom: .5em;
+ }
+ }
+}
diff --git a/public/css/widget/monitoring-health.less b/public/css/widget/monitoring-health.less
new file mode 100644
index 0000000..f0ed252
--- /dev/null
+++ b/public/css/widget/monitoring-health.less
@@ -0,0 +1,136 @@
+.monitoring-health {
+ max-width: 65em;
+
+ > section:not(:last-child) {
+ margin-bottom: 4em;
+ }
+
+ .vertical-key-value .value {
+ display: inline-block;
+ margin-bottom: .25em;
+ }
+
+ .check-summary {
+ padding: .5em 0;
+
+ .col {
+ padding: 0 1em .5em 1em;
+ }
+
+ .col:not(:last-child) {
+ border-right: 1px solid @gray-light;
+ }
+ }
+
+ .check-summary,
+ .instance-features {
+ .rounded-corners();
+ border: 1px solid;
+ border-color: @gray-light;
+ display: flex;
+ width: 100%;
+ }
+
+ .check-summary,
+ .col-content,
+ .icinga-info {
+ width: 100%;
+
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-around;
+ }
+
+
+ .icinga-info {
+ margin-bottom: -2em;
+
+ .vertical-key-value {
+ margin-bottom: 2em;
+ }
+ }
+
+ .col {
+ flex: 1 1 auto;
+ text-align: center;
+
+ > h3 {
+ margin: 0 0 1em;
+ }
+ }
+
+ .icinga-health {
+ .rounded-corners();
+ margin-bottom: 2em;
+ padding: 1em;
+ text-align: center;
+ width: 100%;
+ font-weight: bold;
+
+ &.up {
+ border: 1px solid;
+ border-color: @color-up;
+ color: @color-up;
+ }
+
+ &.down {
+ background-color: @color-down;
+ color: @body-bg-color;
+ }
+ }
+
+ .instance-features {
+ .control-group {
+ flex: 1 1 auto;
+ margin: .5em 0;
+ padding: .5em;
+ width: 0;
+
+ display: flex;
+ align-items: center;
+ flex-direction: column-reverse;
+ justify-content: flex-end;
+
+ &:not(:last-of-type) {
+ border-right: 1px solid @gray-light;
+ }
+
+ .control-label-group {
+ font-size: 10/12em;
+ margin-right: 0;
+ margin-top: 1em;
+ padding: 0;
+ text-align: center;
+ width: auto;
+
+ label {
+ text-align: center;
+ }
+ }
+
+ .toggle-switch {
+ margin: 0;
+ }
+ }
+ }
+}
+
+#layout.minimal-layout {
+ .icinga-info {
+ .vertical-key-value {
+ width: 100%;
+ }
+ }
+
+ .instance-features {
+ flex-wrap: wrap;
+
+ .control-group {
+ width: 33%;
+
+ &:nth-child(3n) {
+ border-right: none;
+ }
+ }
+ }
+}
diff --git a/public/css/widget/notice.less b/public/css/widget/notice.less
new file mode 100644
index 0000000..7067665
--- /dev/null
+++ b/public/css/widget/notice.less
@@ -0,0 +1,23 @@
+// Style
+
+.notice {
+ @margin: 1em / 1.25;
+ @padding: .75em / 1.25;
+
+ .rounded-corners();
+ padding: @padding;
+ color: @text-color-on-icinga-blue;
+ background-color: @state-warning;
+ font-weight: bold;
+ font-size: 1.25em;
+
+ // Layout
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin: 0 @margin @margin @margin;
+
+ > span {
+ .text-ellipsis();
+ }
+}
diff --git a/public/css/widget/object-features.less b/public/css/widget/object-features.less
new file mode 100644
index 0000000..b39414d
--- /dev/null
+++ b/public/css/widget/object-features.less
@@ -0,0 +1,53 @@
+form.object-features {
+ span.description {
+ text-align: left;
+ }
+
+ .control-label-group {
+ text-align: left;
+ margin-right: 0;
+ width: @name-value-table-name-width;
+ color: @text-color-light;
+
+ label {
+ font-size: inherit;
+ }
+ }
+
+ .control-group {
+ margin-top: 0;
+ margin-bottom: 0;
+
+ &.indeterminate {
+ justify-content: flex-start;
+
+ .control-label-group {
+ flex: 0 1 auto;
+ }
+
+ select {
+ width: auto;
+ flex: 0 1 auto;
+
+ & + span.hint {
+ flex: 0 1 auto;
+ }
+ }
+ }
+ }
+
+ .toggle-switch {
+ margin-left: @table-column-padding;
+ }
+
+ select {
+ margin-right: .5em;
+ margin-left: @table-column-padding;
+
+ & + span.hint {
+ margin: .35em;
+ color: @gray-light;
+ font-style: italic;
+ }
+ }
+}
diff --git a/public/css/widget/object-inspection.less b/public/css/widget/object-inspection.less
new file mode 100644
index 0000000..60a99bf
--- /dev/null
+++ b/public/css/widget/object-inspection.less
@@ -0,0 +1,17 @@
+// Style
+
+// Layout
+
+.inspection-detail {
+ th {
+ width: 16em;
+ }
+
+ pre, td {
+ white-space: break-spaces;
+ word-break: break-word;
+ -webkit-hyphens: auto;
+ -ms-hyphens: auto;
+ hyphens: auto;
+ }
+}
diff --git a/public/css/widget/object-meta-info.less b/public/css/widget/object-meta-info.less
new file mode 100644
index 0000000..82ad044
--- /dev/null
+++ b/public/css/widget/object-meta-info.less
@@ -0,0 +1,95 @@
+// Style
+
+.object-meta-info {
+ .vertical-key-value .value {
+ font-weight: normal;
+ }
+
+ .vertical-key-value:first-child {
+ text-align: left;
+ }
+
+ .vertical-key-value:last-child {
+ text-align: right;
+ }
+}
+
+.object-meta-info-control {
+ .link-button();
+
+ .rounded-corners(0 0 .5em .5em);
+ border: 1px solid;
+ border-color: @gray-lighter;
+ border-top-width: 0;
+ background: @body-bg-color;
+}
+
+// Layout
+
+.object-meta-info {
+ display: flex;
+ justify-content: space-between;
+
+ .horizontal-key-value {
+ padding: 0;
+
+ .key {
+ width: 9em;
+ }
+ }
+
+ .vertical-key-value {
+ &:first-child {
+ .text-ellipsis();
+ margin-right: 1em;
+ }
+
+ &:last-child {
+ white-space: nowrap;
+ margin-left: 1em;
+ }
+ }
+
+ .horizontal-key-value .value,
+ .horizontal-key-value .key,
+ .vertical-key-value .value,
+ .vertical-key-value .key {
+ font-size: 16/18em;
+ line-height: 18/16; // compensate smaller font-size (1/font-size)
+ }
+}
+
+.object-meta-info-control {
+ position: absolute;
+ right: 2.25em;
+ bottom: -2.5em; // height + margin-bottom
+ height: 2em;
+ padding: .25em;
+
+ .collapse-icon,
+ .expand-icon {
+ font-size: 1.2em;
+
+ &:before {
+ margin-right: 0;
+ }
+ }
+
+ .collapse-icon {
+ display: block;
+ }
+
+ .expand-icon {
+ display: none;
+ }
+}
+
+.collapsed + .object-meta-info-control {
+ .collapse-icon {
+ display: none;
+ }
+
+ .expand-icon {
+ display: block;
+ }
+}
diff --git a/public/css/widget/object-statistics.less b/public/css/widget/object-statistics.less
new file mode 100644
index 0000000..5a9c97a
--- /dev/null
+++ b/public/css/widget/object-statistics.less
@@ -0,0 +1,44 @@
+ul.object-statistics {
+ // Reset defaults
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+
+ display: flex;
+ align-items: center;
+
+ > li:not(:last-child) {
+ margin-right: 1em;
+ }
+}
+
+.object-statistics-graph .donut-graph {
+ height: 2em;
+ width: 2em;
+ vertical-align: middle;
+}
+
+.object-statistics-total a {
+ display: inline-block;
+ line-height: 1;
+ position: relative;
+
+ &:hover:before {
+ .rounded-corners();
+ background-color: @gray-lighter;
+ content: "";
+ display: block;
+ z-index: 1;
+
+ position: absolute;
+ bottom: -.4em;
+ left: -.4em;
+ top: -.4em;
+ right: -.4em;
+ }
+
+ .vertical-key-value {
+ position: relative;
+ z-index: 2;
+ }
+}
diff --git a/public/css/widget/performance-data-table.less b/public/css/widget/performance-data-table.less
new file mode 100644
index 0000000..26c18c8
--- /dev/null
+++ b/public/css/widget/performance-data-table.less
@@ -0,0 +1,62 @@
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+.performance-data-table {
+ width: 100%;
+ overflow-x: auto;
+ display: block;
+
+ tr:not(:last-child) {
+ border-bottom: 1px solid @gray-lighter;
+ }
+
+ td {
+ text-align: right;
+ .text-ellipsis();
+ }
+
+ th {
+ font-size: .857em;
+ font-weight: normal;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ }
+
+ thead {
+ border-bottom: 1px solid @gray-light;
+ }
+
+ th:first-child,
+ td:first-child {
+ padding-left: 0;
+ }
+
+ .title {
+ text-align: left;
+ width: 100%;
+ }
+
+ td.title {
+ font-weight: bold;
+ }
+
+ .sparkline-col {
+ min-width: 1.75em;
+ width: 1.75em;
+ padding: 2/12em 0;
+ .invalid-perfdata {
+ font-size: 1.25em;
+ vertical-align: text-bottom;
+ color: @color-warning;
+ }
+ }
+
+ .inline-pie > svg {
+ vertical-align: middle;
+ }
+
+ .no-value {
+ color: @gray-semilight;
+ text-align: center;
+ display: block;
+ }
+}
diff --git a/public/css/widget/quick-actions.less b/public/css/widget/quick-actions.less
new file mode 100644
index 0000000..bddea43
--- /dev/null
+++ b/public/css/widget/quick-actions.less
@@ -0,0 +1,47 @@
+.quick-actions {
+ display: flex;
+ flex-wrap: wrap;
+ list-style-type: none;
+ margin: 0 -.5em;
+ padding: 0;
+
+ a {
+ text-decoration: none;
+ }
+
+ a,
+ button {
+ padding: .25em;
+ .rounded-corners();
+ display: inline-flex;
+ align-items: baseline;
+
+ &:hover {
+ background: @gray-lighter;
+ }
+ }
+
+ li {
+ margin: 0 .25em .5em .25em;
+ vertical-align: middle;
+ white-space: nowrap;
+ }
+}
+
+.controls:not(.default-layout) > .quick-actions:last-child,
+.controls > .quick-actions:last-child {
+ margin-bottom: 0;
+}
+
+#layout.twocols:not(.wide-layout) {
+ .quick-actions {
+ justify-content: space-between;
+ min-width: 100%;
+ }
+}
+
+#layout.wide-layout .controls {
+ .quick-actions {
+ float: left;
+ }
+}
diff --git a/public/css/widget/service-state-badges.less b/public/css/widget/service-state-badges.less
new file mode 100644
index 0000000..8a97faa
--- /dev/null
+++ b/public/css/widget/service-state-badges.less
@@ -0,0 +1,3 @@
+.service-state-badges {
+ .state-badges();
+}
diff --git a/public/css/widget/state-change.less b/public/css/widget/state-change.less
new file mode 100644
index 0000000..adc8d42
--- /dev/null
+++ b/public/css/widget/state-change.less
@@ -0,0 +1,128 @@
+.state-change {
+ display: inline-flex;
+
+ &.reversed-state-balls {
+ // This is needed, because with ~ we can address only subsequent nodes
+ flex-direction: row-reverse;
+ }
+
+ .state-ball {
+ .box-shadow(0, 0, 0, 1px, @body-bg-color);
+ }
+
+ // Same on same
+ .state-ball ~ .state-ball {
+ &.ball-size-xs {
+ margin-left: -.05em;
+ }
+
+ &.ball-size-s {
+ margin-left: -.15em;
+ }
+
+ &.ball-size-m {
+ margin-left: -.275em;
+ }
+
+ &.ball-size-ml {
+ margin-left: -.375em;
+ }
+
+ &.ball-size-l,
+ &.ball-size-xl {
+ margin-left: -.875em;
+ }
+ }
+
+ // big left, smaller right
+ &:not(.reversed-state-balls) .ball-size-l ~ .state-ball {
+ &.ball-size-ml {
+ margin-top: .25em;
+ margin-left: -.5em;
+ margin-right: .25em;
+ }
+ }
+
+ // smaller left, big right
+ &.reversed-state-balls .ball-size-l ~ .state-ball {
+ &.ball-size-ml {
+ z-index: -1;
+ margin-top: .25em;
+ margin-right: -.5em;
+ }
+ }
+
+ .state-ball.state-ok,
+ .state-ball.state-up,
+ .state-pending {
+ &.ball-size-l,
+ &.ball-size-xl {
+ background-color: @body-bg-color;
+ }
+ }
+
+ // Avoid transparency on overlapping solid state-change state-balls
+ .state-ball.handled {
+ position: relative;
+ opacity: 1;
+
+ i {
+ position: relative;
+ z-index: 3;
+ }
+
+ &:before {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ border-radius: 50%;
+ opacity: .6;
+ z-index: 2
+ }
+
+ &:after {
+ content: "";
+ display: block;
+ position: absolute;
+ top: -2px;
+ left: -2px;
+ right: -2px;
+ bottom: -2px;
+ border-radius: 50%;
+ background-color: @body-bg-color;
+ z-index: 1;
+ }
+
+ &.state-pending:before {
+ background-color: @color-pending;
+ }
+
+ &.state-down:before {
+ background-color: @color-down;
+ }
+
+ &.state-warning:before {
+ background-color: @color-warning;
+ }
+
+ &.state-critical:before {
+ background-color: @color-critical;
+ }
+
+ &.state-unknown:before {
+ background-color: @color-unknown;
+ }
+ }
+}
+
+.overdue .state-change .state-ball {
+ .box-shadow(0, 0, 0, 1px, @gray-lighter);
+
+ &.handled:after {
+ background-color: @gray-lighter;
+ }
+}
diff --git a/public/css/widget/table-layout.less b/public/css/widget/table-layout.less
new file mode 100644
index 0000000..f67ec0f
--- /dev/null
+++ b/public/css/widget/table-layout.less
@@ -0,0 +1,72 @@
+// HostGroup- and -ServiceGroupTable styles
+
+.item-table.table-layout {
+ --columns: 1;
+}
+
+ul.item-table.table-layout {
+ grid-template-columns: 1fr repeat(var(--columns), auto);
+
+ > li {
+ display: contents;
+
+ &:hover,
+ &.active {
+ .col, &::before, &::after {
+ // The li might get a background on hover. Though, this won't be visible
+ // as it has no box model since we apply display:contents to it.
+ background-color: inherit;
+ }
+ }
+ }
+
+ li:not(:last-of-type) {
+ .col {
+ border-bottom: 1px solid @gray-light;
+ }
+
+ .visual {
+ border-bottom: 1px solid @default-bg;
+ }
+ }
+
+ > .table-row {
+ &:not(:last-of-type) .title .visual {
+ margin-bottom: ~"calc(-.5em - 1px)";
+ }
+
+ .col {
+ padding: .5em 0;
+ }
+
+ .col:not(:last-child) {
+ padding-right: 1em;
+ }
+ }
+}
+
+.content.full-width ul.item-table.table-layout {
+ // Again, since the li has no box model, it cannot have padding. So the first
+ // and last child need to get the left and right padding respectively.
+ // But we don't want to have a border that spans to the very right or left,
+ // so pseudo elements are required. We could add empty cells instead, but
+ // that would require hard coding the width here, which I'd like to avoid.
+
+ grid-template-columns: ~"auto 1fr repeat(calc(var(--columns) + 1), auto)";
+
+ > li.table-row {
+ &::before, &::after {
+ display: inline-block;
+ content: '\00a0';
+ margin-bottom: 1px;
+ }
+
+ &::before {
+ padding-left: inherit;
+ }
+
+ &::after {
+ padding-right: inherit;
+ }
+ }
+}
diff --git a/public/css/widget/tag-list.less b/public/css/widget/tag-list.less
new file mode 100644
index 0000000..fe96691
--- /dev/null
+++ b/public/css/widget/tag-list.less
@@ -0,0 +1,31 @@
+.tag-list {
+ line-height: 1.5;
+ list-style-type: none;
+ margin: -.25em 0 0 0; // TODO: This is wrong here, if at all this must be part of wherever this widget is placed
+ padding: 0;
+
+ > li {
+ display: inline-block;
+
+ &:not(:last-child) {
+ margin-right: .417em;
+ margin-bottom: .25em; // TODO: Really? It's an inline ul, bottom margin is outer layout..
+ }
+
+ i {
+ opacity: 0.8;
+ }
+ }
+
+ > li > a {
+ .rounded-corners();
+ background-color: @gray-lighter;
+ display: block;
+ padding: .25em .5em;
+
+ &:hover {
+ background-color: @gray-light;
+ text-decoration: none;
+ }
+ }
+}
diff --git a/public/css/widget/view-mode-switcher.less b/public/css/widget/view-mode-switcher.less
new file mode 100644
index 0000000..7eda2a8
--- /dev/null
+++ b/public/css/widget/view-mode-switcher.less
@@ -0,0 +1,45 @@
+.view-mode-switcher {
+ list-style-type: none;
+ margin: 0 0 0.25em 0;
+ padding: 0;
+ display: flex;
+
+ input {
+ display: none;
+ }
+
+ label {
+ color: @control-color;
+ line-height: 1;
+ background: @low-sat-blue;
+ padding: 14/16*.25em 14/16*.5em;
+ font-size: 16/12em;
+ height: 24/16em; // desired pixel height / font-size
+ cursor: pointer;
+
+ &:first-of-type {
+ border-top-left-radius: 0.25em;
+ border-bottom-left-radius: 0.25em;
+ }
+
+ &:last-of-type {
+ border-top-right-radius: 0.25em;
+ border-bottom-right-radius: 0.25em;
+ }
+
+ &:not(:last-of-type) {
+ border-right: 1px solid @low-sat-blue-dark;
+ }
+
+ i {
+ // fix height for Chrome
+ display: block;
+ }
+ }
+
+ input[checked] + label {
+ background-color: @control-color;
+ color: @text-color-on-icinga-blue;
+ cursor: default;
+ }
+}
diff --git a/public/js/action-list.js b/public/js/action-list.js
new file mode 100644
index 0000000..69cab05
--- /dev/null
+++ b/public/js/action-list.js
@@ -0,0 +1,788 @@
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+;(function (Icinga) {
+
+ "use strict";
+
+ try {
+ var notjQuery = require('icinga/icinga-php-library/notjQuery');
+ } catch (e) {
+ console.warn('Unable to provide input enrichments. Libraries not available:', e);
+ return;
+ }
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ class ActionList extends Icinga.EventListener {
+ constructor(icinga) {
+ super(icinga);
+
+ this.on('click', '.action-list [data-action-item]:not(.page-separator), .action-list [data-action-item] a[href]', this.onClick, this);
+ this.on('close-column', '#main > #col2', this.onColumnClose, this);
+ this.on('column-moved', this.onColumnMoved, this);
+
+ this.on('rendered', '#main .container', this.onRendered, this);
+ this.on('keydown', '#body', this.onKeyDown, this);
+
+ this.on('click', '.load-more[data-no-icinga-ajax] a', this.onLoadMoreClick, this);
+ this.on('keypress', '.load-more[data-no-icinga-ajax] a', this.onKeyPress, this);
+
+ this.lastActivatedItemUrl = null;
+ this.lastTimeoutId = null;
+ this.isProcessingLoadMore = false;
+ this.activeRequests = {};
+ }
+
+ /**
+ * Parse the filter query contained in the given URL query string
+ *
+ * @param {string} queryString
+ *
+ * @returns {array}
+ */
+ parseSelectionQuery(queryString) {
+ return queryString.split('|');
+ }
+
+ /**
+ * Suspend auto refresh for the given item's container
+ *
+ * @param {Element} item
+ *
+ * @return {string} The container's id
+ */
+ suspendAutoRefresh(item) {
+ const container = item.closest('.container');
+ container.dataset.suspendAutorefresh = '';
+
+ return container.id;
+ }
+
+ /**
+ * Enable auto refresh on the given container
+ *
+ * @param {string} containerId
+ */
+ enableAutoRefresh(containerId) {
+ delete document.getElementById(containerId).dataset.suspendAutorefresh;
+ }
+
+ onClick(event) {
+ let _this = event.data.self;
+ let target = event.currentTarget;
+
+ if (target.matches('a') && (! target.matches('.subject') || event.ctrlKey || event.metaKey)) {
+ return true;
+ }
+
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ event.stopPropagation();
+
+ let item = target.closest('[data-action-item]');
+ let list = target.closest('.action-list');
+ let activeItems = _this.getActiveItems(list);
+ let toActiveItems = [],
+ toDeactivateItems = [];
+
+ const isBeingMultiSelected = list.matches('[data-icinga-multiselect-url]')
+ && (event.ctrlKey || event.metaKey || event.shiftKey);
+
+ if (isBeingMultiSelected) {
+ if (event.ctrlKey || event.metaKey) {
+ if (item.classList.contains('active')) {
+ toDeactivateItems.push(item);
+ } else {
+ toActiveItems.push(item);
+ }
+ } else {
+ document.getSelection().removeAllRanges();
+
+ let allItems = _this.getAllItems(list);
+
+ let startIndex = allItems.indexOf(item);
+ if(startIndex < 0) {
+ startIndex = 0;
+ }
+
+ let endIndex = activeItems.length ? allItems.indexOf(activeItems[0]) : 0;
+ if (startIndex > endIndex) {
+ toActiveItems = allItems.slice(endIndex, startIndex + 1);
+ } else {
+ endIndex = activeItems.length ? allItems.indexOf(activeItems[activeItems.length - 1]) : 0;
+ toActiveItems = allItems.slice(startIndex, endIndex + 1);
+ }
+
+ toDeactivateItems = activeItems.filter(item => ! toActiveItems.includes(item));
+ toActiveItems = toActiveItems.filter(item => ! activeItems.includes(item));
+ }
+ } else {
+ toDeactivateItems = activeItems;
+ toActiveItems.push(item);
+ }
+
+ if (activeItems.length === 1
+ && toActiveItems.length === 0
+ && _this.icinga.loader.getLinkTargetFor($(target)).attr('id') === 'col2'
+ ) {
+ _this.icinga.ui.layout1col();
+ _this.enableAutoRefresh('col1');
+ return;
+ }
+
+ let dashboard = list.closest('.dashboard');
+ if (dashboard) {
+ dashboard.querySelectorAll('.action-list').forEach(otherList => {
+ if (otherList !== list) {
+ toDeactivateItems.push(..._this.getAllItems(otherList));
+ }
+ })
+ }
+
+ let lastActivatedUrl = null;
+ if (toActiveItems.includes(item)) {
+ lastActivatedUrl = item.dataset.icingaDetailFilter;
+ } else if (activeItems.length > 1) {
+ lastActivatedUrl = activeItems[activeItems.length - 1] === item
+ ? activeItems[activeItems.length - 2].dataset.icingaDetailFilter
+ : activeItems[activeItems.length - 1].dataset.icingaDetailFilter;
+ }
+
+ _this.clearSelection(toDeactivateItems);
+ _this.setActive(toActiveItems);
+
+ if (! dashboard) {
+ _this.addSelectionCountToFooter(list);
+ }
+
+ _this.setLastActivatedItemUrl(lastActivatedUrl);
+ _this.loadDetailUrl(list, target.matches('a') ? target.getAttribute('href') : null);
+ }
+
+ /**
+ * Add the selection count to footer if list allow multi selection
+ *
+ * @param list
+ */
+ addSelectionCountToFooter(list) {
+ if (! list.matches('[data-icinga-multiselect-url]')) {
+ return;
+ }
+
+ let activeItemCount = this.getActiveItems(list).length;
+ let footer = list.closest('.container').querySelector('.footer');
+
+ // For items that do not have a bottom status bar like Downtimes, Comments...
+ if (footer === null) {
+ footer = notjQuery.render(
+ '<div class="footer" data-action-list-automatically-added>' +
+ '<div class="selection-count"><span class="selected-items"></span></div>' +
+ '</div>'
+ )
+
+ list.closest('.container').appendChild(footer);
+ }
+
+ let selectionCount = footer.querySelector('.selection-count');
+ if (selectionCount === null) {
+ selectionCount = notjQuery.render(
+ '<div class="selection-count"><span class="selected-items"></span></div>'
+ );
+
+ footer.prepend(selectionCount);
+ }
+
+ let selectedItems = selectionCount.querySelector('.selected-items');
+ selectedItems.innerText = activeItemCount
+ ? list.dataset.icingaMultiselectCountLabel.replace('%d', activeItemCount)
+ : list.dataset.icingaMultiselectHintLabel;
+
+ if (activeItemCount === 0) {
+ selectedItems.classList.add('hint');
+ } else {
+ selectedItems.classList.remove('hint');
+ }
+ }
+
+ /**
+ * Key navigation for .action-list
+ *
+ * - `Shift + ArrowUp|ArrowDown` = Multiselect
+ * - `ArrowUp|ArrowDown` = Select next/previous
+ * - `Ctrl|cmd + A` = Select all on currect page
+ *
+ * @param event
+ */
+ onKeyDown(event) {
+ let _this = event.data.self;
+ let list = null;
+ let pressedArrowDownKey = event.key === 'ArrowDown';
+ let pressedArrowUpKey = event.key === 'ArrowUp';
+ let focusedElement = document.activeElement;
+
+ if (
+ _this.isProcessingLoadMore
+ || ! event.key // input auto-completion is triggered
+ || (event.key.toLowerCase() !== 'a' && ! pressedArrowDownKey && ! pressedArrowUpKey)
+ ) {
+ return;
+ }
+
+ if (focusedElement && (
+ focusedElement.matches('#main > :scope')
+ || focusedElement.matches('#body'))
+ ) {
+ let activeItem = document.querySelector(
+ '#main > .container > .content > .action-list [data-action-item].active'
+ );
+ if (activeItem) {
+ list = activeItem.closest('.action-list');
+ } else {
+ list = focusedElement.querySelector('#main > .container > .content > .action-list');
+ }
+ } else if (focusedElement) {
+ list = focusedElement.closest('.content > .action-list');
+ }
+
+ if (! list) {
+ return;
+ }
+
+ let isMultiSelectableList = list.matches('[data-icinga-multiselect-url]');
+
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
+ if (! isMultiSelectableList) {
+ return;
+ }
+
+ event.preventDefault();
+ _this.selectAll(list);
+ return;
+ }
+
+ event.preventDefault();
+
+ let allItems = _this.getAllItems(list);
+ let firstListItem = allItems[0];
+ let lastListItem = allItems[allItems.length -1];
+ let activeItems = _this.getActiveItems(list);
+ let markAsLastActive = null; // initialized only if it is different from toActiveItem
+ let toActiveItem = null;
+ let wasAllSelected = activeItems.length === allItems.length;
+ let lastActivatedItem = list.querySelector(
+ `[data-icinga-detail-filter="${ _this.lastActivatedItemUrl }"]`
+ );
+
+ if (! lastActivatedItem && activeItems.length) {
+ lastActivatedItem = activeItems[activeItems.length - 1];
+ }
+
+ let directionalNextItem = _this.getDirectionalNext(lastActivatedItem, event.key);
+
+ if (activeItems.length === 0) {
+ toActiveItem = pressedArrowDownKey ? firstListItem : lastListItem;
+ // reset all on manual page refresh
+ _this.clearSelection(activeItems);
+ if (toActiveItem.classList.contains('load-more')) {
+ toActiveItem = toActiveItem.previousElementSibling;
+ }
+ } else if (isMultiSelectableList && event.shiftKey) {
+ if (activeItems.length === 1) {
+ toActiveItem = directionalNextItem;
+ } else if (wasAllSelected && (
+ (lastActivatedItem !== firstListItem && pressedArrowDownKey)
+ || (lastActivatedItem !== lastListItem && pressedArrowUpKey)
+ )) {
+ if (pressedArrowDownKey) {
+ toActiveItem = lastActivatedItem === lastListItem ? null : lastListItem;
+ } else {
+ toActiveItem = lastActivatedItem === firstListItem ? null : lastListItem;
+ }
+
+ } else if (directionalNextItem && directionalNextItem.classList.contains('active')) {
+ // deactivate last activated by down to up select
+ _this.clearSelection([lastActivatedItem]);
+ if (wasAllSelected) {
+ _this.scrollItemIntoView(lastActivatedItem, event.key);
+ }
+
+ toActiveItem = directionalNextItem;
+ } else {
+ [toActiveItem, markAsLastActive] = _this.findToActiveItem(lastActivatedItem, event.key);
+ }
+ } else {
+ toActiveItem = directionalNextItem ?? lastActivatedItem;
+
+ if (toActiveItem) {
+ if (toActiveItem.classList.contains('load-more')) {
+ clearTimeout(_this.lastTimeoutId);
+ _this.handleLoadMoreNavigate(toActiveItem, lastActivatedItem, event.key);
+ return;
+ }
+
+ _this.clearSelection(activeItems);
+ if (toActiveItem.classList.contains('page-separator')) {
+ toActiveItem = _this.getDirectionalNext(toActiveItem, event.key);
+ }
+ }
+ }
+
+ if (! toActiveItem) {
+ return;
+ }
+
+ _this.setActive(toActiveItem);
+ _this.setLastActivatedItemUrl(
+ markAsLastActive ? markAsLastActive.dataset.icingaDetailFilter : toActiveItem.dataset.icingaDetailFilter
+ );
+ _this.scrollItemIntoView(toActiveItem, event.key);
+ _this.addSelectionCountToFooter(list);
+ _this.loadDetailUrl(list);
+ }
+
+ /**
+ * Get the next list item according to the pressed key (`ArrowUp` or `ArrowDown`)
+ *
+ * @param item The list item from which we want the next item
+ * @param eventKey Pressed key (`ArrowUp` or `ArrowDown`)
+ *
+ * @returns {Element|null}
+ */
+ getDirectionalNext(item, eventKey) {
+ if (! item) {
+ return null;
+ }
+
+ return eventKey === 'ArrowUp' ? item.previousElementSibling : item.nextElementSibling;
+ }
+
+ /**
+ * Find the list item that should be activated next
+ *
+ * @param lastActivatedItem
+ * @param eventKey Pressed key (`ArrowUp` or `ArrowDown`)
+ *
+ * @returns {Element[]}
+ */
+ findToActiveItem(lastActivatedItem, eventKey) {
+ let toActiveItem;
+ let markAsLastActive;
+
+ toActiveItem = this.getDirectionalNext(lastActivatedItem, eventKey);
+
+ while (toActiveItem) {
+ if (! toActiveItem.classList.contains('active')) {
+ break;
+ }
+
+ toActiveItem = this.getDirectionalNext(toActiveItem, eventKey);
+ }
+
+ markAsLastActive = toActiveItem;
+ // if the next/previous sibling element is already active,
+ // mark the last/first active element in list as last active
+ while (markAsLastActive && this.getDirectionalNext(markAsLastActive, eventKey)) {
+ if (! this.getDirectionalNext(markAsLastActive, eventKey).classList.contains('active')) {
+ break;
+ }
+
+ markAsLastActive = this.getDirectionalNext(markAsLastActive, eventKey);
+ }
+
+ return [toActiveItem, markAsLastActive];
+ }
+
+ /**
+ * Select All list items
+ *
+ * @param list The action list
+ */
+ selectAll(list) {
+ let allItems = this.getAllItems(list);
+ let activeItems = this.getActiveItems(list);
+ this.setActive(allItems.filter(item => ! activeItems.includes(item)));
+ this.setLastActivatedItemUrl(allItems[allItems.length -1].dataset.icingaDetailFilter);
+ this.addSelectionCountToFooter(list);
+ this.loadDetailUrl(list);
+ }
+
+ /**
+ * Clear the selection by removing .active class
+ *
+ * @param selectedItems The items with class active
+ */
+ clearSelection(selectedItems) {
+ selectedItems.forEach(item => item.classList.remove('active'));
+ }
+
+ /**
+ * Set the last activated item Url
+ *
+ * @param url
+ */
+ setLastActivatedItemUrl (url) {
+ this.lastActivatedItemUrl = url;
+ }
+
+ /**
+ * Scroll the given item into view
+ *
+ * @param item Item to scroll into view
+ * @param pressedKey Pressed key (`ArrowUp` or `ArrowDown`)
+ */
+ scrollItemIntoView(item, pressedKey) {
+ item.scrollIntoView({block: "nearest"});
+ let directionalNext = this.getDirectionalNext(item, pressedKey);
+
+ if (directionalNext) {
+ directionalNext.scrollIntoView({block: "nearest"});
+ }
+ }
+
+ /**
+ * Load the detail url with selected items
+ *
+ * @param list The action list
+ * @param anchorUrl If any anchor is clicked (e.g. host in service list)
+ */
+ loadDetailUrl(list, anchorUrl = null) {
+ let url = anchorUrl;
+ let activeItems = this.getActiveItems(list);
+
+ if (url === null) {
+ if (activeItems.length > 1) {
+ url = this.createMultiSelectUrl(activeItems);
+ } else {
+ let anchor = activeItems[0].querySelector('[href]');
+ url = anchor ? anchor.getAttribute('href') : null;
+ }
+ }
+
+ if (url === null) {
+ return;
+ }
+
+ const suspendedContainer = this.suspendAutoRefresh(list);
+
+ clearTimeout(this.lastTimeoutId);
+ this.lastTimeoutId = setTimeout(() => {
+ const requestNo = this.lastTimeoutId;
+ this.activeRequests[requestNo] = suspendedContainer;
+ this.lastTimeoutId = null;
+
+ let req = this.icinga.loader.loadUrl(
+ url,
+ this.icinga.loader.getLinkTargetFor($(activeItems[0]))
+ );
+
+ req.always((_, __, errorThrown) => {
+ if (errorThrown !== 'abort') {
+ this.enableAutoRefresh(this.activeRequests[requestNo]);
+ }
+
+ delete this.activeRequests[requestNo];
+ });
+ }, 250);
+ }
+
+ /**
+ * Add .active class to given list item
+ *
+ * @param toActiveItem The list item(s)
+ */
+ setActive(toActiveItem) {
+ if (toActiveItem instanceof HTMLElement) {
+ toActiveItem.classList.add('active');
+ } else {
+ toActiveItem.forEach(item => item.classList.add('active'));
+ }
+ }
+
+ /**
+ * Get the active items from given list
+ *
+ * @param list The action list
+ *
+ * @return array
+ */
+ getActiveItems(list)
+ {
+ let items;
+ if (list.tagName.toLowerCase() === 'table') {
+ items = list.querySelectorAll(':scope > tbody > [data-action-item].active');
+ } else {
+ items = list.querySelectorAll(':scope > [data-action-item].active');
+ }
+
+ return Array.from(items);
+ }
+
+ /**
+ * Get all available items from given list
+ *
+ * @param list The action list
+ *
+ * @return array
+ */
+ getAllItems(list)
+ {
+ let items;
+ if (list.tagName.toLowerCase() === 'table') {
+ items = list.querySelectorAll(':scope > tbody > [data-action-item]');
+ } else {
+ items = list.querySelectorAll(':scope > [data-action-item]');
+ }
+
+ return Array.from(items);
+ }
+
+ /**
+ * Handle the navigation on load-more button
+ *
+ * @param loadMoreElement
+ * @param lastActivatedItem
+ * @param pressedKey Pressed key (`ArrowUp` or `ArrowDown`)
+ */
+ handleLoadMoreNavigate(loadMoreElement, lastActivatedItem, pressedKey) {
+ let req = this.loadMore(loadMoreElement.firstChild);
+ this.isProcessingLoadMore = true;
+ req.done(() => {
+ this.isProcessingLoadMore = false;
+ // list has now new items, so select the lastActivatedItem and then move forward
+ let toActiveItem = lastActivatedItem.nextElementSibling;
+ while (toActiveItem) {
+ if (toActiveItem.hasAttribute('data-action-item')) {
+ this.clearSelection([lastActivatedItem]);
+ this.setActive(toActiveItem);
+ this.setLastActivatedItemUrl(toActiveItem.dataset.icingaDetailFilter);
+ this.scrollItemIntoView(toActiveItem, pressedKey);
+ this.addSelectionCountToFooter(toActiveItem.parentElement);
+ this.loadDetailUrl(toActiveItem.parentElement);
+ return;
+ }
+
+ toActiveItem = toActiveItem.nextElementSibling;
+ }
+ });
+ }
+
+ /**
+ * Click on load-more button
+ *
+ * @param event
+ *
+ * @returns {boolean}
+ */
+ onLoadMoreClick(event) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ event.data.self.loadMore(event.target);
+
+ return false;
+ }
+
+ onKeyPress(event) {
+ if (event.key === ' ') { // space
+ event.data.self.onLoadMoreClick(event);
+ }
+ }
+
+ /**
+ * Load more list items based on the given anchor
+ *
+ * @param anchor
+ *
+ * @returns {*|{getAllResponseHeaders: function(): *|null, abort: function(*): this, setRequestHeader: function(*, *): this, readyState: number, getResponseHeader: function(*): null|*, overrideMimeType: function(*): this, statusCode: function(*): this}|jQuery|boolean}
+ */
+ loadMore(anchor) {
+ let showMore = anchor.parentElement;
+ var progressTimer = this.icinga.timer.register(function () {
+ var label = anchor.innerText;
+
+ var dots = label.substr(-3);
+ if (dots.slice(0, 1) !== '.') {
+ dots = '. ';
+ } else {
+ label = label.slice(0, -3);
+ if (dots === '...') {
+ dots = '. ';
+ } else if (dots === '.. ') {
+ dots = '...';
+ } else if (dots === '. ') {
+ dots = '.. ';
+ }
+ }
+
+ anchor.innerText = label + dots;
+ }, null, 250);
+
+ let url = anchor.getAttribute('href');
+ let req = this.icinga.loader.loadUrl(
+ // Add showCompact, we don't want controls in paged results
+ this.icinga.utils.addUrlFlag(url, 'showCompact'),
+ $(showMore.parentElement),
+ undefined,
+ undefined,
+ 'append',
+ false,
+ progressTimer
+ );
+ req.addToHistory = false;
+ req.done(function () {
+ showMore.remove();
+
+ // Set data-icinga-url to make it available for Icinga.History.getCurrentState()
+ req.$target.closest('.container').data('icingaUrl', url);
+
+ this.icinga.history.replaceCurrentState();
+ });
+
+ return req;
+ }
+
+ /**
+ * Create the detail url for multi selectable list
+ *
+ * @param items List items
+ * @param withBaseUrl Default to true
+ *
+ * @returns {string} The url
+ */
+ createMultiSelectUrl(items, withBaseUrl = true) {
+ let filters = [];
+ items.forEach(item => {
+ filters.push(item.getAttribute('data-icinga-multiselect-filter'));
+ });
+
+ let url = '?' + filters.join('|');
+
+ if (withBaseUrl) {
+ return items[0].closest('.action-list').getAttribute('data-icinga-multiselect-url') + url;
+ }
+
+ return url;
+ }
+
+ onColumnClose(event) {
+ let _this = event.data.self;
+ let list = _this.findDetailUrlActionList(document.getElementById('col1'));
+ if (list && list.matches('[data-icinga-multiselect-url], [data-icinga-detail-url]')) {
+ _this.clearSelection(_this.getActiveItems(list));
+ _this.addSelectionCountToFooter(list);
+ }
+ }
+
+ /**
+ * Find the action list using the detail url
+ *
+ * @param {Element} container
+ *
+ * @return Element|null
+ */
+ findDetailUrlActionList(container) {
+ let detailUrl = this.icinga.utils.parseUrl(
+ this.icinga.history.getCol2State().replace(/^#!/, '')
+ );
+
+ let detailItem = container.querySelector(
+ '[data-icinga-detail-filter="'
+ + detailUrl.query.replace('?', '') + '"],' +
+ '[data-icinga-multiselect-filter="'
+ + detailUrl.query.split('|', 1).toString().replace('?', '') + '"]'
+ );
+
+ return detailItem ? detailItem.parentElement : null;
+ }
+
+ /**
+ * Triggers when column is moved to left or right
+ *
+ * @param event
+ * @param sourceId The content is moved from
+ */
+ onColumnMoved(event, sourceId) {
+ let _this = event.data.self;
+
+ if (event.target.id === 'col2' && sourceId === 'col1') { // only for browser-back (col1 shifted to col2)
+ _this.clearSelection(event.target.querySelectorAll('.action-list .active'));
+ } else if (event.target.id === 'col1' && sourceId === 'col2') {
+ for (const requestNo of Object.keys(_this.activeRequests)) {
+ if (_this.activeRequests[requestNo] === sourceId) {
+ _this.enableAutoRefresh(_this.activeRequests[requestNo]);
+ _this.activeRequests[requestNo] = _this.suspendAutoRefresh(event.target);
+ }
+ }
+ }
+ }
+
+ onRendered(event, isAutoRefresh) {
+ let _this = event.data.self;
+ let container = event.target;
+ let isTopLevelContainer = container.matches('#main > :scope');
+
+ let list;
+ if (event.currentTarget !== container || Object.keys(_this.activeRequests).length) {
+ // Nested containers are not processed multiple times || still processing selection/navigation request
+ return;
+ } else if (isTopLevelContainer && container.id !== 'col1') {
+ if (isAutoRefresh) {
+ return;
+ }
+
+ // only for browser back/forward navigation
+ list = _this.findDetailUrlActionList(document.getElementById('col1'));
+ } else {
+ list = _this.findDetailUrlActionList(container);
+ }
+
+ if (list && list.matches('[data-icinga-multiselect-url], [data-icinga-detail-url]')) {
+ let detailUrl = _this.icinga.utils.parseUrl(
+ _this.icinga.history.getCol2State().replace(/^#!/, '')
+ );
+ let toActiveItems = [];
+ if (list.dataset.icingaMultiselectUrl === detailUrl.path) {
+ for (const filter of _this.parseSelectionQuery(detailUrl.query.slice(1))) {
+ let item = list.querySelector(
+ '[data-icinga-multiselect-filter="' + filter + '"]'
+ );
+
+ if (item) {
+ toActiveItems.push(item);
+ }
+ }
+ } else if (_this.matchesDetailUrl(list.dataset.icingaDetailUrl, detailUrl.path)) {
+ let item = list.querySelector(
+ '[data-icinga-detail-filter="' + detailUrl.query.slice(1) + '"]'
+ );
+
+ if (item) {
+ toActiveItems.push(item);
+ }
+ }
+
+ _this.clearSelection(_this.getAllItems(list).filter(item => !toActiveItems.includes(item)));
+ _this.setActive(toActiveItems);
+ }
+
+ if (isTopLevelContainer) {
+ let footerList = list ?? container.querySelector('.content > .action-list');
+ if (footerList) {
+ _this.addSelectionCountToFooter(footerList);
+ }
+ }
+ }
+
+ matchesDetailUrl(itemUrl, detailUrl) {
+ if (itemUrl === detailUrl) {
+ return true;
+ }
+
+ // The slash is used to avoid false positives (e.g. icingadb/hostgroup and icingadb/host)
+ return detailUrl.startsWith(itemUrl + '/');
+ }
+ }
+
+ Icinga.Behaviors.ActionList = ActionList;
+
+}(Icinga));
diff --git a/public/js/migrate.js b/public/js/migrate.js
new file mode 100644
index 0000000..dddbaed
--- /dev/null
+++ b/public/js/migrate.js
@@ -0,0 +1,654 @@
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+;(function(Icinga, $) {
+
+ 'use strict';
+
+ const ANIMATION_LENGTH = 350;
+
+ const POPUP_HTML = '<div class="icinga-module module-icingadb">\n' +
+ ' <div id="migrate-popup">\n' +
+ ' <div class="suggestion-area">\n' +
+ ' <button type="button" class="close">Don\'t show this again</button>\n' +
+ ' <ul class="search-migration-suggestions"></ul>\n' +
+ ' <p class="search-migration-hint">Miss some results? Try the link(s) below</p>\n' +
+ ' <ul class="monitoring-migration-suggestions"></ul>\n' +
+ ' <p class="monitoring-migration-hint">Preview this in Icinga DB</p>\n' +
+ ' </div>\n' +
+ ' <div class="minimizer"><i class="icon-"></i></div>\n' +
+ ' </div>\n' +
+ '</div>';
+
+ const SUGGESTION_HTML = '<li>\n' +
+ ' <button type="button" value="1"></button>\n' +
+ ' <button type="button" value="0"><i class="icon-"></i></button>\n' +
+ '</li>';
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ /**
+ * Icinga DB Migration behavior.
+ *
+ * @param icinga {Icinga} The current Icinga Object
+ */
+ class Migrate extends Icinga.EventListener {
+ constructor(icinga) {
+ super(icinga);
+
+ this.knownMigrations = {};
+ this.knownBackendSupport = {};
+ this.urlMigrationReadyState = null;
+ this.backendSupportReadyState = null;
+ this.searchMigrationReadyState = null;
+ this.backendSupportRelated = {};
+ this.$popup = null;
+
+ // Some persistence, we don't want to annoy our users too much
+ this.storage = Icinga.Storage.BehaviorStorage('icingadb.migrate');
+ this.tempStorage = Icinga.Storage.BehaviorStorage('icingadb.migrate');
+ this.tempStorage.setBackend(window.sessionStorage);
+ this.previousMigrations = {};
+
+ // We don't want to ask the server to migrate non-monitoring urls
+ this.isMonitoringUrl = new RegExp('^' + icinga.config.baseUrl + '/monitoring/');
+
+ this.on('rendered', this.onRendered, this);
+ this.on('close-column', this.onColumnClose, this);
+ this.on('click', '#migrate-popup button.close', this.onClose, this);
+ this.on('click', '#migrate-popup li button', this.onDecision, this);
+ this.on('click', '#migrate-popup .minimizer', this.onHandleClicked, this);
+ this.storage.onChange('minimized', this.onMinimized, this);
+ }
+
+ update(data) {
+ if (data !== 'bogus') {
+ return;
+ }
+
+ $.each(this.backendSupportRelated, (id, _) => {
+ let $container = $('#' + id);
+ let req = this.icinga.loader.loadUrl($container.data('icingaUrl'), $container);
+ req.addToHistory = false;
+ req.scripted = true;
+ });
+ }
+
+ onRendered(event) {
+ var _this = event.data.self;
+ var $target = $(event.target);
+
+ if (_this.tempStorage.get('closed') || $('#layout.fullscreen-layout').length) {
+ // Don't bother in case the user closed the popup or we're in fullscreen
+ return;
+ }
+
+ if (!$target.is('#main > .container')) {
+ if ($target.is('#main .container')) {
+ var attrUrl = $target.attr('data-icinga-url');
+ var dataUrl = $target.data('icingaUrl');
+ if (!! attrUrl && attrUrl !== dataUrl) {
+ // Search urls are redirected, update any migration suggestions
+ _this.prepareMigration($target);
+ return;
+ }
+ }
+
+ // We are else really only interested in top-level containers
+ return;
+ }
+
+ var $dashboard = $target.children('.dashboard');
+ if ($dashboard.length) {
+ // After a page load dashlets have no id as `renderContentToContainer()` didn't ran yet
+ _this.icinga.ui.assignUniqueContainerIds();
+
+ $target = $dashboard.children('.container');
+ }
+
+ _this.prepareMigration($target);
+ }
+
+ prepareMigration($target) {
+ let monitoringUrls = {};
+ let searchUrls = {};
+ let modules = {}
+
+ $target.each((_, container) => {
+ let $container = $(container);
+ let href = decodeURIComponent($container.data('icingaUrl'));
+ let containerId = $container.attr('id');
+
+ if (!!href) {
+ if (
+ typeof this.previousMigrations[containerId] !== 'undefined'
+ && this.previousMigrations[containerId] === href
+ ) {
+ delete this.previousMigrations[containerId];
+ } else {
+ if (href.match(this.isMonitoringUrl)) {
+ monitoringUrls[containerId] = href;
+ } else if ($container.find('[data-enrichment-type="search-bar"]').length) {
+ searchUrls[containerId] = href;
+ }
+ }
+ }
+
+ let moduleName = $container.data('icingaModule');
+ if (!! moduleName && moduleName !== 'default' && moduleName !== 'monitoring' && moduleName !== 'icingadb') {
+ modules[containerId] = moduleName;
+ }
+ });
+
+ if (Object.keys(monitoringUrls).length) {
+ this.setUrlMigrationReadyState(false);
+ this.migrateUrls(monitoringUrls, 'monitoring');
+ } else {
+ this.setUrlMigrationReadyState(null);
+ }
+
+ if (Object.keys(searchUrls).length) {
+ this.setSearchMigrationReadyState(false);
+ this.migrateUrls(searchUrls, 'search');
+ } else {
+ this.setSearchMigrationReadyState(null);
+ }
+
+ if (Object.keys(modules).length) {
+ this.setBackendSupportReadyState(false);
+ this.prepareBackendCheckboxForm(modules);
+ } else {
+ this.setBackendSupportReadyState(null);
+ }
+
+ if (
+ this.urlMigrationReadyState === null
+ && this.backendSupportReadyState === null
+ && this.searchMigrationReadyState === null
+ ) {
+ this.cleanupPopup();
+ }
+ }
+
+ onColumnClose(event) {
+ var _this = event.data.self;
+ _this.Popup().find('.suggestion-area > ul li').each(function () {
+ var $suggestion = $(this);
+ var suggestionUrl = $suggestion.data('containerUrl');
+ var $container = $('#' + $suggestion.data('containerId'));
+
+ var containerUrl = '';
+ if ($container.length) {
+ containerUrl = decodeURIComponent($container.data('icingaUrl'));
+ }
+
+ if (suggestionUrl !== containerUrl) {
+ var $newContainer = $('#main > .container').filter(function () {
+ return decodeURIComponent($(this).data('icingaUrl')) === suggestionUrl;
+ });
+ if ($newContainer.length) {
+ // Container moved
+ $suggestion.attr('id', 'suggest-' + $newContainer.attr('id'));
+ $suggestion.data('containerId', $newContainer.attr('id'));
+ }
+ }
+ });
+
+ let backendSupportRelated = { ..._this.backendSupportRelated };
+ $.each(backendSupportRelated, (id, module) => {
+ let $container = $('#' + id);
+ if (! $container.length || $container.data('icingaModule') !== module) {
+ let $newContainer = $('#main > .container').filter(function () {
+ return $(this).data('icingaModule') === module;
+ });
+ if ($newContainer.length) {
+ _this.backendSupportRelated[$newContainer.attr('id')] = module;
+ }
+
+ delete _this.backendSupportRelated[id];
+ }
+ });
+
+ _this.cleanupPopup();
+ }
+
+ onClose(event) {
+ var _this = event.data.self;
+ _this.tempStorage.set('closed', true);
+ _this.hidePopup();
+ }
+
+ onDecision(event) {
+ var _this = event.data.self;
+ var $button = $(event.target).closest('button');
+ var $suggestion = $button.parent();
+ var $container = $('#' + $suggestion.data('containerId'));
+ var containerUrl = decodeURIComponent($container.data('icingaUrl'));
+
+ if ($button.attr('value') === '1') {
+ // Yes
+ var newHref = _this.knownMigrations[containerUrl];
+ _this.icinga.loader.loadUrl(newHref, $container);
+
+ _this.previousMigrations[$suggestion.data('containerId')] = containerUrl;
+
+ if ($container.parent().is('.dashboard')) {
+ $container.find('h1 a').attr('href', _this.icinga.utils.removeUrlParams(newHref, ['showCompact']));
+ }
+ } else {
+ // No
+ _this.knownMigrations[containerUrl] = false;
+ }
+
+ if (_this.Popup().find('li').length === 1 && ! _this.Popup().find('#setAsBackendForm').length) {
+ _this.hidePopup(function () {
+ // Let the transition finish first, looks cleaner
+ $suggestion.remove();
+ });
+ } else {
+ $suggestion.remove();
+ }
+ }
+
+ onHandleClicked(event) {
+ var _this = event.data.self;
+ if (_this.togglePopup()) {
+ _this.storage.set('minimized', true);
+ } else {
+ _this.storage.remove('minimized');
+ }
+ }
+
+ onMinimized(isMinimized, oldValue) {
+ if (isMinimized && isMinimized !== oldValue && this.isShown()) {
+ this.minimizePopup();
+ }
+ }
+
+ migrateUrls(urls, type) {
+ var _this = this,
+ containerIds = [],
+ containerUrls = [];
+
+ $.each(urls, function (containerId, containerUrl) {
+ if (typeof _this.knownMigrations[containerUrl] === 'undefined') {
+ containerUrls.push(containerUrl);
+ containerIds.push(containerId);
+ }
+ });
+
+ let endpoint, changeCallback;
+ if (type === 'monitoring') {
+ endpoint = 'monitoring-url';
+ changeCallback = this.changeUrlMigrationReadyState.bind(this);
+ } else {
+ endpoint = 'search-url';
+ changeCallback = this.changeSearchMigrationReadyState.bind(this);
+ }
+
+ if (containerUrls.length) {
+ var req = $.ajax({
+ context: this,
+ type: 'post',
+ url: this.icinga.config.baseUrl + '/icingadb/migrate/' + endpoint,
+ headers: {'Accept': 'application/json'},
+ contentType: 'application/json',
+ data: JSON.stringify(containerUrls)
+ });
+
+ req.urls = urls;
+ req.suggestionType = type;
+ req.urlIndexToContainerId = containerIds;
+ req.done(this.processUrlMigrationResults);
+ req.always(() => changeCallback(true));
+ } else {
+ // All urls have already been migrated once, show popup immediately
+ this.addSuggestions(urls, type);
+ changeCallback(true);
+ }
+ }
+
+ processUrlMigrationResults(data, textStatus, req) {
+ var _this = this;
+ var result, containerId;
+
+ if (data.status === 'success') {
+ result = data.data;
+ } else { // if (data.status === 'fail')
+ result = data.data.result;
+
+ $.each(data.data.errors, function (k, error) {
+ _this.icinga.logger.error('[Migrate] Erroneous url "' + k + '": ' + error[0] + '\n' + error[1]);
+ });
+ }
+
+ $.each(result, function (i, migratedUrl) {
+ containerId = req.urlIndexToContainerId[i];
+ _this.knownMigrations[req.urls[containerId]] = migratedUrl;
+ });
+
+ this.addSuggestions(req.urls, req.suggestionType);
+ }
+
+ prepareBackendCheckboxForm(modules) {
+ let containerIds = [];
+ let moduleNames = [];
+
+ $.each(modules, (id, module) => {
+ if (typeof this.knownBackendSupport[module] === 'undefined') {
+ containerIds.push(id);
+ moduleNames.push(module);
+ }
+ });
+
+ if (moduleNames.length) {
+ let req = $.ajax({
+ context : this,
+ type : 'post',
+ url : this.icinga.config.baseUrl + '/icingadb/migrate/backend-support',
+ headers : { 'Accept': 'application/json' },
+ contentType : 'application/json',
+ data : JSON.stringify(moduleNames)
+ });
+
+ req.modules = modules;
+ req.moduleIndexToContainerId = containerIds;
+ req.done(this.processBackendSupportResults);
+ req.always(() => this.changeBackendSupportReadyState(true));
+ } else {
+ // All modules have already been checked once, show popup immediately
+ this.setupBackendCheckboxForm(modules);
+ this.changeBackendSupportReadyState(true);
+ }
+ }
+
+ processBackendSupportResults(data, textStatus, req) {
+ let result = data.data;
+
+ $.each(result, (i, state) => {
+ let containerId = req.moduleIndexToContainerId[i];
+ this.knownBackendSupport[req.modules[containerId]] = state;
+ });
+
+ this.setupBackendCheckboxForm(req.modules);
+ }
+
+ setupBackendCheckboxForm(modules) {
+ let supportedModules = {};
+
+ $.each(modules, (id, module) => {
+ if (this.knownBackendSupport[module]) {
+ supportedModules[id] = module;
+ }
+ });
+
+ if (Object.keys(supportedModules).length) {
+ this.backendSupportRelated = { ...this.backendSupportRelated, ...supportedModules };
+
+ let req = $.ajax({
+ context : this,
+ type : 'get',
+ url : this.icinga.config.baseUrl + '/icingadb/migrate/checkbox-state?showCompact'
+ });
+
+ req.done(this.setCheckboxState);
+ }
+ }
+
+ setCheckboxState(html, textStatus, req) {
+ let $form = this.Popup().find('.suggestion-area > #setAsBackendForm');
+ if (! $form.length) {
+ $form = $(html);
+ $form.attr('data-base-target', 'migrate-popup-backend-submit-blackhole');
+ $form.append('<div id="migrate-popup-backend-submit-blackhole"></div>');
+
+ this.Popup().find('.monitoring-migration-suggestions').before($form);
+ } else {
+ let $newForm = $(html);
+ $form.find('[name=backend]').prop('checked', $newForm.find('[name=backend]').is(':checked'));
+ }
+
+ this.showPopup();
+ }
+
+ addSuggestions(urls, type) {
+ var where;
+ if (type === 'monitoring') {
+ where = '.monitoring-migration-suggestions';
+ } else {
+ where = '.search-migration-suggestions';
+ }
+
+ var _this = this,
+ hasSuggestions = false,
+ $ul = this.Popup().find('.suggestion-area > ul' + where);
+ $.each(urls, function (containerId, containerUrl) {
+ // No urls for which the user clicked "No" or an error occurred and only migrated urls please
+ if (_this.knownMigrations[containerUrl] !== false && _this.knownMigrations[containerUrl] !== containerUrl) {
+ var $container = $('#' + containerId);
+
+ var $suggestion = $ul.find('li#suggest-' + containerId);
+ if ($suggestion.length) {
+ if ($suggestion.data('containerUrl') === containerUrl) {
+ // There's already a suggestion for this exact container and url
+ hasSuggestions = true;
+ return;
+ }
+
+ $suggestion.data('containerUrl', containerUrl);
+ } else {
+ $suggestion = $(SUGGESTION_HTML);
+ $suggestion.attr('id', 'suggest-' + containerId);
+ $suggestion.data('containerId', containerId);
+ $suggestion.data('containerUrl', containerUrl);
+ $ul.append($suggestion);
+ }
+
+ hasSuggestions = true;
+
+ var title;
+ if ($container.data('icingaTitle')) {
+ title = $container.data('icingaTitle').split(' :: ').slice(0, -1).join(' :: ');
+ } else if ($container.parent().is('.dashboard')) {
+ title = $container.find('h1 a').text();
+ } else {
+ title = $container.find('.tabs li.active a').text();
+ }
+
+ $suggestion.find('button:first-of-type').text(title);
+ }
+ });
+
+ if (hasSuggestions) {
+ this.showPopup();
+ if (type === 'search') {
+ this.maximizePopup();
+ }
+ }
+ }
+
+ cleanupSuggestions() {
+ var _this = this,
+ toBeRemoved = [];
+ this.Popup().find('li').each(function () {
+ var $suggestion = $(this);
+ var $container = $('#' + $suggestion.data('containerId'));
+ var containerUrl = decodeURIComponent($container.data('icingaUrl'));
+ if (
+ // Unknown url, yet
+ typeof _this.knownMigrations[containerUrl] === 'undefined'
+ // User doesn't want to migrate
+ || _this.knownMigrations[containerUrl] === false
+ // Already migrated or no migration necessary
+ || containerUrl === _this.knownMigrations[containerUrl]
+ // The container URL changed
+ || containerUrl !== $suggestion.data('containerUrl')
+ ) {
+ toBeRemoved.push($suggestion);
+ }
+ });
+
+ return toBeRemoved;
+ }
+
+ cleanupBackendForm() {
+ let $form = this.Popup().find('#setAsBackendForm');
+ if (! $form.length) {
+ return false;
+ }
+
+ let stillRelated = {};
+ $.each(this.backendSupportRelated, (id, module) => {
+ let $container = $('#' + id);
+ if ($container.length && $container.data('icingaModule') === module) {
+ stillRelated[id] = module;
+ }
+ });
+
+ this.backendSupportRelated = stillRelated;
+
+ if (Object.keys(stillRelated).length) {
+ return true;
+ }
+
+ return $form;
+ }
+
+ cleanupPopup() {
+ let toBeRemoved = this.cleanupSuggestions();
+ let hasBackendForm = this.cleanupBackendForm();
+
+ if (hasBackendForm !== true && this.Popup().find('li').length === toBeRemoved.length) {
+ this.hidePopup(() => {
+ // Let the transition finish first, looks cleaner
+ $.each(toBeRemoved, function (_, $suggestion) {
+ $suggestion.remove();
+ });
+
+ if (typeof hasBackendForm === 'object') {
+ hasBackendForm.remove();
+ }
+ });
+ } else {
+ $.each(toBeRemoved, function (_, $suggestion) {
+ $suggestion.remove();
+ });
+
+ if (typeof hasBackendForm === 'object') {
+ hasBackendForm.remove();
+ }
+
+ // Let showPopup() handle the automatic minimization in case all search suggestions have been removed
+ this.showPopup();
+ }
+ }
+
+ showPopup() {
+ var $popup = this.Popup();
+ if (this.storage.get('minimized') && ! this.forceFullyMaximized()) {
+ if (this.isShown()) {
+ this.minimizePopup();
+ } else {
+ $popup.addClass('active minimized hidden');
+ }
+ } else {
+ $popup.addClass('active');
+ }
+ }
+
+ hidePopup(after) {
+ this.Popup().removeClass('active minimized hidden');
+
+ if (typeof after === 'function') {
+ setTimeout(after, ANIMATION_LENGTH);
+ }
+ }
+
+ isShown() {
+ return this.Popup().is('.active');
+ }
+
+ minimizePopup() {
+ var $popup = this.Popup();
+ $popup.addClass('minimized');
+ setTimeout(function () {
+ $popup.addClass('hidden');
+ }, ANIMATION_LENGTH);
+ }
+
+ maximizePopup() {
+ this.Popup().removeClass('minimized hidden');
+ }
+
+ forceFullyMaximized() {
+ return this.Popup().find('.search-migration-suggestions:not(:empty)').length > 0;
+ }
+
+ togglePopup() {
+ if (this.Popup().is('.minimized')) {
+ this.maximizePopup();
+ return false;
+ } else {
+ this.minimizePopup();
+ return true;
+ }
+ }
+
+ setUrlMigrationReadyState(state) {
+ this.urlMigrationReadyState = state;
+ }
+
+ changeUrlMigrationReadyState(state) {
+ this.setUrlMigrationReadyState(state);
+
+ if (this.backendSupportReadyState !== false && this.searchMigrationReadyState !== false) {
+ this.searchMigrationReadyState = null;
+ this.backendSupportReadyState = null;
+ this.urlMigrationReadyState = null;
+ this.cleanupPopup();
+ }
+ }
+
+ setSearchMigrationReadyState(state) {
+ this.searchMigrationReadyState = state;
+ }
+
+ changeSearchMigrationReadyState(state) {
+ this.setSearchMigrationReadyState(state);
+
+ if (this.backendSupportReadyState !== false && this.urlMigrationReadyState !== false) {
+ this.searchMigrationReadyState = null;
+ this.backendSupportReadyState = null;
+ this.urlMigrationReadyState = null;
+ this.cleanupPopup();
+ }
+ }
+
+ setBackendSupportReadyState(state) {
+ this.backendSupportReadyState = state;
+ }
+
+ changeBackendSupportReadyState(state) {
+ this.setBackendSupportReadyState(state);
+
+ if (this.urlMigrationReadyState !== false && this.searchMigrationReadyState !== false) {
+ this.searchMigrationReadyState = null;
+ this.backendSupportReadyState = null;
+ this.urlMigrationReadyState = null;
+ this.cleanupPopup();
+ }
+ }
+
+ Popup() {
+ // Node.contains() is used due to `?renderLayout`
+ if (this.$popup === null || ! document.body.contains(this.$popup[0])) {
+ $('#layout').append($(POPUP_HTML));
+ this.$popup = $('#migrate-popup');
+ }
+
+ return this.$popup;
+ }
+ }
+
+ Icinga.Behaviors.Migrate = Migrate;
+
+})(Icinga, jQuery);
diff --git a/public/js/progress-bar.js b/public/js/progress-bar.js
new file mode 100644
index 0000000..be24f1c
--- /dev/null
+++ b/public/js/progress-bar.js
@@ -0,0 +1,110 @@
+(function (Icinga) {
+
+ "use strict";
+
+ class ProgressBar extends Icinga.EventListener {
+ constructor(icinga)
+ {
+ super(icinga);
+
+ /**
+ * Frame update threshold. If it reaches zero, the view is updated
+ *
+ * Currently, only every third frame is updated.
+ *
+ * @type {number}
+ */
+ this.frameUpdateThreshold = 3;
+
+ /**
+ * Threshold at which animations get smoothed out (in milliseconds)
+ *
+ * @type {number}
+ */
+ this.smoothUpdateThreshold = 250;
+
+ this.on('rendered', '#main > .container', this.onRendered, this);
+ }
+
+ onRendered(event)
+ {
+ const _this = event.data.self;
+ const container = event.target;
+
+ container.querySelectorAll('[data-animate-progress]').forEach(progress => {
+ const frequency = (
+ (Number(progress.dataset.endTime) - Number(progress.dataset.startTime)
+ ) * 1000) / progress.parentElement.offsetWidth;
+
+ _this.updateProgress(
+ now => _this.animateProgress(progress, now), frequency);
+ });
+ }
+
+ animateProgress(progress, now)
+ {
+ if (! progress.isConnected) {
+ return false; // Exit early if the node is removed from the DOM
+ }
+
+ const durationScale = 100;
+
+ const startTime = Number(progress.dataset.startTime);
+ const endTime = Number(progress.dataset.endTime);
+ const duration = endTime - startTime;
+ const end = new Date(endTime * 1000.0);
+
+ let leftNow = durationScale * (1 - (end - now) / (duration * 1000.0));
+ if (leftNow > durationScale) {
+ leftNow = durationScale;
+ } else if (leftNow < 0) {
+ leftNow = 0;
+ }
+
+ const switchAfter = Number(progress.dataset.switchAfter);
+ if (! isNaN(switchAfter)) {
+ const switchClass = progress.dataset.switchClass;
+ const switchAt = new Date((startTime * 1000.0) + (switchAfter * 1000.0));
+ if (now < switchAt) {
+ progress.classList.add(switchClass);
+ } else if (progress.classList.contains(switchClass)) {
+ progress.classList.remove(switchClass);
+ }
+ }
+
+ const bar = progress.querySelector(':scope > .bar');
+ bar.style.width = leftNow + '%';
+
+ return leftNow !== durationScale;
+ }
+
+ updateProgress(callback, frequency, now = null)
+ {
+ if (now === null) {
+ now = new Date();
+ }
+
+ if (! callback(now)) {
+ return;
+ }
+
+ if (frequency < this.smoothUpdateThreshold) {
+ let counter = this.frameUpdateThreshold;
+ const onNextFrame = timeSinceOrigin => {
+ if (--counter === 0) {
+ this.updateProgress(callback, frequency, new Date(performance.timeOrigin + timeSinceOrigin));
+ } else {
+ requestAnimationFrame(onNextFrame);
+ }
+ };
+ requestAnimationFrame(onNextFrame);
+ } else {
+ setTimeout(() => this.updateProgress(callback, frequency), frequency);
+ }
+ }
+ }
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ Icinga.Behaviors.ProgressBar = ProgressBar;
+})(Icinga);
diff --git a/run.php b/run.php
new file mode 100644
index 0000000..b4c8032
--- /dev/null
+++ b/run.php
@@ -0,0 +1,43 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+/** @var $this \Icinga\Application\Modules\Module */
+
+$this->provideHook('ApplicationState');
+$this->provideHook('X509/Sni');
+$this->provideHook('health', 'IcingaHealth');
+$this->provideHook('health', 'RedisHealth');
+$this->provideHook('Reporting/Report', 'Reporting/HostSlaReport');
+$this->provideHook('Reporting/Report', 'Reporting/TotalHostSlaReport');
+$this->provideHook('Reporting/Report', 'Reporting/ServiceSlaReport');
+$this->provideHook('Reporting/Report', 'Reporting/TotalServiceSlaReport');
+
+if ($this::exists('reporting')) {
+ $this->provideHook('Icingadb/HostActions', 'CreateHostSlaReport');
+ $this->provideHook('Icingadb/ServiceActions', 'CreateServiceSlaReport');
+ $this->provideHook('Icingadb/HostsDetailExtension', 'CreateHostsSlaReport');
+ $this->provideHook('Icingadb/ServicesDetailExtension', 'CreateServicesSlaReport');
+}
+
+if (! $this::exists('monitoring')) {
+ $modulePath = null;
+ foreach ($this->app->getModuleManager()->getModuleDirs() as $path) {
+ $pathToTest = join(DIRECTORY_SEPARATOR, [$path, 'monitoring']);
+ if (file_exists($pathToTest)) {
+ $modulePath = $pathToTest;
+ break;
+ }
+ }
+
+ if ($modulePath === null) {
+ Icinga\Application\Logger::error('Unable to locate monitoring module');
+ } else {
+ // Ensure we can load some classes/interfaces for compatibility with legacy hooks
+ $this->app->getLoader()->registerNamespace(
+ 'Icinga\\Module\\Monitoring',
+ join(DIRECTORY_SEPARATOR, [$modulePath, 'library', 'Monitoring']),
+ join(DIRECTORY_SEPARATOR, [$modulePath, 'application'])
+ );
+ }
+}
diff --git a/test/php/Lib/PerfdataSetWithPublicData.php b/test/php/Lib/PerfdataSetWithPublicData.php
new file mode 100644
index 0000000..97fcd7a
--- /dev/null
+++ b/test/php/Lib/PerfdataSetWithPublicData.php
@@ -0,0 +1,12 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Module\Icingadb\Lib;
+
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+
+class PerfdataSetWithPublicData extends PerfdataSet
+{
+ public $perfdata = [];
+}
diff --git a/test/php/application/clicommands/MigrateCommandTest.php b/test/php/application/clicommands/MigrateCommandTest.php
new file mode 100644
index 0000000..2f591ac
--- /dev/null
+++ b/test/php/application/clicommands/MigrateCommandTest.php
@@ -0,0 +1,1727 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Module\Icingadb\Clicommands;
+
+use Icinga\Application\Cli;
+use Icinga\Application\Config;
+use Icinga\Application\Modules\Manager;
+use Icinga\Cli\Params;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\MissingParameterException;
+use Icinga\File\Storage\TemporaryLocalFileStorage;
+use Icinga\Module\Icingadb\Clicommands\MigrateCommand;
+use PHPUnit\Framework\TestCase;
+
+class MigrateCommandTest extends TestCase
+{
+ protected $config = [
+ 'dashboards' => [
+ 'initial' => [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'hosts.group_members' => [
+ 'title' => 'Group Members',
+ 'url' => 'monitoring/list/hosts?hostgroup_name=group1|hostgroup_name=%28group2%29'
+ ],
+ 'hosts.variables' => [
+ 'title' => 'Host Variables',
+ 'url' => 'monitoring/list/hosts?(_host_foo=bar&_host_bar=foo)|_host_rab=oof'
+ ],
+ 'hosts.wildcards' => [
+ 'title' => 'Host Wildcards',
+ 'url' => 'monitoring/list/hosts?host_name=%2Afoo%2A|host_name=%2Abar%2A'
+ . '&sort=host_severity&dir=asc&limit=25'
+ ],
+ 'hosts.encoded_params' => [
+ 'title' => 'Host Encoded Params',
+ 'url' => 'monitoring/list/hosts?host_name=%28foo%29&sort=_host_%28foo%29'
+ ],
+ 'icingadb' => [
+ 'title' => 'Icinga DB'
+ ],
+ 'icingadb.no-wildcards' => [
+ 'title' => 'No Wildcards',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y&hostgroup.name=linux-hosts'
+ ],
+ 'icingadb.wildcards' => [
+ 'title' => 'Wildcards',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y&hostgroup.name=%2Alinux%2A'
+ ],
+ 'icingadb.also-wildcards' => [
+ 'title' => 'Also Wildcards',
+ 'url' => 'icingadb/hosts?host.name=%2Afoo%2A'
+ ],
+ 'icingadb.with-sort-and-limit' => [
+ 'title' => 'With Sort And Limit',
+ 'url' => 'icingadb/hosts?host.name=%2Afoo%2A|host.name=bar&sort=host.state.severity&limit=50'
+ ],
+ 'not-monitoring-or-icingadb' => [
+ 'title' => 'Not Monitoring Or Icinga DB'
+ ],
+ 'not-monitoring-or-icingadb.something' => [
+ 'title' => 'Something',
+ 'url' => 'somewhere/something?foo=%2Abar%2A'
+ ]
+ ],
+ 'expected' => [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y'
+ ],
+ 'hosts.group_members' => [
+ 'title' => 'Group Members',
+ 'url' => 'icingadb/hosts?hostgroup.name=group1|hostgroup.name=%28group2%29'
+ ],
+ 'hosts.variables' => [
+ 'title' => 'Host Variables',
+ 'url' => 'icingadb/hosts?(host.vars.foo=bar&host.vars.bar=foo)|host.vars.rab=oof'
+ ],
+ 'hosts.wildcards' => [
+ 'title' => 'Host Wildcards',
+ 'url' => 'icingadb/hosts?host.name~%2Afoo%2A|host.name~%2Abar%2A'
+ . '&sort=host.state.severity%20asc&limit=25'
+ ],
+ 'hosts.encoded_params' => [
+ 'title' => 'Host Encoded Params',
+ 'url' => 'icingadb/hosts?host.name=%28foo%29&sort=host.vars.%28foo%29'
+ ],
+ 'icingadb' => [
+ 'title' => 'Icinga DB'
+ ],
+ 'icingadb.no-wildcards' => [
+ 'title' => 'No Wildcards',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y&hostgroup.name=linux-hosts'
+ ],
+ 'icingadb.wildcards' => [
+ 'title' => 'Wildcards',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y&hostgroup.name~%2Alinux%2A'
+ ],
+ 'icingadb.also-wildcards' => [
+ 'title' => 'Also Wildcards',
+ 'url' => 'icingadb/hosts?host.name~%2Afoo%2A'
+ ],
+ 'icingadb.with-sort-and-limit' => [
+ 'title' => 'With Sort And Limit',
+ 'url' => 'icingadb/hosts?host.name~%2Afoo%2A|host.name=bar&sort=host.state.severity&limit=50'
+ ],
+ 'not-monitoring-or-icingadb' => [
+ 'title' => 'Not Monitoring Or Icinga DB'
+ ],
+ 'not-monitoring-or-icingadb.something' => [
+ 'title' => 'Something',
+ 'url' => 'somewhere/something?foo=%2Abar%2A'
+ ]
+ ]
+ ],
+ 'menu-items' => [
+ 'initial' => [
+ 'foreign-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'example.com?q=foo'
+ ],
+ 'monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'icingadb-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.name=%2Afoo%2A'
+ ]
+ ],
+ 'expected' => [
+ 'foreign-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'example.com?q=foo'
+ ],
+ 'monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y'
+ ],
+ 'icingadb-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.name~%2Afoo%2A'
+ ]
+ ]
+ ],
+ 'shared-menu-items' => [
+ 'initial' => [
+ 'foreign-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'example.com?q=foo',
+ 'owner' => 'test'
+ ],
+ 'monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'monitoring/list/hosts?host_problem=1',
+ 'owner' => 'test'
+ ],
+ 'icingadb-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.name=%2Afoo%2A',
+ 'owner' => 'test'
+ ],
+ 'other-monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'monitoring/list/hosts?host_problem=1',
+ 'owner' => 'not-test'
+ ]
+ ],
+ 'expected' => [
+ 'foreign-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'example.com?q=foo',
+ 'owner' => 'test'
+ ],
+ 'monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y',
+ 'owner' => 'test'
+ ],
+ 'icingadb-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.name~%2Afoo%2A',
+ 'owner' => 'test'
+ ],
+ 'other-monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'monitoring/list/hosts?host_problem=1',
+ 'owner' => 'not-test'
+ ]
+ ]
+ ],
+ 'host-actions' => [
+ 'initial' => [
+ 'hosts' => [
+ 'type' => 'host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host_name=%2Afoo%2A'
+ ],
+ 'hosts_encoded_params' => [
+ 'type' => 'host-action',
+ 'url' => 'monitoring/list/hosts?host_name=%28foo%29&sort=_host_%28foo%29',
+ 'filter' => '_host_%28foo%29=bar'
+ ]
+ ],
+ 'expected' => [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name~%2Afoo%2A'
+ ],
+ 'hosts_encoded_params' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'icingadb/hosts?host.name=%28foo%29&sort=host.vars.%28foo%29',
+ 'filter' => 'host.vars.%28foo%29=bar'
+ ]
+ ]
+ ],
+ 'icingadb-host-actions' => [
+ 'initial' => [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name=%2Afoo%2A'
+ ]
+ ],
+ 'expected' => [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name~%2Afoo%2A'
+ ]
+ ]
+ ],
+ 'service-actions' => [
+ 'initial' => [
+ 'services' => [
+ 'type' => 'service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => '_service_foo=bar&_service_bar=%2Afoo%2A'
+ ],
+ 'services_encoded_params' => [
+ 'type' => 'host-action',
+ 'url' => 'monitoring/list/services?host_name=%28foo%29&sort=_host_%28foo%29',
+ 'filter' => '_host_%28foo%29=bar'
+ ]
+ ],
+ 'expected' => [
+ 'services' => [
+ 'type' => 'icingadb-service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => 'service.vars.foo=bar&service.vars.bar~%2Afoo%2A'
+ ],
+ 'services_encoded_params' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'icingadb/services?host.name=%28foo%29&sort=host.vars.%28foo%29',
+ 'filter' => 'host.vars.%28foo%29=bar'
+ ]
+ ]
+ ],
+ 'icingadb-service-actions' => [
+ 'initial' => [
+ 'services' => [
+ 'type' => 'icingadb-service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => 'service.vars.foo=%2Abar%2A'
+ ]
+ ],
+ 'expected' => [
+ 'services' => [
+ 'type' => 'icingadb-service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => 'service.vars.foo~%2Abar%2A'
+ ]
+ ]
+ ],
+ 'shared-host-actions' => [
+ 'initial' => [
+ 'shared-hosts' => [
+ 'type' => 'host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host_name=%2Afoo%2A',
+ 'owner' => 'test'
+ ],
+ 'hosts_encoded_params' => [
+ 'type' => 'host-action',
+ 'url' => 'monitoring/list/hosts?host_name=%28foo%29&sort=_host_%28foo%29',
+ 'filter' => '_host_%28foo%29=bar',
+ 'owner' => 'test'
+ ],
+ 'other-hosts' => [
+ 'type' => 'host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host_name=%2Afoo%2A',
+ 'owner' => 'not-test'
+ ]
+ ],
+ 'expected' => [
+ 'shared-hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name~%2Afoo%2A',
+ 'owner' => 'test'
+ ],
+ 'hosts_encoded_params' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'icingadb/hosts?host.name=%28foo%29&sort=host.vars.%28foo%29',
+ 'filter' => 'host.vars.%28foo%29=bar',
+ 'owner' => 'test'
+ ]
+ ]
+ ],
+ 'host-actions-legacy-macros' => [
+ 'initial' => [
+ 'hosts' => [
+ 'type' => 'host-action',
+ 'url' => 'example.com/search?q=$HOSTNAME$,$HOSTADDRESS$,$HOSTADDRESS6$',
+ 'filter' => 'host_name=%2Afoo%2A'
+ ]
+ ],
+ 'expected' => [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$,$host.address$,$host.address6$',
+ 'filter' => 'host.name~%2Afoo%2A'
+ ]
+ ]
+ ],
+ 'service-actions-legacy-macros' => [
+ 'initial' => [
+ 'services' => [
+ 'type' => 'service-action',
+ 'url' => 'example.com/search?q=$SERVICEDESC$,$HOSTNAME$,$HOSTADDRESS$,$HOSTADDRESS6$',
+ 'filter' => '_service_foo=bar&_service_bar=%2Afoo%2A'
+ ]
+ ],
+ 'expected' => [
+ 'services' => [
+ 'type' => 'icingadb-service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => 'service.vars.foo=bar&service.vars.bar~%2Afoo%2A'
+ ]
+ ]
+ ],
+ 'all-roles' => [
+ 'initial' => [
+ 'no-wildcards' => [
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'wildcards' => [
+ 'monitoring/filter/objects' => 'host_name=%2Afoo%2A|hostgroup_name=%2Afoo%2A'
+ ],
+ 'encoded_column' => [
+ 'monitoring/filter/objects' => '_host_%28foo%29=bar'
+ ],
+ 'blacklist' => [
+ 'monitoring/blacklist/properties' => 'host.vars.foo,service.vars.bar*,host.vars.a.**.d'
+ ],
+ 'full-access' => [
+ 'permissions' => 'module/monitoring,monitoring/*'
+ ],
+ 'general-read-access' => [
+ 'permissions' => 'module/monitoring'
+ ],
+ 'general-write-access' => [
+ 'permissions' => 'module/monitoring,monitoring/command/*'
+ ],
+ 'full-fine-grained-access' => [
+ 'permissions' => 'module/monitoring'
+ . ',monitoring/command/schedule-check'
+ . ',monitoring/command/acknowledge-problem'
+ . ',monitoring/command/remove-acknowledgement'
+ . ',monitoring/command/comment/add'
+ . ',monitoring/command/comment/delete'
+ . ',monitoring/command/downtime/schedule'
+ . ',monitoring/command/downtime/delete'
+ . ',monitoring/command/process-check-result'
+ . ',monitoring/command/feature/instance'
+ . ',monitoring/command/feature/object/active-checks'
+ . ',monitoring/command/feature/object/passive-checks'
+ . ',monitoring/command/feature/object/notifications'
+ . ',monitoring/command/feature/object/event-handler'
+ . ',monitoring/command/feature/object/flap-detection'
+ . ',monitoring/command/send-custom-notification'
+ ],
+ 'full-with-refusals' => [
+ 'permissions' => 'module/monitoring,monitoring/command/*',
+ 'refusals' => 'monitoring/command/downtime/*,monitoring/command/feature/instance'
+ ],
+ 'active-only' => [
+ 'permissions' => 'module/monitoring,monitoring/command/schedule-check/active-only'
+ ],
+ 'no-monitoring-contacts' => [
+ 'permissions' => 'module/monitoring,no-monitoring/contacts'
+ ],
+ 'reporting-only' => [
+ 'permissions' => 'module/reporting'
+ ],
+ 'icingadb' => [
+ 'icingadb/filter/objects' => 'host.name=%2Afoo%2A|hostgroup.name=%2Afoo%2A',
+ 'icingadb/filter/services' => 'service.name=%2Abar%2A&service.vars.env=prod',
+ 'icingadb/filter/hosts' => 'host.vars.env=%2Afoo%2A'
+ ]
+ ],
+ 'expected' => [
+ 'no-wildcards' => [
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo',
+ 'icingadb/filter/objects' => 'host.name=foo|hostgroup.name=foo'
+ ],
+ 'wildcards' => [
+ 'monitoring/filter/objects' => 'host_name=%2Afoo%2A|hostgroup_name=%2Afoo%2A',
+ 'icingadb/filter/objects' => 'host.name~%2Afoo%2A|hostgroup.name~%2Afoo%2A'
+ ],
+ 'encoded_column' => [
+ 'monitoring/filter/objects' => '_host_%28foo%29=bar',
+ 'icingadb/filter/objects' => 'host.vars.%28foo%29=bar'
+ ],
+ 'blacklist' => [
+ 'monitoring/blacklist/properties' => 'host.vars.foo,service.vars.bar*,host.vars.a.**.d',
+ 'icingadb/denylist/variables' => 'foo,bar*,a.*.d'
+ ],
+ 'full-access' => [
+ 'permissions' => 'module/monitoring,monitoring/*'
+ ],
+ 'general-read-access' => [
+ 'permissions' => 'module/monitoring'
+ ],
+ 'general-write-access' => [
+ 'permissions' => 'module/monitoring,monitoring/command/*,icingadb/command/*'
+ ],
+ 'full-fine-grained-access' => [
+ 'permissions' => 'module/monitoring'
+ . ',monitoring/command/schedule-check'
+ . ',icingadb/command/schedule-check'
+ . ',monitoring/command/acknowledge-problem'
+ . ',icingadb/command/acknowledge-problem'
+ . ',monitoring/command/remove-acknowledgement'
+ . ',icingadb/command/remove-acknowledgement'
+ . ',monitoring/command/comment/add'
+ . ',icingadb/command/comment/add'
+ . ',monitoring/command/comment/delete'
+ . ',icingadb/command/comment/delete'
+ . ',monitoring/command/downtime/schedule'
+ . ',icingadb/command/downtime/schedule'
+ . ',monitoring/command/downtime/delete'
+ . ',icingadb/command/downtime/delete'
+ . ',monitoring/command/process-check-result'
+ . ',icingadb/command/process-check-result'
+ . ',monitoring/command/feature/instance'
+ . ',icingadb/command/feature/instance'
+ . ',monitoring/command/feature/object/active-checks'
+ . ',icingadb/command/feature/object/active-checks'
+ . ',monitoring/command/feature/object/passive-checks'
+ . ',icingadb/command/feature/object/passive-checks'
+ . ',monitoring/command/feature/object/notifications'
+ . ',icingadb/command/feature/object/notifications'
+ . ',monitoring/command/feature/object/event-handler'
+ . ',icingadb/command/feature/object/event-handler'
+ . ',monitoring/command/feature/object/flap-detection'
+ . ',icingadb/command/feature/object/flap-detection'
+ . ',monitoring/command/send-custom-notification'
+ . ',icingadb/command/send-custom-notification'
+ ],
+ 'full-with-refusals' => [
+ 'permissions' => 'module/monitoring,monitoring/command/*,icingadb/command/*',
+ 'refusals' => 'monitoring/command/downtime/*'
+ . ',icingadb/command/downtime/*'
+ . ',monitoring/command/feature/instance'
+ . ',icingadb/command/feature/instance'
+ ],
+ 'active-only' => [
+ 'permissions' => 'module/monitoring'
+ . ',monitoring/command/schedule-check/active-only'
+ . ',icingadb/command/schedule-check/active-only'
+ ],
+ 'no-monitoring-contacts' => [
+ 'permissions' => 'module/monitoring,no-monitoring/contacts',
+ 'icingadb/denylist/routes' => 'users,usergroups'
+ ],
+ 'reporting-only' => [
+ 'permissions' => 'module/reporting'
+ ],
+ 'icingadb' => [
+ 'icingadb/filter/objects' => 'host.name~%2Afoo%2A|hostgroup.name~%2Afoo%2A',
+ 'icingadb/filter/services' => 'service.name~%2Abar%2A&service.vars.env=prod',
+ 'icingadb/filter/hosts' => 'host.vars.env~%2Afoo%2A'
+ ]
+ ]
+ ],
+ 'single-role-or-group' => [
+ 'initial' => [
+ 'one' => [
+ 'groups' => 'support,helpdesk',
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'two' => [
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'three' => [
+ 'icingadb/filter/objects' => 'host.name=%2Afoo%2A'
+ ]
+ ],
+ 'expected' => [
+ 'one' => [
+ 'groups' => 'support,helpdesk',
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo',
+ 'icingadb/filter/objects' => 'host.name=foo|hostgroup.name=foo'
+ ],
+ 'two' => [
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'three' => [
+ 'icingadb/filter/objects' => 'host.name=%2Afoo%2A'
+ ]
+ ]
+ ]
+ ];
+
+ protected $defaultConfigDir;
+
+ protected $fileStorage;
+
+ protected function setUp(): void
+ {
+ $this->defaultConfigDir = Config::$configDir;
+ $this->fileStorage = new TemporaryLocalFileStorage();
+
+ Config::$configDir = dirname($this->fileStorage->resolvePath('bogus'));
+ }
+
+ protected function tearDown(): void
+ {
+ Config::$configDir = $this->defaultConfigDir;
+ unset($this->fileStorage); // Should clean up automatically
+ Config::module('monitoring', 'config', true);
+ }
+
+ protected function getConfig(string $case): array
+ {
+ return [$this->config[$case]['initial'], $this->config[$case]['expected']];
+ }
+
+ protected function createConfig(string $path, array $data): void
+ {
+ $config = new Config(new ConfigObject($data));
+ $config->saveIni($this->fileStorage->resolvePath($path));
+ }
+
+ protected function loadConfig(string $path): array
+ {
+ return Config::fromIni($this->fileStorage->resolvePath($path))->toArray();
+ }
+
+ protected function createCommandInstance(string ...$params): MigrateCommand
+ {
+ array_unshift($params, 'program');
+
+ $app = $this->createConfiguredMock(Cli::class, [
+ 'getParams' => new Params($params),
+ 'getModuleManager' => $this->createConfiguredMock(Manager::class, [
+ 'loadEnabledModules' => null
+ ])
+ ]);
+
+ return new MigrateCommand(
+ $app,
+ 'migrate',
+ 'toicingadb',
+ 'dashboard',
+ false
+ );
+ }
+
+ /**
+ * Checks the following:
+ * - Whether only a single user is handled
+ * - Whether backups are made
+ * - Whether a second run changes nothing, if nothing changed
+ * - Whether a second run keeps the backup, if nothing changed
+ * - Whether a new backup isn't made, if nothing changed
+ * - Whether existing Icinga DB dashboards are transformed regarding wildcard filters
+ */
+ public function testDashboardMigrationBehavesAsExpectedByDefault()
+ {
+ [$initialConfig, $expected] = $this->getConfig('dashboards');
+
+ $this->createConfig('dashboards/test/dashboard.ini', $initialConfig);
+ $this->createConfig('dashboards/test2/dashboard.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->dashboardAction();
+
+ $config = $this->loadConfig('dashboards/test/dashboard.ini');
+ $this->assertSame($expected, $config);
+
+ $config2 = $this->loadConfig('dashboards/test2/dashboard.ini');
+ $this->assertSame($initialConfig, $config2);
+
+ $backup = $this->loadConfig('dashboards/test/dashboard.backup.ini');
+ $this->assertSame($initialConfig, $backup);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->dashboardAction();
+
+ $configAfterSecondRun = $this->loadConfig('dashboards/test/dashboard.ini');
+ $this->assertSame($config, $configAfterSecondRun);
+
+ $backupAfterSecondRun = $this->loadConfig('dashboards/test/dashboard.backup.ini');
+ $this->assertSame($backup, $backupAfterSecondRun);
+
+ $backup1AfterSecondRun = $this->loadConfig('dashboards/test/dashboard.backup1.ini');
+ $this->assertEmpty($backup1AfterSecondRun);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether a second run creates a new backup, if something changed
+ */
+ public function testDashboardMigrationCreatesMultipleBackups()
+ {
+ $initialOldConfig = [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ]
+ ];
+ $initialNewConfig = [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y'
+ ],
+ 'hosts.group_members' => [
+ 'title' => 'Group Members',
+ 'url' => 'monitoring/list/hosts?hostgroup_name=group1|hostgroup_name=group2'
+ ]
+ ];
+ $expectedNewConfig = [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y'
+ ]
+ ];
+ $expectedFinalConfig = [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y'
+ ],
+ 'hosts.group_members' => [
+ 'title' => 'Group Members',
+ 'url' => 'icingadb/hosts?hostgroup.name=group1|hostgroup.name=group2'
+ ]
+ ];
+
+ $this->createConfig('dashboards/test/dashboard.ini', $initialOldConfig);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->dashboardAction();
+
+ $newConfig = $this->loadConfig('dashboards/test/dashboard.ini');
+ $this->assertSame($expectedNewConfig, $newConfig);
+ $oldBackup = $this->loadConfig('dashboards/test/dashboard.backup.ini');
+ $this->assertSame($initialOldConfig, $oldBackup);
+
+ $this->createConfig('dashboards/test/dashboard.ini', $initialNewConfig);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->dashboardAction();
+
+ $finalConfig = $this->loadConfig('dashboards/test/dashboard.ini');
+ $this->assertSame($expectedFinalConfig, $finalConfig);
+ $newBackup = $this->loadConfig('dashboards/test/dashboard.backup1.ini');
+ $this->assertSame($initialNewConfig, $newBackup);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether backups are skipped
+ *
+ * @depends testDashboardMigrationBehavesAsExpectedByDefault
+ */
+ public function testDashboardMigrationSkipsBackupIfRequested()
+ {
+ [$initialConfig, $expected] = $this->getConfig('dashboards');
+
+ $this->createConfig('dashboards/test/dashboard.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--user', 'test', '--no-backup');
+ $command->dashboardAction();
+
+ $config = $this->loadConfig('dashboards/test/dashboard.ini');
+ $this->assertSame($expected, $config);
+
+ $backup = $this->loadConfig('dashboards/test/dashboard.backup.ini');
+ $this->assertEmpty($backup);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether multiple users are handled
+ * - Whether multiple backups are made
+ *
+ * @depends testDashboardMigrationBehavesAsExpectedByDefault
+ */
+ public function testDashboardMigrationMigratesAllUsers()
+ {
+ [$initialConfig, $expected] = $this->getConfig('dashboards');
+
+ $users = ['foo', 'bar', 'raboof'];
+
+ foreach ($users as $user) {
+ $this->createConfig("dashboards/$user/dashboard.ini", $initialConfig);
+ }
+
+ $command = $this->createCommandInstance('--user', '*');
+ $command->dashboardAction();
+
+ foreach ($users as $user) {
+ $config = $this->loadConfig("dashboards/$user/dashboard.ini");
+ $this->assertSame($expected, $config);
+
+ $backup = $this->loadConfig("dashboards/$user/dashboard.backup.ini");
+ $this->assertSame($initialConfig, $backup);
+ }
+ }
+
+ public function testDashboardMigrationExpectsUserSwitch()
+ {
+ $this->expectException(MissingParameterException::class);
+ $this->expectExceptionMessage('Required parameter \'user\' missing');
+
+ $command = $this->createCommandInstance();
+ $command->dashboardAction();
+ }
+
+ /**
+ * Checks the following:
+ * - Whether only a single user is handled
+ * - Whether shared items are migrated, depending on the owner
+ * - Whether old configs are kept/or backups are created
+ * - Whether a second run changes nothing, if nothing changed
+ * - Whether a second run keeps the backup, if nothing changed
+ * - Whether a new backup isn't created, if nothing changed
+ */
+ public function testNavigationMigrationBehavesAsExpectedByDefault()
+ {
+ [$initialMenuConfig, $expectedMenu] = $this->getConfig('menu-items');
+ [$initialHostConfig, $expectedHosts] = $this->getConfig('host-actions');
+ [$initialServiceConfig, $expectedServices] = $this->getConfig('service-actions');
+
+ $this->createConfig('preferences/test/menu.ini', $initialMenuConfig);
+ $this->createConfig('preferences/test/host-actions.ini', $initialHostConfig);
+ $this->createConfig('preferences/test/service-actions.ini', $initialServiceConfig);
+ $this->createConfig('preferences/test2/menu.ini', $initialMenuConfig);
+ $this->createConfig('preferences/test2/host-actions.ini', $initialHostConfig);
+ $this->createConfig('preferences/test2/service-actions.ini', $initialServiceConfig);
+
+ [$initialSharedMenuConfig, $expectedSharedMenu] = $this->getConfig('shared-menu-items');
+ $this->createConfig('navigation/menu.ini', $initialSharedMenuConfig);
+
+ [$initialSharedConfig, $expectedShared] = $this->getConfig('shared-host-actions');
+ $this->createConfig('navigation/host-actions.ini', $initialSharedConfig);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+
+ $menuConfig = $this->loadConfig('preferences/test/menu.ini');
+ $this->assertSame($expectedMenu, $menuConfig);
+
+ $sharedMenuConfig = $this->loadConfig('navigation/menu.ini');
+ $this->assertSame($expectedSharedMenu, $sharedMenuConfig);
+
+ $menuConfig2 = $this->loadConfig('preferences/test2/menu.ini');
+ $this->assertSame($initialMenuConfig, $menuConfig2);
+
+ $menuBackup = $this->loadConfig('preferences/test/menu.backup.ini');
+ $this->assertSame($initialMenuConfig, $menuBackup);
+
+ $hosts = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $services = $this->loadConfig('preferences/test/icingadb-service-actions.ini');
+ $this->assertSame($expectedHosts, $hosts);
+ $this->assertSame($expectedServices, $services);
+
+ $sharedConfig = $this->loadConfig('navigation/icingadb-host-actions.ini');
+ $this->assertSame($expectedShared, $sharedConfig);
+
+ $hosts2 = $this->loadConfig('preferences/test2/icingadb-host-actions.ini');
+ $services2 = $this->loadConfig('preferences/test2/icingadb-service-actions.ini');
+ $this->assertEmpty($hosts2);
+ $this->assertEmpty($services2);
+
+ $oldHosts = $this->loadConfig('preferences/test/host-actions.ini');
+ $oldServices = $this->loadConfig('preferences/test/service-actions.ini');
+ $this->assertSame($initialHostConfig, $oldHosts);
+ $this->assertSame($initialServiceConfig, $oldServices);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+
+ $menuConfigAfterSecondRun = $this->loadConfig('preferences/test/menu.ini');
+ $this->assertSame($menuConfig, $menuConfigAfterSecondRun);
+
+ $menuBackupAfterSecondRun = $this->loadConfig('preferences/test/menu.backup.ini');
+ $this->assertSame($menuBackup, $menuBackupAfterSecondRun);
+
+ $menuBackup1AfterSecondRun = $this->loadConfig('preferences/test/menu.backup1.ini');
+ $this->assertEmpty($menuBackup1AfterSecondRun);
+
+ $hostsAfterSecondRun = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $servicesAfterSecondRun = $this->loadConfig('preferences/test/icingadb-service-actions.ini');
+ $this->assertSame($hosts, $hostsAfterSecondRun);
+ $this->assertSame($services, $servicesAfterSecondRun);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether a second run creates a new backup, if something changed
+ *
+ * @depends testNavigationMigrationBehavesAsExpectedByDefault
+ */
+ public function testNavigationMigrationCreatesMultipleBackups()
+ {
+ $initialOldConfig = [
+ 'hosts' => [
+ 'title' => 'Host Problems',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ]
+ ];
+ $initialNewConfig = [
+ 'hosts' => [
+ 'title' => 'Host Problems',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y'
+ ],
+ 'group_members' => [
+ 'title' => 'Group Members',
+ 'url' => 'monitoring/list/hosts?hostgroup_name=group1|hostgroup_name=group2'
+ ]
+ ];
+ $expectedNewConfig = [
+ 'hosts' => [
+ 'title' => 'Host Problems',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y'
+ ]
+ ];
+ $expectedFinalConfig = [
+ 'hosts' => [
+ 'title' => 'Host Problems',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y'
+ ],
+ 'group_members' => [
+ 'title' => 'Group Members',
+ 'url' => 'icingadb/hosts?hostgroup.name=group1|hostgroup.name=group2'
+ ]
+ ];
+
+ $this->createConfig('preferences/test/menu.ini', $initialOldConfig);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+
+ $newConfig = $this->loadConfig('preferences/test/menu.ini');
+ $this->assertSame($expectedNewConfig, $newConfig);
+ $oldBackup = $this->loadConfig('preferences/test/menu.backup.ini');
+ $this->assertSame($initialOldConfig, $oldBackup);
+
+ $this->createConfig('preferences/test/menu.ini', $initialNewConfig);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+
+ $finalConfig = $this->loadConfig('preferences/test/menu.ini');
+ $this->assertSame($expectedFinalConfig, $finalConfig);
+ $newBackup = $this->loadConfig('preferences/test/menu.backup1.ini');
+ $this->assertSame($initialNewConfig, $newBackup);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether backups are skipped
+ *
+ * @depends testNavigationMigrationBehavesAsExpectedByDefault
+ */
+ public function testNavigationMigrationSkipsBackupIfRequested()
+ {
+ [$initialConfig, $expected] = $this->getConfig('menu-items');
+
+ $this->createConfig('preferences/test/menu.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--user', 'test', '--no-backup');
+ $command->navigationAction();
+
+ $config = $this->loadConfig('preferences/test/menu.ini');
+ $this->assertSame($expected, $config);
+
+ $backup = $this->loadConfig('preferences/test/menu.backup.ini');
+ $this->assertEmpty($backup);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether existing Icinga DB Actions are transformed regarding wildcard filters
+ */
+ public function testNavigationMigrationTransformsAlreadyExistingIcingaDBActions()
+ {
+ [$initialHostConfig, $expectedHosts] = $this->getConfig('icingadb-host-actions');
+ [$initialServiceConfig, $expectedServices] = $this->getConfig('icingadb-service-actions');
+
+ $this->createConfig('preferences/test/icingadb-host-actions.ini', $initialHostConfig);
+ $this->createConfig('preferences/test/icingadb-service-actions.ini', $initialServiceConfig);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+
+ $hosts = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $services = $this->loadConfig('preferences/test/icingadb-service-actions.ini');
+ $this->assertSame($expectedHosts, $hosts);
+ $this->assertSame($expectedServices, $services);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+
+ $hostsAfterSecondRun = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $servicesAfterSecondRun = $this->loadConfig('preferences/test/icingadb-service-actions.ini');
+ $this->assertSame($hosts, $hostsAfterSecondRun);
+ $this->assertSame($services, $servicesAfterSecondRun);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether legacy host/service macros are migrated
+ */
+ public function testNavigationMigrationMigratesLegacyMacros()
+ {
+ [$initialHostConfig, $expectedHosts] = $this->getConfig('host-actions-legacy-macros');
+ [$initialServiceConfig, $expectedServices] = $this->getConfig('service-actions-legacy-macros');
+
+ $this->createConfig('preferences/test/host-actions.ini', $initialHostConfig);
+ $this->createConfig('preferences/test/service-actions.ini', $initialServiceConfig);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+
+ $hosts = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $services = $this->loadConfig('preferences/test/icingadb-service-actions.ini');
+ $this->assertSame($expectedHosts, $hosts);
+ $this->assertSame($expectedServices, $services);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether old configs are removed
+ */
+ public function testNavigationMigrationDeletesOldConfigsIfRequested()
+ {
+ [$initialHostConfig, $expectedHosts] = $this->getConfig('host-actions');
+ [$initialServiceConfig, $expectedServices] = $this->getConfig('service-actions');
+
+ $this->createConfig('preferences/test/host-actions.ini', $initialHostConfig);
+ $this->createConfig('preferences/test/service-actions.ini', $initialServiceConfig);
+
+ $command = $this->createCommandInstance('--user', 'test', '--no-backup');
+ $command->navigationAction();
+
+ $hosts = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $services = $this->loadConfig('preferences/test/icingadb-service-actions.ini');
+ $this->assertSame($expectedHosts, $hosts);
+ $this->assertSame($expectedServices, $services);
+
+ $oldHosts = $this->loadConfig('preferences/test/host-actions.ini');
+ $oldServices = $this->loadConfig('preferences/test/service-actions.ini');
+ $this->assertEmpty($oldHosts);
+ $this->assertEmpty($oldServices);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether existing configs are left alone by default
+ * - Whether existing configs are overridden if requested
+ */
+ public function testNavigationMigrationOverridesExistingActionsIfRequested()
+ {
+ $initialOldUserConfig = [
+ 'hosts' => [
+ 'type' => 'host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host_name=%2Afoo%2A'
+ ]
+ ];
+ $initialOldSharedConfig = [
+ 'hosts' => [
+ 'type' => 'host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host_name=%2Afoo%2A',
+ 'owner' => 'test'
+ ]
+ ];
+ $initialNewUserConfig = [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name~%2Abar%2A'
+ ]
+ ];
+ $initialNewSharedConfig = [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name~%2Abar%2A',
+ 'owner' => 'test'
+ ]
+ ];
+ $expectedFinalUserConfig = [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name~%2Afoo%2A'
+ ]
+ ];
+ $expectedFinalSharedConfig = [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name~%2Afoo%2A',
+ 'owner' => 'test'
+ ]
+ ];
+
+ $this->createConfig('preferences/test/host-actions.ini', $initialOldUserConfig);
+ $this->createConfig('preferences/test/icingadb-host-actions.ini', $initialNewUserConfig);
+ $this->createConfig('navigation/host-actions.ini', $initialOldSharedConfig);
+ $this->createConfig('navigation/icingadb-host-actions.ini', $initialNewSharedConfig);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+
+ $finalUserConfig = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $this->assertSame($initialNewUserConfig, $finalUserConfig);
+
+ $finalSharedConfig = $this->loadConfig('navigation/icingadb-host-actions.ini');
+ $this->assertSame($initialNewSharedConfig, $finalSharedConfig);
+
+ $command = $this->createCommandInstance('--user', 'test', '--override');
+ $command->navigationAction();
+
+ $finalUserConfig = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $this->assertSame($expectedFinalUserConfig, $finalUserConfig);
+
+ $finalSharedConfig = $this->loadConfig('navigation/icingadb-host-actions.ini');
+ $this->assertSame($expectedFinalSharedConfig, $finalSharedConfig);
+ }
+
+ public function testNavigationMigrationExpectsUserSwitch()
+ {
+ $this->expectException(MissingParameterException::class);
+ $this->expectExceptionMessage('Required parameter \'user\' missing');
+
+ $command = $this->createCommandInstance();
+ $command->navigationAction();
+ }
+
+ /**
+ * Checks the following:
+ * - Whether only a single role is handled
+ * - Whether role name matching works
+ */
+ public function testRoleMigrationHandlesASingleRoleOnlyIfRequested()
+ {
+ [$initialConfig, $expected] = $this->getConfig('single-role-or-group');
+
+ $this->createConfig('roles.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--role', 'one');
+ $command->roleAction();
+
+ $config = $this->loadConfig('roles.ini');
+ $this->assertSame($expected, $config);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether only a single role is handled
+ * - Whether group matching works
+ */
+ public function testRoleMigrationHandlesARoleWithMatchingGroups()
+ {
+ [$initialConfig, $expected] = $this->getConfig('single-role-or-group');
+
+ $this->createConfig('roles.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--group', 'support');
+ $command->roleAction();
+
+ $config = $this->loadConfig('roles.ini');
+ $this->assertSame($expected, $config);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether permissions are properly migrated
+ * - Whether refusals are properly migrated
+ * - Whether restrictions are properly migrated
+ * - Whether blacklists are properly migrated
+ * - Whether backups are created
+ * - Whether a second run changes nothing, if nothing changed
+ * - Whether a second run keeps the backup, if nothing changed
+ * - Whether a new backup isn't created, if nothing changed
+ */
+ public function testRoleMigrationMigratesAllRoles()
+ {
+ [$initialConfig, $expected] = $this->getConfig('all-roles');
+
+ $this->createConfig('roles.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--role', '*');
+ $command->roleAction();
+
+ $config = $this->loadConfig('roles.ini');
+ $this->assertSame($expected, $config);
+
+ $backup = $this->loadConfig('roles.backup.ini');
+ $this->assertSame($initialConfig, $backup);
+
+ $command = $this->createCommandInstance('--role', '*');
+ $command->roleAction();
+
+ $configAfterSecondRun = $this->loadConfig('roles.ini');
+ $this->assertSame($config, $configAfterSecondRun);
+
+ $backupAfterSecondRun = $this->loadConfig('roles.backup.ini');
+ $this->assertSame($backup, $backupAfterSecondRun);
+
+ $backup2 = $this->loadConfig('roles.backup1.ini');
+ $this->assertEmpty($backup2);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether backups are skipped
+ *
+ * @depends testRoleMigrationMigratesAllRoles
+ */
+ public function testRoleMigrationSkipsBackupIfRequested()
+ {
+ [$initialConfig, $expected] = $this->getConfig('all-roles');
+
+ $this->createConfig('roles.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--role', '*', '--no-backup');
+ $command->roleAction();
+
+ $config = $this->loadConfig('roles.ini');
+ $this->assertSame($expected, $config);
+
+ $backup = $this->loadConfig('roles.backup.ini');
+ $this->assertEmpty($backup);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether monitoring's variable protection rules are migrated to all roles granting access to monitoring
+ */
+ public function testRoleMigrationAlsoMigratesVariableProtections()
+ {
+ $initialConfig = [
+ 'one' => [
+ 'permissions' => 'module/monitoring'
+ ],
+ 'two' => [
+ 'permissions' => 'module/monitoring'
+ ],
+ 'three' => [
+ 'permissions' => 'module/reporting'
+ ]
+ ];
+ $expectedConfig = [
+ 'one' => [
+ 'permissions' => 'module/monitoring',
+ 'icingadb/protect/variables' => 'ob.*,env'
+ ],
+ 'two' => [
+ 'permissions' => 'module/monitoring',
+ 'icingadb/protect/variables' => 'ob.*,env'
+ ],
+ 'three' => [
+ 'permissions' => 'module/reporting'
+ ]
+ ];
+
+ $this->createConfig('modules/monitoring/config.ini', [
+ 'security' => [
+ 'protected_customvars' => 'ob.*,env'
+ ]
+ ]);
+
+ // Invalidate config cache
+ Config::module('monitoring', 'config', true);
+
+ $this->createConfig('roles.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--role', '*');
+ $command->roleAction();
+
+ $config = $this->loadConfig('roles.ini');
+ $this->assertSame($expectedConfig, $config);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether already migrated roles are skipped during migration
+ * - Whether already migrated roles are transformed regarding wildcard filters
+ */
+ public function testRoleMigrationSkipsRolesThatAlreadyGrantAccessToIcingaDbButTransformWildcardRestrictions()
+ {
+ $initialConfig = [
+ 'only-monitoring' => [
+ 'permissions' => 'module/monitoring,monitoring/command/comment/*',
+ 'monitoring/filter/objects' => 'host_name=%2Afoo%2A'
+ ],
+ 'monitoring-and-icingadb' => [
+ 'permissions' => 'module/monitoring,monitoring/command/comment/*,module/icingadb',
+ 'monitoring/filter/objects' => 'host_name=%2Abar%2A',
+ 'icingadb/filter/objects' => 'host.name=%2Afoo%2A'
+ ]
+ ];
+ $expectedConfig = [
+ 'only-monitoring' => [
+ 'permissions' => 'module/monitoring,monitoring/command/comment/*,icingadb/command/comment/*',
+ 'monitoring/filter/objects' => 'host_name=%2Afoo%2A',
+ 'icingadb/filter/objects' => 'host.name~%2Afoo%2A'
+ ],
+ 'monitoring-and-icingadb' => [
+ 'permissions' => 'module/monitoring,monitoring/command/comment/*,module/icingadb',
+ 'monitoring/filter/objects' => 'host_name=%2Abar%2A',
+ 'icingadb/filter/objects' => 'host.name~%2Afoo%2A'
+ ]
+ ];
+
+ $this->createConfig('roles.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--role', '*');
+ $command->roleAction();
+
+ $config = $this->loadConfig('roles.ini');
+ $this->assertSame($expectedConfig, $config);
+ }
+
+ /**
+ * Checks the following:
+ * - Whether already migrated roles are reset if requested
+ */
+ public function testRoleMigrationOverridesAlreadyMigratedRolesIfRequested()
+ {
+ $initialConfig = [
+ 'only-monitoring' => [
+ 'permissions' => 'module/monitoring,monitoring/command/comment/*',
+ 'monitoring/filter/objects' => 'host_name=%2Afoo%2A'
+ ],
+ 'monitoring-and-icingadb' => [
+ 'permissions' => 'module/monitoring,monitoring/command/comment/*,module/icingadb',
+ 'monitoring/filter/objects' => 'host_name=%2Abar%2A',
+ 'icingadb/filter/objects' => 'host.name=%2Afoo%2A'
+ ]
+ ];
+ $expectedConfig = [
+ 'only-monitoring' => [
+ 'permissions' => 'module/monitoring,monitoring/command/comment/*,icingadb/command/comment/*',
+ 'monitoring/filter/objects' => 'host_name=%2Afoo%2A',
+ 'icingadb/filter/objects' => 'host.name~%2Afoo%2A'
+ ],
+ 'monitoring-and-icingadb' => [
+ 'permissions' => 'module/monitoring'
+ . ',monitoring/command/comment/*'
+ . ',icingadb/command/comment/*',
+ 'monitoring/filter/objects' => 'host_name=%2Abar%2A',
+ 'icingadb/filter/objects' => 'host.name~%2Abar%2A'
+ ]
+ ];
+
+ $this->createConfig('roles.ini', $initialConfig);
+
+ $command = $this->createCommandInstance('--role', '*', '--override');
+ $command->roleAction();
+
+ $config = $this->loadConfig('roles.ini');
+ $this->assertSame($expectedConfig, $config);
+ }
+
+ public function testRoleMigrationExpectsTheRoleOrGroupSwitch()
+ {
+ $this->expectException(IcingaException::class);
+ $this->expectExceptionMessage("One of the parameters 'group' or 'role' must be supplied");
+
+ $command = $this->createCommandInstance();
+ $command->roleAction();
+ }
+
+ public function testRoleMigrationExpectsEitherTheRoleOrGroupSwitchButNotBoth()
+ {
+ $this->expectException(IcingaException::class);
+ $this->expectExceptionMessage("Use either 'group' or 'role'. Both cannot be used as role overrules group.");
+
+ $command = $this->createCommandInstance('--role=foo', '--group=bar');
+ $command->roleAction();
+ }
+
+ public function testFilterMigrationWorksAsExpected()
+ {
+ $initialHostActionConfig = [
+ 'hosts' => [
+ 'type' => 'host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host_name=%2Afoo%2A'
+ ]
+ ];
+ $expectedHostActionConfig = $initialHostActionConfig;
+
+ $initialIcingadbHostActionConfig = [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name=%2Afoo%2A'
+ ]
+ ];
+ $expectedIcingadbHostActionConfig = [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name~%2Afoo%2A'
+ ]
+ ];
+
+ $initialServiceActionConfig = [
+ 'services' => [
+ 'type' => 'service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => '_service_foo=bar&_service_bar=%2Afoo%2A'
+ ]
+ ];
+ $expectedServiceActionConfig = $initialServiceActionConfig;
+
+ $initialIcingadbServiceActionConfig = [
+ 'services' => [
+ 'type' => 'icingadb-service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => 'service.vars.foo=bar&service.vars.bar=%2Afoo%2A'
+ ]
+ ];
+ $expectedIcingadbServiceActionConfig = [
+ 'services' => [
+ 'type' => 'icingadb-service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => 'service.vars.foo=bar&service.vars.bar~%2Afoo%2A'
+ ]
+ ];
+
+ $initialMenuConfig = [
+ 'foreign-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'example.com?q=foo'
+ ],
+ 'monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'icingadb-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.name=%2Afoo%2A'
+ ]
+ ];
+ $expectedMenuConfig = [
+ 'foreign-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'example.com?q=foo'
+ ],
+ 'monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'icingadb-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.name~%2Afoo%2A'
+ ]
+ ];
+
+ $initialDashboardConfig = [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'icingadb' => [
+ 'title' => 'Icinga DB'
+ ],
+ 'icingadb.wildcards' => [
+ 'title' => 'Wildcards',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y&hostgroup.name=%2Alinux%2A'
+ ]
+ ];
+ $expectedDashboardConfig = [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'icingadb' => [
+ 'title' => 'Icinga DB'
+ ],
+ 'icingadb.wildcards' => [
+ 'title' => 'Wildcards',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y&hostgroup.name~%2Alinux%2A'
+ ]
+ ];
+
+ $initialRoleConfig = [
+ 'one' => [
+ 'groups' => 'support,helpdesk',
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'two' => [
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'three' => [
+ 'icingadb/filter/objects' => 'host.name=%2Afoo%2A'
+ ]
+ ];
+ $expectedRoleConfig = [
+ 'one' => [
+ 'groups' => 'support,helpdesk',
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'two' => [
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'three' => [
+ 'icingadb/filter/objects' => 'host.name~%2Afoo%2A'
+ ]
+ ];
+
+ $this->createConfig('preferences/test/host-actions.ini', $initialHostActionConfig);
+ $this->createConfig('preferences/test/service-actions.ini', $initialServiceActionConfig);
+ $this->createConfig('preferences/test/icingadb-host-actions.ini', $initialIcingadbHostActionConfig);
+ $this->createConfig('preferences/test/icingadb-service-actions.ini', $initialIcingadbServiceActionConfig);
+ $this->createConfig('dashboards/test/dashboard.ini', $initialDashboardConfig);
+ $this->createConfig('preferences/test/menu.ini', $initialMenuConfig);
+ $this->createConfig('roles.ini', $initialRoleConfig);
+
+ $command = $this->createCommandInstance();
+ $command->filterAction();
+
+ $hostActionConfig = $this->loadConfig('preferences/test/host-actions.ini');
+ $serviceActionConfig = $this->loadConfig('preferences/test/service-actions.ini');
+ $icingadbHostActionConfig = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $icingadbServiceActionConfig = $this->loadConfig('preferences/test/icingadb-service-actions.ini');
+ $dashboardBackup = $this->loadConfig('dashboards/test/dashboard.backup.ini');
+ $dashboardConfig = $this->loadConfig('dashboards/test/dashboard.ini');
+ $menuBackup = $this->loadConfig('preferences/test/menu.backup.ini');
+ $menuConfig = $this->loadConfig('preferences/test/menu.ini');
+ $roleBackup = $this->loadConfig('roles.backup.ini');
+ $roleConfig = $this->loadConfig('roles.ini');
+
+ $this->assertSame($expectedHostActionConfig, $hostActionConfig);
+ $this->assertSame($expectedServiceActionConfig, $serviceActionConfig);
+ $this->assertSame($initialDashboardConfig, $dashboardBackup);
+ $this->assertSame($initialMenuConfig, $menuBackup);
+ $this->assertSame($initialRoleConfig, $roleBackup);
+
+ $this->assertSame($expectedIcingadbHostActionConfig, $icingadbHostActionConfig);
+ $this->assertSame($expectedIcingadbServiceActionConfig, $icingadbServiceActionConfig);
+ $this->assertSame($expectedDashboardConfig, $dashboardConfig);
+ $this->assertSame($expectedMenuConfig, $menuConfig);
+ $this->assertSame($expectedRoleConfig, $roleConfig);
+ }
+
+ /**
+ * @depends testFilterMigrationWorksAsExpected
+ */
+ public function testFilterMigrationSkipsBackupsIfRequested()
+ {
+ $initialHostActionConfig = [
+ 'hosts' => [
+ 'type' => 'host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host_name=%2Afoo%2A'
+ ]
+ ];
+ $expectedHostActionConfig = $initialHostActionConfig;
+
+ $initialIcingadbHostActionConfig = [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name=%2Afoo%2A'
+ ]
+ ];
+ $expectedIcingadbHostActionConfig = [
+ 'hosts' => [
+ 'type' => 'icingadb-host-action',
+ 'url' => 'example.com/search?q=$host.name$',
+ 'filter' => 'host.name~%2Afoo%2A'
+ ]
+ ];
+
+ $initialServiceActionConfig = [
+ 'services' => [
+ 'type' => 'service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => '_service_foo=bar&_service_bar=%2Afoo%2A'
+ ]
+ ];
+ $expectedServiceActionConfig = $initialServiceActionConfig;
+
+ $initialIcingadbServiceActionConfig = [
+ 'services' => [
+ 'type' => 'icingadb-service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => 'service.vars.foo=bar&service.vars.bar=%2Afoo%2A'
+ ]
+ ];
+ $expectedIcingadbServiceActionConfig = [
+ 'services' => [
+ 'type' => 'icingadb-service-action',
+ 'url' => 'example.com/search?q=$service.name$,$host.name$,$host.address$,$host.address6$',
+ 'filter' => 'service.vars.foo=bar&service.vars.bar~%2Afoo%2A'
+ ]
+ ];
+
+ $initialMenuConfig = [
+ 'foreign-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'example.com?q=foo'
+ ],
+ 'monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'icingadb-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.name=%2Afoo%2A'
+ ]
+ ];
+ $expectedMenuConfig = [
+ 'foreign-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'example.com?q=foo'
+ ],
+ 'monitoring-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'icingadb-url' => [
+ 'type' => 'menu-item',
+ 'target' => '_blank',
+ 'url' => 'icingadb/hosts?host.name~%2Afoo%2A'
+ ]
+ ];
+
+ $initialDashboardConfig = [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'icingadb' => [
+ 'title' => 'Icinga DB'
+ ],
+ 'icingadb.wildcards' => [
+ 'title' => 'Wildcards',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y&hostgroup.name=%2Alinux%2A'
+ ]
+ ];
+ $expectedDashboardConfig = [
+ 'hosts' => [
+ 'title' => 'Hosts'
+ ],
+ 'hosts.problems' => [
+ 'title' => 'Host Problems',
+ 'url' => 'monitoring/list/hosts?host_problem=1'
+ ],
+ 'icingadb' => [
+ 'title' => 'Icinga DB'
+ ],
+ 'icingadb.wildcards' => [
+ 'title' => 'Wildcards',
+ 'url' => 'icingadb/hosts?host.state.is_problem=y&hostgroup.name~%2Alinux%2A'
+ ]
+ ];
+
+ $initialRoleConfig = [
+ 'one' => [
+ 'groups' => 'support,helpdesk',
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'two' => [
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'three' => [
+ 'icingadb/filter/objects' => 'host.name=%2Afoo%2A'
+ ]
+ ];
+ $expectedRoleConfig = [
+ 'one' => [
+ 'groups' => 'support,helpdesk',
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'two' => [
+ 'monitoring/filter/objects' => 'host_name=foo|hostgroup_name=foo'
+ ],
+ 'three' => [
+ 'icingadb/filter/objects' => 'host.name~%2Afoo%2A'
+ ]
+ ];
+
+ $this->createConfig('preferences/test/host-actions.ini', $initialHostActionConfig);
+ $this->createConfig('preferences/test/service-actions.ini', $initialServiceActionConfig);
+ $this->createConfig('preferences/test/icingadb-host-actions.ini', $initialIcingadbHostActionConfig);
+ $this->createConfig('preferences/test/icingadb-service-actions.ini', $initialIcingadbServiceActionConfig);
+ $this->createConfig('dashboards/test/dashboard.ini', $initialDashboardConfig);
+ $this->createConfig('preferences/test/menu.ini', $initialMenuConfig);
+ $this->createConfig('roles.ini', $initialRoleConfig);
+
+ $command = $this->createCommandInstance('--no-backup');
+ $command->filterAction();
+
+ $hostActionConfig = $this->loadConfig('preferences/test/host-actions.ini');
+ $serviceActionConfig = $this->loadConfig('preferences/test/service-actions.ini');
+ $icingadbHostActionConfig = $this->loadConfig('preferences/test/icingadb-host-actions.ini');
+ $icingadbServiceActionConfig = $this->loadConfig('preferences/test/icingadb-service-actions.ini');
+ $dashboardBackup = $this->loadConfig('dashboards/test/dashboard.backup.ini');
+ $dashboardConfig = $this->loadConfig('dashboards/test/dashboard.ini');
+ $menuBackup = $this->loadConfig('preferences/test/menu.backup.ini');
+ $menuConfig = $this->loadConfig('preferences/test/menu.ini');
+ $roleBackup = $this->loadConfig('roles.backup.ini');
+ $roleConfig = $this->loadConfig('roles.ini');
+
+ $this->assertSame($expectedHostActionConfig, $hostActionConfig);
+ $this->assertSame($expectedServiceActionConfig, $serviceActionConfig);
+ $this->assertEmpty($dashboardBackup);
+ $this->assertEmpty($menuBackup);
+ $this->assertEmpty($roleBackup);
+
+ $this->assertSame($expectedIcingadbHostActionConfig, $icingadbHostActionConfig);
+ $this->assertSame($expectedIcingadbServiceActionConfig, $icingadbServiceActionConfig);
+ $this->assertSame($expectedDashboardConfig, $dashboardConfig);
+ $this->assertSame($expectedMenuConfig, $menuConfig);
+ $this->assertSame($expectedRoleConfig, $roleConfig);
+ }
+
+ public function testNavigationMigrationWorksEvenIfOnlySharedItemsExist()
+ {
+ $this->expectNotToPerformAssertions();
+
+ $this->createConfig('navigation/menu.ini', []);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+ }
+
+ public function testNavigationMigrationWorksEvenIfOnlyUserItemsExist()
+ {
+ $this->expectNotToPerformAssertions();
+
+ $this->createConfig('preferences/test/menu.ini', []);
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->navigationAction();
+ }
+
+ public function testDashboardMigrationWorksEvenIfNoDashboardsExist()
+ {
+ $this->expectNotToPerformAssertions();
+
+ $command = $this->createCommandInstance('--user', 'test');
+ $command->dashboardAction();
+ }
+}
diff --git a/test/php/library/Icingadb/Common/MacrosTest.php b/test/php/library/Icingadb/Common/MacrosTest.php
new file mode 100644
index 0000000..f998da8
--- /dev/null
+++ b/test/php/library/Icingadb/Common/MacrosTest.php
@@ -0,0 +1,174 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Modules\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Common\Macros;
+use Icinga\Module\Icingadb\Compat\CompatHost;
+use Icinga\Module\Icingadb\Compat\CompatService;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Orm\Query;
+use ipl\Orm\ResultSet;
+use PHPUnit\Framework\TestCase;
+
+class MacrosTest extends TestCase
+{
+ use Macros;
+
+ const VARS = [
+ 'os' => "Ubuntu",
+ 'days[0]' => 'mo',
+ 'days[1]' => 'tue',
+ 'days[2]' => 'wed',
+ 'days[3]' => 'thu',
+ 'days[4]' => 'fr'
+ ];
+
+ public function testHostMacros()
+ {
+ $host = new Host();
+ $host->name = 'test';
+ $host->address = '1.1.1.1';
+ $host->address6 = '::1';
+ $host->vars = self::VARS;
+
+ $host->hostgroup = new Query();
+
+ $this->performHostMacroTests($host, $host);
+ }
+
+ public function testHostMacrosOnCompatObject()
+ {
+ if (! class_exists('Icinga\Module\Monitoring\Object\Host')) {
+ $this->markTestSkipped('This test requires the monitoring module');
+ }
+
+ $host = new Host();
+ $host->name = 'test';
+ $host->address = '1.1.1.1';
+ $host->address6 = '::1';
+ $host->vars = self::VARS;
+
+ $host->hostgroup = new Query();
+
+ $compatHost = new CompatHost($host);
+
+ $this->performHostMacroTests($compatHost, $host);
+ }
+
+ protected function performHostMacroTests($host, $source)
+ {
+ $this->assertEquals($source->name, $this->expandMacros('$host.name$', $host));
+ $this->assertEquals($source->name, $this->expandMacros('$name$', $host));
+ $this->assertEquals($source->address, $this->expandMacros('$host.address$', $host));
+ $this->assertEquals($source->address6, $this->expandMacros('$host.address6$', $host));
+
+ // A Host can have more than one hostgroups
+ $this->assertEquals('$host.hostgroup$', $this->expandMacros('$host.hostgroup$', $host));
+ $this->assertEquals('$host.hostgroup.name$', $this->expandMacros('$host.hostgroup.name$', $host));
+
+ // Host custom vars
+ $this->assertEquals($source->vars['os'], $this->expandMacros('$host.vars.os$', $host));
+ $this->assertEquals($source->vars['os'], $this->expandMacros('$vars.os$', $host));
+ $this->assertEquals($source->vars['days[2]'], $this->expandMacros('$vars.days[2]$', $host));
+ $this->assertEquals($source->vars['days[4]'], $this->expandMacros('$host.vars.days[4]$', $host));
+
+ // Host to service relation
+ $this->assertEquals('$service.name$', $this->expandMacros('$service.name$', $host));
+ $this->assertEquals('$service.address$', $this->expandMacros('$service.address$', $host));
+
+ // Service custom vars
+ $this->assertEquals('$service.vars.os$', $this->expandMacros('$service.vars.os$', $host));
+ $this->assertEquals('$service.vars.days[0]$', $this->expandMacros('$service.vars.days[0]$', $host));
+ $this->assertEquals('$service.vars.days[2]$', $this->expandMacros('$service.vars.days[2]$', $host));
+ }
+
+ public function testServiceMacros()
+ {
+ $service = new Service();
+ $service->name = 'test-service';
+ $service->description = 'A test service';
+ $service->vars = self::VARS;
+
+ $service->servicegroup = new Query();
+
+ $host = new Host();
+ $host->name = 'test';
+ $host->address = '1.1.1.1';
+ $host->hostgroup = new ResultSet(new \ArrayIterator());
+ $host->vars = self::VARS;
+
+ $service->host = $host;
+
+ $this->performServiceMacroTests($service, $service);
+ }
+
+ public function testServiceMacrosOnCompatObject()
+ {
+ if (! class_exists('Icinga\Module\Monitoring\Object\Service')) {
+ $this->markTestSkipped('This test requires the monitoring module');
+ }
+
+ $service = new Service();
+ $service->name = 'test-service';
+ $service->description = 'A test service';
+ $service->vars = self::VARS;
+
+ $service->servicegroup = new Query();
+
+ $host = new Host();
+ $host->name = 'test';
+ $host->address = '1.1.1.1';
+ $host->hostgroup = new ResultSet(new \ArrayIterator());
+ $host->vars = self::VARS;
+
+ $service->host = $host;
+
+ $compatService = new CompatService($service);
+
+ $this->performServiceMacroTests($compatService, $service);
+ }
+
+ protected function performServiceMacroTests($service, $source)
+ {
+ $this->assertEquals($source->name, $this->expandMacros('$service.name$', $service));
+ $this->assertEquals($source->name, $this->expandMacros('$name$', $service));
+ $this->assertEquals($source->description, $this->expandMacros('$service.description$', $service));
+
+ // A Service can have more than one hostgroups
+ $this->assertEquals(
+ '$service.servicegroup$',
+ $this->expandMacros('$service.servicegroup$', $service)
+ );
+ $this->assertEquals(
+ '$service.servicegroup.name$',
+ $this->expandMacros('$service.servicegroup.name$', $service)
+ );
+
+ // Service custom vars
+ $this->assertEquals($source->vars['os'], $this->expandMacros('$service.vars.os$', $service));
+ $this->assertEquals($source->vars['os'], $this->expandMacros('$vars.os$', $service));
+ $this->assertEquals($source->vars['days[2]'], $this->expandMacros('$vars.days[2]$', $service));
+ $this->assertEquals($source->vars['days[4]'], $this->expandMacros('$service.vars.days[4]$', $service));
+
+ $this->assertEquals($source->host->name, $this->expandMacros('$host.name$', $service));
+ $this->assertEquals($source->host->address, $this->expandMacros('$host.address$', $service));
+
+ // Host custom vars
+ $this->assertEquals($source->host->vars['os'], $this->expandMacros('$host.vars.os$', $service));
+ $this->assertEquals($source->host->vars['days[0]'], $this->expandMacros('$host.vars.days[0]$', $service));
+ $this->assertEquals($source->host->vars['days[3]'], $this->expandMacros('$host.vars.days[3]$', $service));
+
+ // A Host can have more than one hostgroups
+ $this->assertEquals(
+ '$host.hostgroup$',
+ $this->expandMacros('$host.hostgroup$', $service)
+ );
+ $this->assertEquals(
+ '$host.hostgroup.name$',
+ $this->expandMacros('$host.hostgroup.name$', $service)
+ );
+ }
+}
diff --git a/test/php/library/Icingadb/Common/StateBadgesTest.php b/test/php/library/Icingadb/Common/StateBadgesTest.php
new file mode 100644
index 0000000..b535e65
--- /dev/null
+++ b/test/php/library/Icingadb/Common/StateBadgesTest.php
@@ -0,0 +1,86 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Modules\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Common\StateBadges;
+use Icinga\Web\UrlParams;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use PHPUnit\Framework\TestCase;
+
+class StateBadgesTest extends TestCase
+{
+ public function testCreateLinkRendersBaseFilterCorrectly()
+ {
+ $stateBadges = $this->createStateBadges()
+ ->setBaseFilter(Filter::any(
+ Filter::equal('foo', 'bar'),
+ Filter::equal('bar', 'foo')
+ ));
+
+ $link = $stateBadges->createLink('test', Filter::equal('rab', 'oof'));
+
+ $this->assertSame(
+ 'rab=oof&(foo=bar|bar=foo)',
+ $link->getUrl()->getQueryString()
+ );
+ }
+
+ private function createStateBadges()
+ {
+ $queryString = null;
+
+ $urlMock = $this->createConfiguredMock(Url::class, [
+ 'getBasePath' => 'test',
+ 'getParams' => $this->createConfiguredMock(UrlParams::class, [
+ 'toArray' => []
+ ])
+ ]);
+ $urlMock->method('setFilter')->willReturnCallback(
+ function ($qs) use ($urlMock, &$queryString) {
+ $queryString = QueryString::render($qs);
+
+ return $urlMock;
+ }
+ );
+ $urlMock->method('getQueryString')->willReturnCallback(
+ function () use (&$queryString) {
+ return $queryString;
+ }
+ );
+
+ return new class ($urlMock) extends StateBadges {
+ private $urlMock;
+
+ public function __construct($urlMock)
+ {
+ $this->urlMock = $urlMock;
+
+ parent::__construct((object) []);
+ }
+
+ protected function getBaseUrl(): Url
+ {
+ return $this->urlMock;
+ }
+
+ protected function getType(): string
+ {
+ return 'test';
+ }
+
+ protected function getPrefix(): string
+ {
+ return 'Test';
+ }
+
+ protected function getStateInt(string $state): int
+ {
+ return 0;
+ }
+ };
+ }
+}
diff --git a/test/php/library/Icingadb/Model/CustomvarFlatTest.php b/test/php/library/Icingadb/Model/CustomvarFlatTest.php
new file mode 100644
index 0000000..a69f578
--- /dev/null
+++ b/test/php/library/Icingadb/Model/CustomvarFlatTest.php
@@ -0,0 +1,121 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Modules\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use PHPUnit\Framework\TestCase;
+
+class CustomvarFlatTest extends TestCase
+{
+ const EMPTY_TEST_SOURCE = [
+ ["dict.not_empty.foo","bar","dict","{\"empty\":{},\"not_empty\":{\"foo\":\"bar\"}}"],
+ ["dict.empty",null,"dict","{\"empty\":{},\"not_empty\":{\"foo\":\"bar\"}}"],
+ ["list[1]",null,"list","[[\"foo\",\"bar\"],[]]"],
+ ["list[0][0]","foo","list","[[\"foo\",\"bar\"],[]]"],
+ ["list[0][1]","bar","list","[[\"foo\",\"bar\"],[]]"],
+ ["empty_list",null,"empty_list","[]"],
+ ["empty_dict",null,"empty_dict","{}"],
+ ["null","null","null","null"]
+ ];
+
+ const EMPTY_TEST_RESULT = [
+ "dict" => [
+ "not_empty" => [
+ "foo" => "bar"
+ ],
+ "empty" => []
+ ],
+ "list" => [
+ ["foo", "bar"],
+ []
+ ],
+ "empty_list" => [],
+ "empty_dict" => [],
+ "null" => "null"
+ ];
+
+ const SPECIAL_CHAR_TEST_SOURCE = [
+ [
+ "vhosts.xxxxxxxxxxxxx.mgmt.xxxxxx.com.http_port",
+ "443",
+ "vhosts",
+ "{\"xxxxxxxxxxxxx.mgmt.xxxxxx.com\":{\"http_port\":\"443\"}}"
+ ],
+ ["ex.ample.com.bla","blub","ex","{\"ample.com\":{\"bla\":\"blub\"}}"],
+ ["example[1]","zyx","example[1]","\"zyx\""],
+ ["example.0.org","xyz","example.0.org","\"xyz\""],
+ ["ob.je.ct","***","ob","{\"je\":{\"ct\":\"tcejbo\"}}"],
+ ["real_list[2]","three","real_list","[\"one\",\"two\",\"three\"]"],
+ ["real_list[1]","two","real_list","[\"one\",\"two\",\"three\"]"],
+ ["real_list[0]","one","real_list","[\"one\",\"two\",\"three\"]"],
+ ["[1].2.[3].4.[5].6","123456","[1].2","{\"[3].4\":{\"[5].6\":123456}}"],
+ ["ex.ample.com","cba","ex.ample.com","\"cba\""],
+ ["[4]","four","[4]","\"four\""]
+ ];
+
+ const SPECIAL_CHAR_TEST_RESULT = [
+ "vhosts" => [
+ "xxxxxxxxxxxxx.mgmt.xxxxxx.com" => [
+ "http_port" => 443
+ ]
+ ],
+ "ex" => [
+ "ample.com" => [
+ "bla" => "blub"
+ ]
+ ],
+ "example[1]" => "zyx",
+ "example.0.org" => "xyz",
+ "ob" => [
+ "je" => [
+ "ct" => "***"
+ ]
+ ],
+ "real_list" => [
+ "one",
+ "two",
+ "three"
+ ],
+ "[1].2" => [
+ "[3].4" => [
+ "[5].6" => "123456"
+ ]
+ ],
+ "ex.ample.com" => "cba",
+ "[4]" => "four"
+ ];
+
+ public function testUnflatteningOfEmptyCustomVariables()
+ {
+ $this->assertEquals(
+ self::EMPTY_TEST_RESULT,
+ (new CustomvarFlat())->unFlattenVars($this->transformSource(self::EMPTY_TEST_SOURCE)),
+ "Empty custom variables are not correctly unflattened"
+ );
+ }
+
+ public function testUnflatteningOfCustomVariablesWithSpecialCharacters()
+ {
+ $this->assertEquals(
+ self::SPECIAL_CHAR_TEST_RESULT,
+ (new CustomvarFlat())->unFlattenVars($this->transformSource(self::SPECIAL_CHAR_TEST_SOURCE)),
+ "Custom variables with special characters are not correctly unflattened"
+ );
+ }
+
+ protected function transformSource(array $source): \Generator
+ {
+ foreach ($source as $data) {
+ yield (object) [
+ 'flatname' => $data[0],
+ 'flatvalue' => $data[1],
+ 'customvar' => (object) [
+ 'name' => $data[2],
+ 'value' => $data[3]
+ ]
+ ];
+ }
+ }
+}
diff --git a/test/php/library/Icingadb/Util/PerfdataSetTest.php b/test/php/library/Icingadb/Util/PerfdataSetTest.php
new file mode 100644
index 0000000..618c29a
--- /dev/null
+++ b/test/php/library/Icingadb/Util/PerfdataSetTest.php
@@ -0,0 +1,120 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Module\Icingadb\Util;
+
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use PHPUnit\Framework\TestCase;
+use Tests\Icinga\Module\Icingadb\Lib\PerfdataSetWithPublicData;
+
+class PerfdataSetTest extends TestCase
+{
+ public function testWhetherValidSimplePerfdataLabelsAreProperlyParsed()
+ {
+ $pset = PerfdataSetWithPublicData::fromString('key1=val1 key2=val2 key3 =val3');
+ $this->assertSame(
+ 'key1',
+ $pset->perfdata[0]->getLabel(),
+ 'PerfdataSet does not correctly parse valid simple labels'
+ );
+ $this->assertSame(
+ 'key2',
+ $pset->perfdata[1]->getLabel(),
+ 'PerfdataSet does not correctly parse valid simple labels'
+ );
+ $this->assertSame(
+ 'key3',
+ $pset->perfdata[2]->getLabel(),
+ 'PerfdataSet does not correctly parse valid simple labels'
+ );
+ }
+
+ public function testWhetherNonQuotedPerfdataLablesWithSpacesAreProperlyParsed()
+ {
+ $pset = PerfdataSetWithPublicData::fromString('key 1=val1 key 1 + 1=val2');
+ $this->assertSame(
+ 'key 1',
+ $pset->perfdata[0]->getLabel(),
+ 'PerfdataSet does not correctly parse non quoted labels with spaces'
+ );
+ $this->assertSame(
+ 'key 1 + 1',
+ $pset->perfdata[1]->getLabel(),
+ 'PerfdataSet does not correctly parse non quoted labels with spaces'
+ );
+ }
+
+ public function testWhetherValidQuotedPerfdataLabelsAreProperlyParsed()
+ {
+ $pset = PerfdataSetWithPublicData::fromString('\'key 1\'=val1 "key 2"=val2 \'a=b\'=0%;;2');
+ $this->assertSame(
+ 'key 1',
+ $pset->perfdata[0]->getLabel(),
+ 'PerfdataSet does not correctly parse valid quoted labels'
+ );
+ $this->assertSame(
+ 'key 2',
+ $pset->perfdata[1]->getLabel(),
+ 'PerfdataSet does not correctly parse valid quoted labels'
+ );
+ $this->assertSame(
+ 'a=b',
+ $pset->perfdata[2]->getLabel(),
+ 'PerfdataSet does not correctly parse labels with equal signs'
+ );
+ }
+
+ public function testWhetherInvalidQuotedPerfdataLabelsAreProperlyParsed()
+ {
+ $pset = PerfdataSetWithPublicData::fromString('\'key 1=1 key 2"=2');
+ $this->assertSame(
+ 'key 1',
+ $pset->perfdata[0]->getLabel(),
+ 'PerfdataSet does not correctly parse invalid quoted labels'
+ );
+ $this->assertSame(
+ 'key 2"',
+ $pset->perfdata[1]->getLabel(),
+ 'PerfdataSet does not correctly parse invalid quoted labels'
+ );
+ $pset = PerfdataSetWithPublicData::fromString('"key 1=1 "key 2"=2');
+ $this->assertSame(
+ 'key 1=1',
+ $pset->perfdata[0]->getLabel(),
+ 'PerfdataSet does not correctly parse invalid quoted labels'
+ );
+ $this->assertNull(
+ $pset->perfdata[0]->getValue()
+ );
+ $this->assertSame(
+ '2"',
+ $pset->perfdata[1]->getLabel(),
+ 'PerfdataSet does not correctly parse invalid quoted labels'
+ );
+ $this->assertSame(
+ 2.0,
+ $pset->perfdata[1]->getValue()
+ );
+ }
+
+ /**
+ * @depends testWhetherValidSimplePerfdataLabelsAreProperlyParsed
+ */
+ public function testWhetherAPerfdataSetIsIterable()
+ {
+ $pset = PerfdataSet::fromString('key=value');
+ foreach ($pset as $p) {
+ $this->assertSame('key', $p->getLabel());
+ return;
+ }
+
+ $this->fail('PerfdataSet objects cannot be iterated');
+ }
+
+ public function testWhetherPerfdataSetsCanBeInitializedWithEmptyStrings()
+ {
+ $pset = PerfdataSetWithPublicData::fromString('');
+ $this->assertEmpty($pset->perfdata, 'PerfdataSet::fromString does not accept emtpy strings');
+ }
+}
diff --git a/test/php/library/Icingadb/Util/PerfdataTest.php b/test/php/library/Icingadb/Util/PerfdataTest.php
new file mode 100644
index 0000000..5a63825
--- /dev/null
+++ b/test/php/library/Icingadb/Util/PerfdataTest.php
@@ -0,0 +1,591 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Module\Icingadb\Util;
+
+use Icinga\Module\Icingadb\Util\PerfData;
+use PHPUnit\Framework\TestCase;
+
+class PerfdataTest extends TestCase
+{
+ public function testWhetherFromStringThrowsExceptionWhenGivenAnEmptyString()
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ Perfdata::fromString('');
+ }
+
+ public function testWhetherFromStringThrowsExceptionWhenGivenAnInvalidString()
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ Perfdata::fromString('test');
+ }
+
+ public function testWhetherFromStringParsesAGivenStringCorrectly()
+ {
+ $p = Perfdata::fromString('key=1234');
+ $this->assertSame(
+ 'key',
+ $p->getLabel(),
+ 'Perfdata::fromString does not properly parse performance data labels'
+ );
+ $this->assertSame(
+ 1234.0,
+ $p->getValue(),
+ 'Perfdata::fromString does not properly parse performance data values'
+ );
+ }
+
+ /**
+ * @depends testWhetherFromStringParsesAGivenStringCorrectly
+ */
+ public function testWhetherGetValueReturnsValidValues()
+ {
+ $this->assertSame(
+ 1337.0,
+ Perfdata::fromString('test=1337')->getValue(),
+ 'Perfdata::getValue does not return correct values'
+ );
+ $this->assertSame(
+ 1337.0,
+ Perfdata::fromString('test=1337;;;;')->getValue(),
+ 'Perfdata::getValue does not return correct values'
+ );
+ }
+
+ /**
+ * @depends testWhetherFromStringParsesAGivenStringCorrectly
+ */
+ public function testWhetherDecimalValuesAreCorrectlyParsed()
+ {
+ $this->assertSame(
+ 1337.5,
+ Perfdata::fromString('test=1337.5')->getValue(),
+ 'Perfdata objects do not parse decimal values correctly'
+ );
+ $this->assertSame(
+ 1337.5,
+ Perfdata::fromString('test=1337.5B')->getValue(),
+ 'Perfdata objects do not parse decimal values correctly'
+ );
+ }
+
+ /**
+ * @depends testWhetherFromStringParsesAGivenStringCorrectly
+ */
+ public function testWhetherGetValueReturnsNullForInvalidOrUnknownValues()
+ {
+ $this->assertNull(
+ Perfdata::fromString('test=U')->getValue(),
+ 'Perfdata::getValue does not return null for unknown values'
+ );
+ $this->assertNull(
+ Perfdata::fromString('test=i am not a value')->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertNull(
+ PerfData::fromString('test=')->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertNull(
+ PerfData::fromString('test=-kW')->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertNull(
+ PerfData::fromString('test=kW')->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertNull(
+ PerfData::fromString('test=-')->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ }
+
+ /**
+ * @depends testWhetherFromStringParsesAGivenStringCorrectly
+ */
+ public function testWhetherUnitOfUnkownValuesIsCorrectlyIdentified()
+ {
+ $this->assertNull(
+ Perfdata::fromString('test=U')->getUnit(),
+ 'Perfdata::getUnit does not return null for unknown values'
+ );
+ $this->assertNull(
+ Perfdata::fromString('test=i am not a value')->getUnit(),
+ 'Perfdata::getUnit does not return null for unknown values'
+ );
+ $this->assertNull(
+ PerfData::fromString('test=')->getUnit(),
+ 'Perfdata::getUnit does not return null for unknown values'
+ );
+ $this->assertSame(
+ 'kW',
+ PerfData::fromString('test=-kW')->getUnit(),
+ 'Perfdata::getUnit does not return correct unit for invalid values'
+ );
+ $this->assertSame(
+ 'kW',
+ PerfData::fromString('test=kW')->getUnit(),
+ 'Perfdata::getUnit does not return correct unit for invalid values'
+ );
+ $this->assertNull(
+ PerfData::fromString('test=-')->getUnit(),
+ 'Perfdata::getUnit does not return null for unknown values'
+ );
+ }
+
+ /**
+ * @depends testWhetherFromStringParsesAGivenStringCorrectly
+ */
+ public function testWhethergetWarningThresholdReturnsCorrectValues()
+ {
+ $zeroToTen = Perfdata::fromString('test=1;10')->getWarningThreshold();
+ $this->assertSame(
+ 0.0,
+ $zeroToTen->getMin(),
+ 'Perfdata::getWarningThreshold does not return correct values'
+ );
+ $this->assertSame(
+ 10.0,
+ $zeroToTen->getMax(),
+ 'Perfdata::getWarningThreshold does not return correct values'
+ );
+ $tenToInfinity = Perfdata::fromString('test=1;10:')->getWarningThreshold();
+ $this->assertSame(
+ 10.0,
+ $tenToInfinity->getMin(),
+ 'Perfdata::getWarningThreshold does not return correct values'
+ );
+ $this->assertNull(
+ $tenToInfinity->getMax(),
+ 'Perfdata::getWarningThreshold does not return correct values'
+ );
+ $infinityToTen = Perfdata::fromString('test=1;~:10')->getWarningThreshold();
+ $this->assertNull(
+ $infinityToTen->getMin(),
+ 'Perfdata::getWarningThreshold does not return correct values'
+ );
+ $this->assertSame(
+ 10.0,
+ $infinityToTen->getMax(),
+ 'Perfdata::getWarningThreshold does not return correct values'
+ );
+ $tenToTwenty = Perfdata::fromString('test=1;10:20')->getWarningThreshold();
+ $this->assertSame(
+ 10.0,
+ $tenToTwenty->getMin(),
+ 'Perfdata::getWarningThreshold does not return correct values'
+ );
+ $this->assertSame(
+ 20.0,
+ $tenToTwenty->getMax(),
+ 'Perfdata::getWarningThreshold does not return correct values'
+ );
+ $tenToTwentyInverted = Perfdata::fromString('test=1;@10:20')->getWarningThreshold();
+ $this->assertTrue(
+ $tenToTwentyInverted->isInverted(),
+ 'Perfdata::getWarningThreshold does not return correct values'
+ );
+ }
+
+ /**
+ * @depends testWhetherFromStringParsesAGivenStringCorrectly
+ */
+ public function testWhetherGetCriticalThresholdReturnsCorrectValues()
+ {
+ $zeroToTen = Perfdata::fromString('test=1;;10')->getCriticalThreshold();
+ $this->assertSame(
+ 0.0,
+ $zeroToTen->getMin(),
+ 'Perfdata::getCriticalThreshold does not return correct values'
+ );
+ $this->assertSame(
+ 10.0,
+ $zeroToTen->getMax(),
+ 'Perfdata::getCriticalThreshold does not return correct values'
+ );
+ $tenToInfinity = Perfdata::fromString('test=1;;10:')->getCriticalThreshold();
+ $this->assertSame(
+ 10.0,
+ $tenToInfinity->getMin(),
+ 'Perfdata::getCriticalThreshold does not return correct values'
+ );
+ $this->assertNull(
+ $tenToInfinity->getMax(),
+ 'Perfdata::getCriticalThreshold does not return correct values'
+ );
+ $infinityToTen = Perfdata::fromString('test=1;;~:10')->getCriticalThreshold();
+ $this->assertNull(
+ $infinityToTen->getMin(),
+ 'Perfdata::getCriticalThreshold does not return correct values'
+ );
+ $this->assertSame(
+ 10.0,
+ $infinityToTen->getMax(),
+ 'Perfdata::getCriticalThreshold does not return correct values'
+ );
+ $tenToTwenty = Perfdata::fromString('test=1;;10:20')->getCriticalThreshold();
+ $this->assertSame(
+ 10.0,
+ $tenToTwenty->getMin(),
+ 'Perfdata::getCriticalThreshold does not return correct values'
+ );
+ $this->assertSame(
+ 20.0,
+ $tenToTwenty->getMax(),
+ 'Perfdata::getCriticalThreshold does not return correct values'
+ );
+ $tenToTwentyInverted = Perfdata::fromString('test=1;;@10:20')->getCriticalThreshold();
+ $this->assertTrue(
+ $tenToTwentyInverted->isInverted(),
+ 'Perfdata::getCriticalThreshold does not return correct values'
+ );
+ }
+
+ /**
+ * @depends testWhetherFromStringParsesAGivenStringCorrectly
+ */
+ public function testWhetherGetMinimumValueReturnsCorrectValues()
+ {
+ $this->assertSame(
+ 1337.0,
+ Perfdata::fromString('test=1;;;1337')->getMinimumValue(),
+ 'Perfdata::getMinimumValue does not return correct values'
+ );
+ $this->assertSame(
+ 1337.5,
+ Perfdata::fromString('test=1;;;1337.5')->getMinimumValue(),
+ 'Perfdata::getMinimumValue does not return correct values'
+ );
+ }
+
+ /**
+ * @depends testWhetherFromStringParsesAGivenStringCorrectly
+ */
+ public function testWhetherGetMaximumValueReturnsCorrectValues()
+ {
+ $this->assertSame(
+ 1337.0,
+ Perfdata::fromString('test=1;;;;1337')->getMaximumValue(),
+ 'Perfdata::getMaximumValue does not return correct values'
+ );
+ $this->assertSame(
+ 1337.5,
+ Perfdata::fromString('test=1;;;;1337.5')->getMaximumValue(),
+ 'Perfdata::getMaximumValue does not return correct values'
+ );
+ }
+
+ /**
+ * @depends testWhetherFromStringParsesAGivenStringCorrectly
+ */
+ public function testWhetherMissingValuesAreProperlyHandled()
+ {
+ $perfdata = Perfdata::fromString('test=1;;3;5');
+ $this->assertEmpty(
+ (string) $perfdata->getWarningThreshold(),
+ 'Perfdata objects do not correctly identify omitted warning tresholds'
+ );
+ $this->assertNull(
+ $perfdata->getMaximumValue(),
+ 'Perfdata objects do not return null for missing maximum values'
+ );
+ }
+
+ /**
+ * @depends testWhetherGetValueReturnsValidValues
+ */
+ public function testWhetherValuesAreIdentifiedAsNumber()
+ {
+ $this->assertTrue(
+ Perfdata::fromString('test=666')->isNumber(),
+ 'Perfdata objects do not identify ordinary digits as number'
+ );
+ }
+
+ /**
+ * @depends testWhetherGetValueReturnsValidValues
+ */
+ public function testWhetherValuesAreIdentifiedAsSeconds()
+ {
+ $this->assertTrue(
+ Perfdata::fromString('test=666s')->isSeconds(),
+ 'Perfdata objects do not identify seconds as seconds'
+ );
+ }
+
+ /**
+ * @depends testWhetherGetValueReturnsValidValues
+ */
+ public function testWhetherValuesAreIdentifiedAsPercentage()
+ {
+ $this->assertTrue(
+ Perfdata::fromString('test=66%')->isPercentage(),
+ 'Perfdata objects do not identify percentages as percentages'
+ );
+ }
+
+ /**
+ * @depends testWhetherValuesAreIdentifiedAsPercentage
+ */
+ public function testWhetherMinAndMaxAreNotRequiredIfUnitIsInPercent()
+ {
+ $perfdata = Perfdata::fromString('test=1%');
+ $this->assertSame(
+ 0.0,
+ $perfdata->getMinimumValue(),
+ 'Perfdata objects do not set minimum value to 0 if UOM is %'
+ );
+ $this->assertSame(
+ 100.0,
+ $perfdata->getMaximumValue(),
+ 'Perfdata objects do not set maximum value to 100 if UOM is %'
+ );
+ }
+
+ /**
+ * @depends testWhetherGetValueReturnsValidValues
+ */
+ public function testWhetherValuesAreIdentifiedAsBytes()
+ {
+ $this->assertTrue(
+ Perfdata::fromString('test=66666B')->isBytes(),
+ 'Perfdata objects do not identify bytes as bytes'
+ );
+ }
+
+ /**
+ * @depends testWhetherGetValueReturnsValidValues
+ */
+ public function testWhetherValuesAreIdentifiedAsCounter()
+ {
+ $this->assertTrue(
+ Perfdata::fromString('test=123c')->isCounter(),
+ 'Perfdata objects do not identify counters as counters'
+ );
+ }
+
+ /**
+ * @depends testWhetherValuesAreIdentifiedAsPercentage
+ */
+ public function testWhetherPercentagesAreHandledCorrectly()
+ {
+ $this->assertSame(
+ 66.0,
+ Perfdata::fromString('test=66%')->getPercentage(),
+ 'Perfdata objects do not correctly handle native percentages'
+ );
+ $this->assertSame(
+ 50.0,
+ Perfdata::fromString('test=0;;;-250;250')->getPercentage(),
+ 'Perfdata objects do not correctly convert suitable values to percentages'
+ );
+ $this->assertNull(
+ Perfdata::fromString('test=50')->getPercentage(),
+ 'Perfdata objects do return a percentage though their unit is not % and no maximum is given'
+ );
+ $this->assertNull(
+ Perfdata::fromString('test=25;;;50;100')->getPercentage(),
+ 'Perfdata objects do return a percentage though their value is lower than it\'s allowed minimum'
+ );
+ $this->assertNull(
+ Perfdata::fromString('test=25;;;0;')->getPercentage(),
+ 'Perfdata objects do not ignore empty max values when returning percentages'
+ );
+ $this->assertNull(
+ Perfdata::fromString('test=25;;;0;0')->getPercentage(),
+ 'Perfdata objects do not ignore impossible min/max combinations when returning percentages'
+ );
+ }
+
+ public function testWhetherInvalidValueInPerfDataHandledCorrectly()
+ {
+ $p1 = Perfdata::fromString('test=2,0');
+ $this->assertFalse($p1->isValid());
+ $this->assertNull(
+ $p1->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertSame(
+ '2,0',
+ $p1->toArray()['value']
+ );
+
+ $p2 = Perfdata::fromString('test=i am not a value');
+ $this->assertFalse($p2->isValid());
+ $this->assertNull(
+ $p2->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertSame(
+ 'i am not a value',
+ $p2->toArray()['value']
+ );
+
+ $p3 = Perfdata::fromString('test=');
+ $this->assertFalse($p3->isValid());
+ $this->assertNull(
+ $p3->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertSame(
+ '',
+ $p3->toArray()['value']
+ );
+
+ $p4 = Perfdata::fromString('test=-kW');
+ $this->assertFalse($p4->isValid());
+ $this->assertNull(
+ $p4->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertSame(
+ '-kW',
+ $p4->toArray()['value']
+ );
+
+ $p5 = Perfdata::fromString('test=kW');
+ $this->assertFalse($p5->isValid());
+ $this->assertNull(
+ $p5->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertSame(
+ 'kW',
+ $p5->toArray()['value']
+ );
+
+ $p6 = Perfdata::fromString('test=-');
+ $this->assertFalse($p6->isValid());
+ $this->assertNull(
+ $p6->getValue(),
+ 'Perfdata::getValue does not return null for invalid values'
+ );
+ $this->assertSame(
+ '-',
+ $p6->toArray()['value']
+ );
+ }
+
+ public function testWhetherInvalidMinInPerfDataHandledCorrectly()
+ {
+ $p1 = Perfdata::fromString('test=1;;;2,0');
+ $this->assertFalse($p1->isValid());
+ $this->assertNull(
+ $p1->getMinimumValue(),
+ 'Perfdata::getMinimumValue does not return null for invalid min values'
+ );
+ $this->assertSame(
+ '2,0',
+ $p1->toArray()['min']
+ );
+
+ $p2 = Perfdata::fromString('test=1;;;foo');
+ $this->assertFalse($p2->isValid());
+ $this->assertNull(
+ $p2->getMinimumValue(),
+ 'Perfdata::getMinimumValue does not return null for invalid min values'
+ );
+ $this->assertSame(
+ 'foo',
+ $p2->toArray()['min']
+ );
+ }
+
+ public function testWhetherInvalidMaxInPerfDataHandledCorrectly()
+ {
+ $p1 = Perfdata::fromString('test=1;;;;2,0');
+ $this->assertFalse($p1->isValid());
+ $this->assertNull(
+ $p1->getMaximumValue(),
+ 'Perfdata::getMaximumValue does not return null for invalid max values'
+ );
+ $this->assertSame(
+ '2,0',
+ $p1->toArray()['max']
+ );
+
+ $p2 = Perfdata::fromString('test=1;;;;foo');
+ $this->assertFalse($p2->isValid());
+ $this->assertNull(
+ $p2->getMaximumValue(),
+ 'Perfdata::getMaximumValue does not return null for invalid max values'
+ );
+ $this->assertSame(
+ 'foo',
+ $p2->toArray()['max']
+ );
+ }
+
+ public function testWhetherInvalidWarningThresholdInPerfDataHandledCorrectly()
+ {
+ $p1 = Perfdata::fromString('test=1;2,0:');
+ $this->assertFalse($p1->getWarningThreshold()->isValid());
+ $this->assertFalse($p1->isValid());
+ $this->assertSame(
+ '2,0:',
+ (string) $p1->getWarningThreshold()
+ );
+
+ $p2 = Perfdata::fromString('test=1;0:4,0');
+ $this->assertFalse($p2->getWarningThreshold()->isValid());
+ $this->assertFalse($p2->isValid());
+ $this->assertSame(
+ '0:4,0',
+ (string) $p2->getWarningThreshold()
+ );
+
+ $p3 = Perfdata::fromString('test=1;foo');
+ $this->assertFalse($p2->getWarningThreshold()->isValid());
+ $this->assertFalse($p3->isValid());
+ $this->assertSame(
+ 'foo',
+ (string) $p3->getWarningThreshold()
+ );
+
+ $p4 = Perfdata::fromString('test=1;10@');
+ $this->assertFalse($p2->getWarningThreshold()->isValid());
+ $this->assertFalse($p4->isValid());
+ $this->assertSame(
+ '10@',
+ (string) $p4->getWarningThreshold()
+ );
+ }
+
+ public function testWhetherInvalidCriticalThresholdInPerfDataHandledCorrectly()
+ {
+ $p1 = Perfdata::fromString('test=1;;2,0:');
+ $this->assertFalse($p1->getCriticalThreshold()->isValid());
+ $this->assertFalse($p1->isValid());
+ $this->assertSame(
+ '2,0:',
+ (string) $p1->getCriticalThreshold()
+ );
+
+ $p2 = Perfdata::fromString('test=1;;0:4,0');
+ $this->assertFalse($p2->getCriticalThreshold()->isValid());
+ $this->assertFalse($p2->isValid());
+ $this->assertSame(
+ '0:4,0',
+ (string) $p2->getCriticalThreshold()
+ );
+
+ $p3 = Perfdata::fromString('test=1;;foo');
+ $this->assertFalse($p2->getCriticalThreshold()->isValid());
+ $this->assertFalse($p3->isValid());
+ $this->assertSame(
+ 'foo',
+ (string) $p3->getCriticalThreshold()
+ );
+
+ $p4 = Perfdata::fromString('test=1;;10@');
+ $this->assertFalse($p2->getCriticalThreshold()->isValid());
+ $this->assertFalse($p4->isValid());
+ $this->assertSame(
+ '10@',
+ (string) $p4->getCriticalThreshold()
+ );
+ }
+}
diff --git a/test/php/library/Icingadb/Util/ThresholdRangeTest.php b/test/php/library/Icingadb/Util/ThresholdRangeTest.php
new file mode 100644
index 0000000..b191e88
--- /dev/null
+++ b/test/php/library/Icingadb/Util/ThresholdRangeTest.php
@@ -0,0 +1,343 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Tests\Icinga\Module\Icingadb\Util;
+
+use Icinga\Module\Icingadb\Util\ThresholdRange;
+use PHPUnit\Framework\TestCase;
+
+class ThresholdRangeTest extends TestCase
+{
+ public function testFromStringProperlyParsesDoubleExclusiveRanges()
+ {
+ $outside0And10 = ThresholdRange::fromString('10');
+ $this->assertSame(
+ 0.0,
+ $outside0And10->getMin(),
+ 'ThresholdRange::fromString() does not identify zero as default minimum for double exclusive ranges'
+ );
+ $this->assertSame(
+ 10.0,
+ $outside0And10->getMax(),
+ 'ThresholdRange::fromString() does not identify ten as explicit maximum for double exclusive ranges'
+ );
+ $this->assertFalse(
+ $outside0And10->isInverted(),
+ 'ThresholdRange::fromString() identifies double exclusive ranges as inclusive'
+ );
+
+ $outside10And20 = ThresholdRange::fromString('10:20');
+ $this->assertSame(
+ 10.0,
+ $outside10And20->getMin(),
+ 'ThresholdRange::fromString() does not identify ten as explicit minimum for double exclusive ranges'
+ );
+ $this->assertSame(
+ 20.0,
+ $outside10And20->getMax(),
+ 'ThresholdRange::fromString() does not identify twenty as explicit maximum for double exclusive ranges'
+ );
+ $this->assertFalse(
+ $outside10And20->isInverted(),
+ 'ThresholdRange::fromString() identifies double exclusive ranges as inclusive'
+ );
+ }
+
+ /**
+ * @depends testFromStringProperlyParsesDoubleExclusiveRanges
+ */
+ public function testContainsCorrectlyEvaluatesDoubleExclusiveRanges()
+ {
+ $outside0And10 = ThresholdRange::fromString('10');
+ $this->assertFalse(
+ $outside0And10->contains(-1),
+ 'ThresholdRange::contains() identifies negative values as greater than or equal to zero'
+ );
+ $this->assertFalse(
+ $outside0And10->contains(11),
+ 'ThresholdRange::contains() identifies eleven as smaller than or equal to ten'
+ );
+ $this->assertTrue(
+ $outside0And10->contains(10),
+ 'ThresholdRange::contains() identifies 10 as outside the range 0..10'
+ );
+
+ $outside10And20 = ThresholdRange::fromString('10:20');
+ $this->assertFalse(
+ $outside10And20->contains(9),
+ 'ThresholdRange::contains() identifies nine as greater than or equal to 10'
+ );
+ $this->assertFalse(
+ $outside10And20->contains(21),
+ 'ThresholdRange::contains() identifies twenty-one as smaller than or equal to twenty'
+ );
+ $this->assertTrue(
+ $outside10And20->contains(20),
+ 'ThresholdRange::contains() identifies 20 as outside the range 10..20'
+ );
+ }
+
+ public function testFromStringProperlyParsesSingleExclusiveRanges()
+ {
+ $smallerThan10 = ThresholdRange::fromString('10:');
+ $this->assertSame(
+ 10.0,
+ $smallerThan10->getMin(),
+ 'ThresholdRange::fromString() does not identify ten as explicit minimum for single exclusive ranges'
+ );
+ $this->assertNull(
+ $smallerThan10->getMax(),
+ 'ThresholdRange::fromString() does not identify infinity as default maximum for single exclusive ranges'
+ );
+ $this->assertFalse(
+ $smallerThan10->isInverted(),
+ 'ThresholdRange::fromString() identifies single exclusive ranges as inclusive'
+ );
+
+ $greaterThan10 = ThresholdRange::fromString('~:10');
+ $this->assertNull(
+ $greaterThan10->getMin(),
+ 'ThresholdRange::fromString() does not identify infinity as explicit minimum for single exclusive ranges'
+ );
+ $this->assertSame(
+ 10.0,
+ $greaterThan10->getMax(),
+ 'ThresholdRange::fromString() does not identify ten as explicit maximum for single exclusive ranges'
+ );
+ $this->assertFalse(
+ $greaterThan10->isInverted(),
+ 'ThresholdRange::fromString() identifies single exclusive ranges as inclusive'
+ );
+ }
+
+ /**
+ * @depends testFromStringProperlyParsesSingleExclusiveRanges
+ */
+ public function testContainsCorrectlyEvaluatesSingleExclusiveRanges()
+ {
+ $smallerThan10 = ThresholdRange::fromString('10:');
+ $this->assertFalse(
+ $smallerThan10->contains(9),
+ 'ThresholdRange::contains() identifies nine as greater than or equal to ten'
+ );
+ $this->assertTrue(
+ $smallerThan10->contains(PHP_INT_MAX),
+ 'ThresholdRange::contains() identifies infinity as outside the range 10..~'
+ );
+
+ $greaterThan10 = ThresholdRange::fromString('~:10');
+ $this->assertFalse(
+ $greaterThan10->contains(11),
+ 'ThresholdRange::contains() identifies eleven as smaller than or equal to ten'
+ );
+ $this->assertTrue(
+ $greaterThan10->contains(~PHP_INT_MAX),
+ 'ThresholdRange::contains() identifies negative infinity as outside the range ~..10'
+ );
+ }
+
+ public function testFromStringProperlyParsesInclusiveRanges()
+ {
+ $inside0And10 = ThresholdRange::fromString('@10');
+ $this->assertSame(
+ 0.0,
+ $inside0And10->getMin(),
+ 'ThresholdRange::fromString() does not identify zero as default minimum for inclusive ranges'
+ );
+ $this->assertSame(
+ 10.0,
+ $inside0And10->getMax(),
+ 'ThresholdRange::fromString() does not identify ten as explicit maximum for inclusive ranges'
+ );
+ $this->assertTrue(
+ $inside0And10->isInverted(),
+ 'ThresholdRange::fromString() identifies inclusive ranges as double exclusive'
+ );
+
+ $inside10And20 = ThresholdRange::fromString('@10:20');
+ $this->assertSame(
+ 10.0,
+ $inside10And20->getMin(),
+ 'ThresholdRange::fromString() does not identify ten as explicit minimum for inclusive ranges'
+ );
+ $this->assertSame(
+ 20.0,
+ $inside10And20->getMax(),
+ 'ThresholdRange::fromString() does not identify twenty as explicit maximum for inclusive ranges'
+ );
+ $this->assertTrue(
+ $inside10And20->isInverted(),
+ 'ThresholdRange::fromString() identifies inclusive ranges as double exclusive'
+ );
+
+ $greaterThan10 = ThresholdRange::fromString('@10:');
+ $this->assertSame(
+ 10.0,
+ $greaterThan10->getMin(),
+ 'ThresholdRange::fromString() does not identify ten as explicit minimum for inclusive ranges'
+ );
+ $this->assertNull(
+ $greaterThan10->getMax(),
+ 'ThresholdRange::fromString() does not identify infinity as default maximum for inclusive ranges'
+ );
+ $this->assertTrue(
+ $greaterThan10->isInverted(),
+ 'ThresholdRange::fromString() identifies inclusive ranges as single exclusive'
+ );
+
+ $smallerThan10 = ThresholdRange::fromString('@~:10');
+ $this->assertNull(
+ $smallerThan10->getMin(),
+ 'ThresholdRange::fromString() does not identify infinity as explicit minimum for inclusive ranges'
+ );
+ $this->assertSame(
+ 10.0,
+ $smallerThan10->getMax(),
+ 'ThresholdRange::fromString() does not identify ten as explicit maximum for inclusive ranges'
+ );
+ $this->assertTrue(
+ $smallerThan10->isInverted(),
+ 'ThresholdRange::fromString() identifies inclusive ranges as single exclusive'
+ );
+ }
+
+ /**
+ * @depends testFromStringProperlyParsesInclusiveRanges
+ */
+ public function testContainsCorrectlyEvaluatesInclusiveRanges()
+ {
+ $inside0And10 = ThresholdRange::fromString('@10');
+ $this->assertFalse(
+ $inside0And10->contains(10),
+ 'ThresholdRange::contains() identifies ten as greater than ten'
+ );
+ $this->assertTrue(
+ $inside0And10->contains(11),
+ 'ThresholdRange::contains() identifies eleven as smaller than or equal to ten'
+ );
+ $this->assertTrue(
+ $inside0And10->contains(-1),
+ 'ThresholdRange::contains() identifies negative values as greater than or equal to zero'
+ );
+
+ $inside10And20 = ThresholdRange::fromString('@10:20');
+ $this->assertFalse(
+ $inside10And20->contains(20),
+ 'ThresholdRange::contains() identifies twenty as greater than twenty'
+ );
+ $this->assertTrue(
+ $inside10And20->contains(21),
+ 'ThresholdRange::contains() identifies twenty-one as smaller than or equal to twenty'
+ );
+ $this->assertTrue(
+ $inside10And20->contains(9),
+ 'ThresholdRange::contains() identifies nine as greater than or equal to ten'
+ );
+
+ $greaterThan10 = ThresholdRange::fromString('@10:');
+ $this->assertFalse(
+ $greaterThan10->contains(PHP_INT_MAX),
+ 'ThresholdRange::contains() identifies infinity as smaller than ten'
+ );
+ $this->assertTrue(
+ $greaterThan10->contains(9),
+ 'ThresholdRange::contains() identifies nine as greater than or equal to ten'
+ );
+
+ $smallerThan10 = ThresholdRange::fromString('@~:10');
+ $this->assertFalse(
+ $smallerThan10->contains(~PHP_INT_MAX),
+ 'ThresholdRange::contains() identifies negative infinity as greater than ten'
+ );
+ $this->assertTrue(
+ $smallerThan10->contains(11),
+ 'ThresholdRange::contains() identifies eleven as smaller than or equal to ten'
+ );
+ }
+
+ public function testFromStringProperlyParsesEmptyThresholds()
+ {
+ $emptyThreshold = ThresholdRange::fromString('');
+ $this->assertNull(
+ $emptyThreshold->getMin(),
+ 'ThresholdRange::fromString() does not identify negative infinity as implicit minimum for empty strings'
+ );
+ $this->assertNull(
+ $emptyThreshold->getMax(),
+ 'ThresholdRange::fromString() does not identify infinity as implicit maximum for empty strings'
+ );
+ $this->assertFalse(
+ $emptyThreshold->isInverted(),
+ 'ThresholdRange::fromString() identifies empty strings as inclusive ranges rather than exclusive'
+ );
+ }
+
+ /**
+ * @depends testFromStringProperlyParsesEmptyThresholds
+ */
+ public function testContainsEvaluatesEverythingToTrueForEmptyThresholds()
+ {
+ $emptyThreshold = ThresholdRange::fromString('');
+ $this->assertTrue(
+ $emptyThreshold->contains(0),
+ 'ThresholdRange::contains() does not identify zero as valid without any threshold'
+ );
+ $this->assertTrue(
+ $emptyThreshold->contains(10),
+ 'ThresholdRange::contains() does not identify ten as valid without any threshold'
+ );
+ $this->assertTrue(
+ $emptyThreshold->contains(PHP_INT_MAX),
+ 'ThresholdRange::contains() does not identify infinity as valid without any threshold'
+ );
+ $this->assertTrue(
+ $emptyThreshold->contains(~PHP_INT_MAX),
+ 'ThresholdRange::contains() does not identify negative infinity as valid without any threshold'
+ );
+ }
+
+ public function testInvalidThresholdNotationsAreRenderedAsIs()
+ {
+ $this->assertSame(
+ ':',
+ (string) ThresholdRange::fromString(':')
+ );
+ $this->assertSame(
+ '~:',
+ (string) ThresholdRange::fromString('~:')
+ );
+ $this->assertSame(
+ '20:10',
+ (string) ThresholdRange::fromString('20:10')
+ );
+ $this->assertSame(
+ '10@',
+ (string) ThresholdRange::fromString('10@')
+ );
+ $this->assertSame(
+ 'foo',
+ (string) ThresholdRange::fromString('foo')
+ );
+ $this->assertSame(
+ '4,4:2,2',
+ (string) ThresholdRange::fromString('4,4:2,2')
+ );
+ }
+
+ public function testInvalidThresholdNotationsConsideredInValid()
+ {
+ $this->assertFalse(
+ ThresholdRange::fromString('10@')->isValid(),
+ 'Invalid threshold notation 10@ considered as valid'
+ );
+ $this->assertFalse(
+ ThresholdRange::fromString('foo')->isValid(),
+ 'Invalid threshold notation foo considered as valid'
+ );
+ $this->assertFalse(
+ ThresholdRange::fromString('4,4:2,2')->isValid(),
+ 'Invalid threshold notation 4,4:2,2 considered as valid'
+ );
+ }
+}