summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:36:40 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:36:40 +0000
commita0901c4b7f2db488cb4fb3be2dd921a0308f4659 (patch)
treefafb393cf330a60df129ff10d0059eb7b14052a7
parentInitial commit. (diff)
downloadicingadb-web-a0901c4b7f2db488cb4fb3be2dd921a0308f4659.tar.xz
icingadb-web-a0901c4b7f2db488cb4fb3be2dd921a0308f4659.zip
Adding upstream version 1.0.2.upstream/1.0.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--AUTHORS13
-rw-r--r--CHANGELOG.md32
-rw-r--r--CONTRIBUTING.md253
-rw-r--r--LICENSE339
-rw-r--r--README.md69
-rw-r--r--application/controllers/CommandTransportController.php155
-rw-r--r--application/controllers/CommentController.php72
-rw-r--r--application/controllers/CommentsController.php200
-rw-r--r--application/controllers/ConfigController.php63
-rw-r--r--application/controllers/DowntimeController.php84
-rw-r--r--application/controllers/DowntimesController.php206
-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.php138
-rw-r--r--application/controllers/HostController.php288
-rw-r--r--application/controllers/HostgroupController.php89
-rw-r--r--application/controllers/HostgroupsController.php121
-rw-r--r--application/controllers/HostsController.php240
-rw-r--r--application/controllers/MigrateController.php120
-rw-r--r--application/controllers/NotificationsController.php134
-rw-r--r--application/controllers/ServiceController.php239
-rw-r--r--application/controllers/ServicegroupController.php95
-rw-r--r--application/controllers/ServicegroupsController.php120
-rw-r--r--application/controllers/ServicesController.php438
-rw-r--r--application/controllers/TacticalController.php96
-rw-r--r--application/controllers/UserController.php48
-rw-r--r--application/controllers/UsergroupController.php48
-rw-r--r--application/controllers/UsergroupsController.php97
-rw-r--r--application/controllers/UsersController.php99
-rw-r--r--application/forms/ApiTransportForm.php102
-rw-r--r--application/forms/Command/CommandForm.php136
-rw-r--r--application/forms/Command/Instance/ToggleInstanceFeaturesForm.php157
-rw-r--r--application/forms/Command/Object/AcknowledgeProblemForm.php203
-rw-r--r--application/forms/Command/Object/AddCommentForm.php154
-rw-r--r--application/forms/Command/Object/CheckNowForm.php71
-rw-r--r--application/forms/Command/Object/DeleteCommentForm.php73
-rw-r--r--application/forms/Command/Object/DeleteDowntimeForm.php88
-rw-r--r--application/forms/Command/Object/ProcessCheckResultForm.php159
-rw-r--r--application/forms/Command/Object/RemoveAcknowledgementForm.php81
-rw-r--r--application/forms/Command/Object/ScheduleCheckForm.php134
-rw-r--r--application/forms/Command/Object/ScheduleHostDowntimeForm.php122
-rw-r--r--application/forms/Command/Object/ScheduleServiceDowntimeForm.php255
-rw-r--r--application/forms/Command/Object/SendCustomNotificationForm.php129
-rw-r--r--application/forms/Command/Object/ToggleObjectFeaturesForm.php188
-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.php603
-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.phtml148
-rw-r--r--application/views/scripts/services/grid.phtml149
-rw-r--r--configuration.php573
-rw-r--r--doc/01-About.md64
-rw-r--r--doc/02-Installation.md273
-rw-r--r--doc/02-Installation.md.d/01-Amazon-Linux.md3
-rw-r--r--doc/02-Installation.md.d/02-CentOS.md3
-rw-r--r--doc/02-Installation.md.d/03-Debian.md3
-rw-r--r--doc/02-Installation.md.d/04-RHEL.md3
-rw-r--r--doc/02-Installation.md.d/05-SLES.md3
-rw-r--r--doc/02-Installation.md.d/06-Ubuntu.md3
-rw-r--r--doc/02-Installation.md.d/07-From-Source.md3
-rw-r--r--doc/03-Configuration.md63
-rw-r--r--doc/04-Security.md142
-rw-r--r--doc/05-Upgrading.md16
-rw-r--r--doc/10-Migration.md132
-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/url-migration-preview.pngbin0 -> 83690 bytes
-rw-r--r--doc/res/view-switcher-preview.pngbin0 -> 103252 bytes
-rw-r--r--library/Icingadb/Authentication/ObjectAuthorization.php259
-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.php52
-rw-r--r--library/Icingadb/Command/Object/DeleteDowntimeCommand.php58
-rw-r--r--library/Icingadb/Command/Object/GetObjectCommand.php71
-rw-r--r--library/Icingadb/Command/Object/ObjectCommand.php51
-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.php322
-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.php358
-rw-r--r--library/Icingadb/Common/BaseFilter.php50
-rw-r--r--library/Icingadb/Common/BaseItemList.php99
-rw-r--r--library/Icingadb/Common/BaseListItem.php165
-rw-r--r--library/Icingadb/Common/BaseOrderedItemList.php34
-rw-r--r--library/Icingadb/Common/BaseOrderedListItem.php41
-rw-r--r--library/Icingadb/Common/BaseStatusBar.php45
-rw-r--r--library/Icingadb/Common/BaseTableRowItem.php102
-rw-r--r--library/Icingadb/Common/CaptionDisabled.php30
-rw-r--r--library/Icingadb/Common/CommandActions.php254
-rw-r--r--library/Icingadb/Common/Database.php102
-rw-r--r--library/Icingadb/Common/DetailActions.php140
-rw-r--r--library/Icingadb/Common/HostLink.php27
-rw-r--r--library/Icingadb/Common/HostLinks.php76
-rw-r--r--library/Icingadb/Common/HostStates.php118
-rw-r--r--library/Icingadb/Common/IcingaRedis.php236
-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.php108
-rw-r--r--library/Icingadb/Common/Macros.php118
-rw-r--r--library/Icingadb/Common/NoSubjectLink.php35
-rw-r--r--library/Icingadb/Common/ObjectInspectionDetail.php330
-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.php185
-rw-r--r--library/Icingadb/Common/TicketLinks.php56
-rw-r--r--library/Icingadb/Common/ViewMode.php35
-rw-r--r--library/Icingadb/Compat/CompatHost.php106
-rw-r--r--library/Icingadb/Compat/CompatObject.php373
-rw-r--r--library/Icingadb/Compat/CompatService.php159
-rw-r--r--library/Icingadb/Compat/UrlMigrator.php1254
-rw-r--r--library/Icingadb/Data/CsvResultSet.php77
-rw-r--r--library/Icingadb/Data/JsonResultSet.php70
-rw-r--r--library/Icingadb/Data/PivotTable.php436
-rw-r--r--library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php83
-rw-r--r--library/Icingadb/Hook/Common/HookUtils.php39
-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.php118
-rw-r--r--library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php102
-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.php50
-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/Behavior/Timestamp.php37
-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.php120
-rw-r--r--library/Icingadb/Model/CommentHistory.php107
-rw-r--r--library/Icingadb/Model/Customvar.php72
-rw-r--r--library/Icingadb/Model/CustomvarFlat.php122
-rw-r--r--library/Icingadb/Model/Downtime.php147
-rw-r--r--library/Icingadb/Model/DowntimeHistory.php129
-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.php233
-rw-r--r--library/Icingadb/Model/HostCustomvar.php52
-rw-r--r--library/Icingadb/Model/HostState.php89
-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.php208
-rw-r--r--library/Icingadb/Model/HoststateSummary.php89
-rw-r--r--library/Icingadb/Model/IconImage.php55
-rw-r--r--library/Icingadb/Model/Instance.php78
-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.php114
-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.php224
-rw-r--r--library/Icingadb/Model/ServiceCustomvar.php52
-rw-r--r--library/Icingadb/Model/ServiceState.php86
-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.php159
-rw-r--r--library/Icingadb/Model/ServicestateSummary.php99
-rw-r--r--library/Icingadb/Model/State.php83
-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/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.php279
-rw-r--r--library/Icingadb/ProvidedHook/X509/Sni.php57
-rw-r--r--library/Icingadb/Redis/VolatileStateResults.php164
-rw-r--r--library/Icingadb/Setup/ApiTransportPage.php127
-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.php86
-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.php642
-rw-r--r--library/Icingadb/Util/PerfDataFormat.php172
-rw-r--r--library/Icingadb/Util/PerfDataSet.php159
-rw-r--r--library/Icingadb/Util/PluginOutput.php260
-rw-r--r--library/Icingadb/Util/ThresholdRange.php180
-rw-r--r--library/Icingadb/Web/Control/ProblemToggle.php74
-rw-r--r--library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php400
-rw-r--r--library/Icingadb/Web/Control/ViewModeSwitcher.php203
-rw-r--r--library/Icingadb/Web/Controller.php566
-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.php204
-rw-r--r--library/Icingadb/Widget/Detail/CommentDetail.php140
-rw-r--r--library/Icingadb/Widget/Detail/CustomVarTable.php267
-rw-r--r--library/Icingadb/Widget/Detail/DowntimeCard.php336
-rw-r--r--library/Icingadb/Widget/Detail/DowntimeDetail.php206
-rw-r--r--library/Icingadb/Widget/Detail/EventDetail.php612
-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.php75
-rw-r--r--library/Icingadb/Widget/Detail/HostStatistics.php62
-rw-r--r--library/Icingadb/Widget/Detail/MultiselectQuickActions.php194
-rw-r--r--library/Icingadb/Widget/Detail/ObjectDetail.php585
-rw-r--r--library/Icingadb/Widget/Detail/ObjectStatistics.php34
-rw-r--r--library/Icingadb/Widget/Detail/ObjectsDetail.php192
-rw-r--r--library/Icingadb/Widget/Detail/PerfDataTable.php133
-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.php66
-rw-r--r--library/Icingadb/Widget/Detail/UserDetail.php188
-rw-r--r--library/Icingadb/Widget/Detail/UsergroupDetail.php98
-rw-r--r--library/Icingadb/Widget/EmptyState.php27
-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.php76
-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.php217
-rw-r--r--library/Icingadb/Widget/ItemList/BaseHistoryListItem.php404
-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.php22
-rw-r--r--library/Icingadb/Widget/ItemList/CommandTransportListItem.php70
-rw-r--r--library/Icingadb/Widget/ItemList/CommentList.php44
-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.php44
-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.php58
-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.php72
-rw-r--r--library/Icingadb/Widget/ItemList/HostList.php36
-rw-r--r--library/Icingadb/Widget/ItemList/HostListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/HostListItemDetailed.php103
-rw-r--r--library/Icingadb/Widget/ItemList/HostListItemMinimal.php18
-rw-r--r--library/Icingadb/Widget/ItemList/HostgroupList.php33
-rw-r--r--library/Icingadb/Widget/ItemList/HostgroupListItem.php84
-rw-r--r--library/Icingadb/Widget/ItemList/NotificationList.php56
-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.php72
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceList.php33
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php107
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php18
-rw-r--r--library/Icingadb/Widget/ItemList/ServicegroupList.php33
-rw-r--r--library/Icingadb/Widget/ItemList/ServicegroupListItem.php68
-rw-r--r--library/Icingadb/Widget/ItemList/StateList.php31
-rw-r--r--library/Icingadb/Widget/ItemList/StateListItem.php129
-rw-r--r--library/Icingadb/Widget/ItemList/UserList.php29
-rw-r--r--library/Icingadb/Widget/ItemList/UserListItem.php62
-rw-r--r--library/Icingadb/Widget/ItemList/UsergroupList.php29
-rw-r--r--library/Icingadb/Widget/ItemList/UsergroupListItem.php62
-rw-r--r--library/Icingadb/Widget/ItemTable/BaseItemTable.php198
-rw-r--r--library/Icingadb/Widget/ItemTable/BaseRowItem.php106
-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/ServiceItemTable.php31
-rw-r--r--library/Icingadb/Widget/ItemTable/ServiceRowItem.php64
-rw-r--r--library/Icingadb/Widget/ItemTable/StateItemTable.php35
-rw-r--r--library/Icingadb/Widget/ItemTable/StateRowItem.php139
-rw-r--r--library/Icingadb/Widget/MarkdownLine.php28
-rw-r--r--library/Icingadb/Widget/MarkdownText.php28
-rw-r--r--library/Icingadb/Widget/Notice.php31
-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.php78
-rw-r--r--library/Icingadb/Widget/ShowMore.php61
-rw-r--r--library/Icingadb/Widget/StateBadge.php49
-rw-r--r--library/Icingadb/Widget/StateChange.php98
-rw-r--r--library/Icingadb/Widget/TagList.php35
-rw-r--r--module.info6
-rw-r--r--public/css/common.less357
-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.less239
-rw-r--r--public/css/list/item-table.less176
-rw-r--r--public/css/list/list-item.less139
-rw-r--r--public/css/list/state-item-table.less34
-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.less179
-rw-r--r--public/css/mixin/state-badges.less31
-rw-r--r--public/css/mixins.less22
-rw-r--r--public/css/view/service-grid.less60
-rw-r--r--public/css/widget/actions.less20
-rw-r--r--public/css/widget/cancel-button.less0
-rw-r--r--public/css/widget/check-attempt.less17
-rw-r--r--public/css/widget/check-statistics.less55
-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.less47
-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.less163
-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.less57
-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-badge.less47
-rw-r--r--public/css/widget/state-change.less128
-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.js212
-rw-r--r--public/js/loadmore.js85
-rw-r--r--public/js/migrate.js585
-rw-r--r--run.php34
-rw-r--r--test/php/library/Icingadb/Common/MacrosTest.php105
412 files changed, 40859 insertions, 0 deletions
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..e3cc5d2
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1,13 @@
+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>
+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>
+Sukhwinder Dhillon <sukhwinder.dhillon@icinga.com>
+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..394f1cf
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,32 @@
+# 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.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..15b07d7
--- /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 master 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 master
+branch and rebase your fix/feature branch against it before submitting a PR.
+
+```
+git checkout master
+git pull upstream HEAD
+
+git checkout fix/style-detail-view-5678
+git rebase master
+```
+
+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 master,
+developers might ask you to rebase your PR.
+
+First off, fetch and pull `upstream` master.
+
+```
+git checkout master
+git fetch --all
+git pull upstream HEAD
+```
+
+Then change to your working branch and start rebasing it against master:
+
+```
+git checkout fix/style-detail-view-5678
+git rebase master
+```
+
+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 master
+
+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..b9ed6e8
--- /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/icingaweb2-module-icingadb/workflows/PHP%20Tests/badge.svg?branch=master)
+[![Github Tag](https://img.shields.io/github/tag/Icinga/icingaweb2-module-icingadb.svg)](https://github.com/Icinga/icingaweb2-module-icingadb)
+
+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/application/controllers/CommandTransportController.php b/application/controllers/CommandTransportController.php
new file mode 100644
index 0000000..b0b4c32
--- /dev/null
+++ b/application/controllers/CommandTransportController.php
@@ -0,0 +1,155 @@
+<?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->setAttrib('style', 'text-align:center;');
+ $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..298fae5
--- /dev/null
+++ b/application/controllers/CommentsController.php
@@ -0,0 +1,200 @@
+<?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\Widget\ShowMore;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Filter\QueryString;
+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'
+ ]);
+
+ $this->handleSearchRequest($comments);
+
+ $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()->setQueryString(QueryString::render($this->getFilter())),
+ sprintf(t('Show all %d comments'), $comments->count())
+ ));
+
+ $this->addContent(
+ (new DeleteCommentForm())
+ ->setObjects($comments)
+ ->setAction(
+ Links::commentsDelete()
+ ->setQueryString(QueryString::render($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..0d64fbf
--- /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..89fdfc8
--- /dev/null
+++ b/application/controllers/DowntimesController.php
@@ -0,0 +1,206 @@
+<?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\Widget\ShowMore;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Filter\QueryString;
+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'
+ ]);
+
+ $this->handleSearchRequest($downtimes);
+
+ $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()->setQueryString(QueryString::render($this->getFilter())),
+ sprintf(t('Show all %d downtimes'), $downtimes->count())
+ ));
+
+ $this->addContent(
+ (new DeleteDowntimeForm())
+ ->setObjects($downtimes)
+ ->setAction(
+ Links::downtimesDelete()
+ ->setQueryString(QueryString::render($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..b4fc6df
--- /dev/null
+++ b/application/controllers/HistoryController.php
@@ -0,0 +1,138 @@
+<?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\Filter\QueryString;
+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' => 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->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);
+ $url->setQueryString(QueryString::render($filter) . '&' . $url->getParams()->toString());
+
+ $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..ae5944d
--- /dev/null
+++ b/application/controllers/HostController.php
@@ -0,0 +1,288 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+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']);
+ $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())->setObject($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' => 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' => 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..978489d
--- /dev/null
+++ b/application/controllers/HostgroupController.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\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\ItemList\HostgroupList;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\HorizontalKeyValue;
+
+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 HostgroupList([$this->hostgroup]))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addContent(Html::tag('h2', null, t('Hosts')));
+ } else {
+ $this->addControl((new HostgroupList([$this->hostgroup]))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ }
+
+ $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..f510cf4
--- /dev/null
+++ b/application/controllers/HostgroupsController.php
@@ -0,0 +1,121 @@
+<?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\ItemList\HostgroupList;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+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);
+ $sortControl = $this->createSortControl(
+ $hostgroups,
+ [
+ 'display_name' => t('Name'),
+ 'hosts_severity desc' => t('Severity'),
+ 'hosts_total desc' => t('Total Hosts'),
+ 'services_total desc' => t('Total Services')
+ ]
+ );
+ $searchBar = $this->createSearchBar($hostgroups, [
+ $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($hostgroups, $filter);
+
+ $hostgroups->peekAhead($compact);
+
+ yield $this->export($hostgroups);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $results = $hostgroups->execute();
+
+ $this->addContent(
+ (new HostgroupList($results))->setBaseFilter($filter)
+ );
+
+ 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..eaf804a
--- /dev/null
+++ b/application/controllers/HostsController.php
@@ -0,0 +1,240 @@
+<?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\Widget\ShowMore;
+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;
+use ipl\Web\Filter\QueryString;
+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);
+
+ $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' => 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 (isset($summary)) {
+ $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 {
+ $this->addFooter((new HostStatusBar($summary->first()))->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')
+ );
+ $this->addControl(new ShowMore(
+ $results,
+ Links::hosts()->setQueryString(QueryString::render($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()->setQueryString(QueryString::render($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..395319a
--- /dev/null
+++ b/application/controllers/MigrateController.php
@@ -0,0 +1,120 @@
+<?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\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\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 = 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 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');
+ }
+
+ $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..1c8a5ff
--- /dev/null
+++ b/application/controllers/NotificationsController.php
@@ -0,0 +1,134 @@
+<?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->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);
+ $url->setQueryString(QueryString::render($filter) . '&' . $url->getParams()->toString());
+
+ $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..2933d93
--- /dev/null
+++ b/application/controllers/ServiceController.php
@@ -0,0 +1,239 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Controllers;
+
+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'
+ ]);
+ $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())->setObject($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' => 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..89fb829
--- /dev/null
+++ b/application/controllers/ServicegroupController.php
@@ -0,0 +1,95 @@
+<?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\ItemList\ServicegroupList;
+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 ServicegroupList([$this->servicegroup]))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ $this->addContent(Html::tag('h2', null, t('Services')));
+ } else {
+ $this->addControl((new ServicegroupList([$this->servicegroup]))
+ ->setViewMode('minimal')
+ ->setDetailActionsDisabled()
+ ->setNoSubjectLink());
+ }
+
+ $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..76c48fb
--- /dev/null
+++ b/application/controllers/ServicegroupsController.php
@@ -0,0 +1,120 @@
+<?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\ItemList\ServicegroupList;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+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);
+ $sortControl = $this->createSortControl(
+ $servicegroups,
+ [
+ 'display_name' => t('Name'),
+ 'services_severity desc' => t('Severity'),
+ 'services_total desc' => t('Total Services')
+ ]
+ );
+ $searchBar = $this->createSearchBar($servicegroups, [
+ $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($servicegroups, $filter);
+
+ $servicegroups->peekAhead($compact);
+
+ yield $this->export($servicegroups);
+
+ $this->addControl($paginationControl);
+ $this->addControl($sortControl);
+ $this->addControl($limitControl);
+ $this->addControl($searchBar);
+
+ $results = $servicegroups->execute();
+
+ $this->addContent(
+ (new ServicegroupList($results))->setBaseFilter($filter)
+ );
+
+ 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..9ee9474
--- /dev/null
+++ b/application/controllers/ServicesController.php
@@ -0,0 +1,438 @@
+<?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\Widget\ShowMore;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+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\Filter\QueryString;
+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' => 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 {
+ $this->addFooter((new ServiceStatusBar($summary->first()))->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')
+ );
+ $this->addControl(new ShowMore(
+ $results,
+ Links::services()->setQueryString(QueryString::render($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()
+ {
+ $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 = $this->getRequest()->getUrl()
+ ->onlyWith([
+ LimitControl::DEFAULT_LIMIT_PARAM,
+ $sortControl->getSortParam(),
+ 'flipped',
+ 'page',
+ 'problems'
+ ]);
+ $preservedParams = $this->view->baseUrl->getParams();
+ $this->view->baseUrl->setQueryString(QueryString::render($filter));
+ foreach ($preservedParams->toArray(false) as $name => $value) {
+ if (is_int($name)) {
+ $name = $value;
+ $value = true;
+ }
+
+ $this->view->baseUrl->getParams()->addEncoded($name, $value);
+ }
+
+ $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()->setQueryString(QueryString::render($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)
+ {
+ if ($this->params->shift('_hostFilterOnly', false)) {
+ $filter->add(Filter::like('host.name_ci', "*$search*"));
+ } else {
+ parent::prepareSearchFilter($query, $search, $filter);
+ }
+ }
+
+ 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..582b046
--- /dev/null
+++ b/application/controllers/TacticalController.php
@@ -0,0 +1,96 @@
+<?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);
+
+ $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'));
+ }
+
+ protected function prepareSearchFilter(Query $query, string $search, Filter\Any $filter)
+ {
+ parent::prepareSearchFilter($query, $search, $filter);
+
+ $filter->add(Filter::like('host.name_ci', "*$search*"));
+ }
+}
diff --git a/application/controllers/UserController.php b/application/controllers/UserController.php
new file mode 100644
index 0000000..751bb38
--- /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\ItemList\UserList;
+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 UserList([$this->user]))->setNoSubjectLink()->setDetailActionsDisabled());
+ $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..e1d4ae2
--- /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\ItemList\UsergroupList;
+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 UsergroupList([$this->usergroup]))->setNoSubjectLink()->setDetailActionsDisabled());
+ $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..3f0ea11
--- /dev/null
+++ b/application/controllers/UsergroupsController.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\Usergroup;
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use Icinga\Module\Icingadb\Web\Controller;
+use Icinga\Module\Icingadb\Widget\ItemList\UsergroupList;
+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);
+
+ $this->handleSearchRequest($usergroups);
+
+ $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 UsergroupList($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..ef75e89
--- /dev/null
+++ b/application/controllers/UsersController.php
@@ -0,0 +1,99 @@
+<?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\ItemList\UserList;
+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);
+
+ $this->handleSearchRequest($users);
+
+ $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 UserList($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..7fb7966
--- /dev/null
+++ b/application/forms/Command/CommandForm.php
@@ -0,0 +1,136 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Web\Notification;
+use Icinga\Web\Session;
+use ipl\Html\Form;
+use ipl\Orm\Model;
+use ipl\Web\Common\CsrfCounterMeasure;
+
+abstract class CommandForm extends Form
+{
+ use CsrfCounterMeasure;
+
+ protected $defaultAttributes = ['class' => 'icinga-form icinga-controls'];
+
+ /** @var mixed */
+ protected $objects;
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 command to issue for the given object
+ *
+ * @param Model $object
+ *
+ * @return IcingaCommand|IcingaCommand[]|null NULL in case no command should be issued for the object
+ */
+ abstract protected function getCommand(Model $object);
+
+ protected function assemble()
+ {
+ $this->assembleElements();
+ $this->assembleSubmitButton();
+ $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId()));
+ }
+
+ protected function onSuccess()
+ {
+ $errors = [];
+ foreach ($this->getObjects() as $object) {
+ $commands = $this->getCommand($object);
+ if ($commands === null) {
+ continue;
+ }
+
+ if ($commands instanceof IcingaCommand) {
+ $commands = [$commands];
+ }
+
+ foreach ($commands 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);
+ }
+}
diff --git a/application/forms/Command/Instance/ToggleInstanceFeaturesForm.php b/application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
new file mode 100644
index 0000000..eb270da
--- /dev/null
+++ b/application/forms/Command/Instance/ToggleInstanceFeaturesForm.php
@@ -0,0 +1,157 @@
+<?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\Common\Auth;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Orm\Model;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+
+class ToggleInstanceFeaturesForm extends CommandForm
+{
+ use Auth;
+
+ 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 getCommand(Model $object): \Generator
+ {
+ 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..5ca7074
--- /dev/null
+++ b/application/forms/Command/Object/AcknowledgeProblemForm.php
@@ -0,0 +1,203 @@
+<?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\Common\Auth;
+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;
+
+class AcknowledgeProblemForm extends CommandForm
+{
+ use Auth;
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (current($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'));
+
+ $acknowledgeExpire = (bool) $config->get('settings', 'acknowledge_expire', false);
+
+ $this->addElement(
+ 'checkbox',
+ 'expire',
+ [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'value' => $acknowledgeExpire,
+ 'label' => t('Use Expire Time'),
+ 'description' => t('If the acknowledgement should expire, check this option.')
+ ]
+ );
+ $decorator->decorate($this->getElement('expire'));
+
+ if ($acknowledgeExpire || $this->getPopulatedValue('expire') === 'y') {
+ $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.'
+ )
+ ]
+ );
+ $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'));
+ }
+
+ /**
+ * @return ?AcknowledgeProblemCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (! $this->isGrantedOn('icingadb/command/acknowledge-problem', $object)) {
+ return null;
+ }
+
+ $command = new AcknowledgeProblemCommand();
+ $command->setObject($object);
+ $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());
+ }
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/AddCommentForm.php b/application/forms/Command/Object/AddCommentForm.php
new file mode 100644
index 0000000..af75f86
--- /dev/null
+++ b/application/forms/Command/Object/AddCommentForm.php
@@ -0,0 +1,154 @@
+<?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\Common\Auth;
+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;
+
+class AddCommentForm extends CommandForm
+{
+ use Auth;
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (current($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');
+ $commentExpire = (bool) $config->get('settings', 'comment_expire', false);
+
+ $this->addElement(
+ 'checkbox',
+ 'expire',
+ [
+ 'ignore' => true,
+ 'class' => 'autosubmit',
+ 'value' => $commentExpire,
+ 'label' => t('Use Expire Time'),
+ 'description' => t('If the comment should expire, check this option.')
+ ]
+ );
+ $decorator->decorate($this->getElement('expire'));
+
+ if ($commentExpire || $this->getPopulatedValue('expire') === 'y') {
+ $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.')
+ ]
+ );
+ $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'));
+ }
+
+ /**
+ * @return ?AddCommentCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (! $this->isGrantedOn('icingadb/command/comment/add', $object)) {
+ return null;
+ }
+
+ $command = new AddCommentCommand();
+ $command->setObject($object);
+ $command->setComment($this->getValue('comment'));
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ if (($expireTime = $this->getValue('expire_time'))) {
+ /** @var DateTime $expireTime */
+ $command->setExpireTime($expireTime->getTimestamp());
+ }
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/CheckNowForm.php b/application/forms/Command/Object/CheckNowForm.php
new file mode 100644
index 0000000..136b40e
--- /dev/null
+++ b/application/forms/Command/Object/CheckNowForm.php
@@ -0,0 +1,71 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Module\Icingadb\Command\Object\ScheduleCheckCommand;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Orm\Model;
+use ipl\Web\Widget\Icon;
+
+class CheckNowForm extends CommandForm
+{
+ use Auth;
+
+ 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')
+ ]
+ );
+ }
+
+ /**
+ * @return ?ScheduleCheckCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (
+ ! $this->isGrantedOn('icingadb/command/schedule-check', $object)
+ && (
+ ! $object->active_checks_enabled
+ || ! $this->isGrantedOn('icingadb/command/schedule-check/active-only', $object)
+ )
+ ) {
+ return null;
+ }
+
+ $command = new ScheduleCheckCommand();
+ $command->setObject($object);
+ $command->setCheckTime(time());
+ $command->setForced();
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/DeleteCommentForm.php b/application/forms/Command/Object/DeleteCommentForm.php
new file mode 100644
index 0000000..1889c7b
--- /dev/null
+++ b/application/forms/Command/Object/DeleteCommentForm.php
@@ -0,0 +1,73 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Module\Icingadb\Command\Object\DeleteCommentCommand;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Orm\Model;
+use ipl\Web\Common\RedirectOption;
+use ipl\Web\Widget\Icon;
+
+class DeleteCommentForm extends CommandForm
+{
+ use Auth;
+ 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()))
+ ]
+ ]
+ );
+ }
+
+ /**
+ * @return ?DeleteCommentCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (! $this->isGrantedOn('icingadb/command/comment/delete', $object->{$object->object_type})) {
+ return null;
+ }
+
+ $command = new DeleteCommentCommand();
+ $command->setCommentName($object->name);
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/DeleteDowntimeForm.php b/application/forms/Command/Object/DeleteDowntimeForm.php
new file mode 100644
index 0000000..c34c9a8
--- /dev/null
+++ b/application/forms/Command/Object/DeleteDowntimeForm.php
@@ -0,0 +1,88 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Module\Icingadb\Command\Object\DeleteDowntimeCommand;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Web\Notification;
+use ipl\Orm\Model;
+use ipl\Web\Common\RedirectOption;
+use ipl\Web\Widget\Icon;
+
+class DeleteDowntimeForm extends CommandForm
+{
+ use Auth;
+ 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()))
+ ]
+ ]
+ );
+ }
+
+ /**
+ * @return ?DeleteDowntimeCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (
+ ! $this->isGrantedOn('icingadb/command/downtime/delete', $object->{$object->object_type})
+ || $object->scheduled_by !== null
+ ) {
+ return null;
+ }
+
+ $command = new DeleteDowntimeCommand();
+ $command->setDowntimeName($object->name);
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/ProcessCheckResultForm.php b/application/forms/Command/Object/ProcessCheckResultForm.php
new file mode 100644
index 0000000..e7e23e7
--- /dev/null
+++ b/application/forms/Command/Object/ProcessCheckResultForm.php
@@ -0,0 +1,159 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use Icinga\Module\Icingadb\Command\Object\ProcessCheckResultCommand;
+use Icinga\Module\Icingadb\Common\Auth;
+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;
+
+class ProcessCheckResultForm extends CommandForm
+{
+ use Auth;
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (current($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();
+
+ foreach ($this->getObjects() as $object) {
+ /** @var Model $object */
+ // Nasty, but as getObjects() returns everything but an object with a real
+ // iterator interface this is the only way to fetch just the first element
+ break;
+ }
+
+ $this->addElement(
+ 'select',
+ 'status',
+ [
+ 'required' => true,
+ 'label' => t('Status'),
+ 'description' => t('The state this check result should report'),
+ 'multiOptions' => $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'));
+ }
+
+ /**
+ * @return ?ProcessCheckResultCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (
+ ! $object->passive_checks_enabled
+ || ! $this->isGrantedOn('icingadb/command/process-check-result', $object)
+ ) {
+ return null;
+ }
+
+ $command = new ProcessCheckResultCommand();
+ $command->setObject($object);
+ $command->setStatus($this->getValue('status'));
+ $command->setOutput($this->getValue('output'));
+ $command->setPerformanceData($this->getValue('perfdata'));
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/RemoveAcknowledgementForm.php b/application/forms/Command/Object/RemoveAcknowledgementForm.php
new file mode 100644
index 0000000..a69706a
--- /dev/null
+++ b/application/forms/Command/Object/RemoveAcknowledgementForm.php
@@ -0,0 +1,81 @@
+<?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\Common\Auth;
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Web\Notification;
+use ipl\Orm\Model;
+use ipl\Web\Widget\Icon;
+
+class RemoveAcknowledgementForm extends CommandForm
+{
+ use Auth;
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (current($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()))
+ ]
+ ]
+ );
+ }
+
+ /**
+ * @return ?RemoveAcknowledgementCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (! $this->isGrantedOn('icingadb/command/remove-acknowledgement', $object)) {
+ return null;
+ }
+
+ $command = new RemoveAcknowledgementCommand();
+ $command->setObject($object);
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/ScheduleCheckForm.php b/application/forms/Command/Object/ScheduleCheckForm.php
new file mode 100644
index 0000000..853516f
--- /dev/null
+++ b/application/forms/Command/Object/ScheduleCheckForm.php
@@ -0,0 +1,134 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms\Command\Object;
+
+use DateInterval;
+use DateTime;
+use Icinga\Module\Icingadb\Command\Object\ScheduleCheckCommand;
+use Icinga\Module\Icingadb\Common\Auth;
+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;
+
+class ScheduleCheckForm extends CommandForm
+{
+ use Auth;
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (current($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'));
+ }
+
+ /**
+ * @return ?ScheduleCheckCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (
+ ! $this->isGrantedOn('icingadb/command/schedule-check', $object)
+ && (
+ ! $object->active_checks_enabled
+ || ! $this->isGrantedOn('icingadb/command/schedule-check/active-only', $object)
+ )
+ ) {
+ return null;
+ }
+
+ $command = new ScheduleCheckCommand();
+ $command->setObject($object);
+ $command->setForced($this->getElement('force_check')->isChecked());
+ $command->setCheckTime($this->getValue('check_time')->getTimestamp());
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/ScheduleHostDowntimeForm.php b/application/forms/Command/Object/ScheduleHostDowntimeForm.php
new file mode 100644
index 0000000..081316b
--- /dev/null
+++ b/application/forms/Command/Object/ScheduleHostDowntimeForm.php
@@ -0,0 +1,122 @@
+<?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\Orm\Model;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+
+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'),
+ 'multiOptions' => [
+ 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'));
+ }
+
+ /**
+ * @return ?PropagateHostDowntimeCommand|ScheduleHostDowntimeCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (! $this->isGrantedOn('icingadb/command/downtime/schedule', $object)) {
+ return null;
+ }
+
+ if (($childOptions = (int) $this->getValue('child_options'))) {
+ $command = new PropagateHostDowntimeCommand();
+ $command->setTriggered($childOptions === 1);
+ } else {
+ $command = new ScheduleHostDowntimeCommand();
+ }
+
+ $command->setObject($object);
+ $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
+ );
+ }
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/ScheduleServiceDowntimeForm.php b/application/forms/Command/Object/ScheduleServiceDowntimeForm.php
new file mode 100644
index 0000000..05453e2
--- /dev/null
+++ b/application/forms/Command/Object/ScheduleServiceDowntimeForm.php
@@ -0,0 +1,255 @@
+<?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\Common\Auth;
+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\Orm\Model;
+use ipl\Validator\CallbackValidator;
+use ipl\Web\FormDecorator\IcingaFormDecorator;
+use ipl\Web\Widget\Icon;
+
+class ScheduleServiceDowntimeForm extends CommandForm
+{
+ use Auth;
+
+ /** @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
+ ]
+ );
+ $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()
+ ->add($minutesInput)
+ ->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'));
+ }
+
+ /**
+ * @return ?ScheduleServiceDowntimeCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (! $this->isGrantedOn('icingadb/command/downtime/schedule', $object)) {
+ return null;
+ }
+
+ $command = new ScheduleServiceDowntimeCommand();
+ $command->setObject($object);
+ $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
+ );
+ }
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/SendCustomNotificationForm.php b/application/forms/Command/Object/SendCustomNotificationForm.php
new file mode 100644
index 0000000..9813def
--- /dev/null
+++ b/application/forms/Command/Object/SendCustomNotificationForm.php
@@ -0,0 +1,129 @@
+<?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\Common\Auth;
+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;
+
+class SendCustomNotificationForm extends CommandForm
+{
+ use Auth;
+
+ public function __construct()
+ {
+ $this->on(self::ON_SUCCESS, function () {
+ if ($this->errorOccurred) {
+ return;
+ }
+
+ $countObjects = count($this->getObjects());
+ if (current($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'));
+ }
+
+ /**
+ * @return ?SendCustomNotificationCommand
+ */
+ protected function getCommand(Model $object)
+ {
+ if (! $this->isGrantedOn('icingadb/command/send-custom-notification', $object)) {
+ return null;
+ }
+
+ $command = new SendCustomNotificationCommand();
+ $command->setObject($object);
+ $command->setComment($this->getValue('comment'));
+ $command->setForced($this->getElement('forced')->isChecked());
+ $command->setAuthor($this->getAuth()->getUser()->getUsername());
+
+ return $command;
+ }
+}
diff --git a/application/forms/Command/Object/ToggleObjectFeaturesForm.php b/application/forms/Command/Object/ToggleObjectFeaturesForm.php
new file mode 100644
index 0000000..8b2d2d2
--- /dev/null
+++ b/application/forms/Command/Object/ToggleObjectFeaturesForm.php
@@ -0,0 +1,188 @@
+<?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\Common\Auth;
+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;
+
+class ToggleObjectFeaturesForm extends CommandForm
+{
+ use Auth;
+
+ 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 getCommand(Model $object): \Generator
+ {
+ foreach ($this->features as $feature => $spec) {
+ if ($this->getElement($feature) instanceof CheckboxElement) {
+ $featureState = $this->getElement($feature)->isChecked();
+ } else {
+ $featureState = $this->getElement($feature)->getValue();
+ }
+
+ if (
+ ! $this->isGrantedOn($spec['permission'], $object)
+ || $featureState === self::LEAVE_UNCHANGED
+ || (int) $featureState === (int) $this->featureStatus[$feature]
+ ) {
+ continue;
+ }
+
+ $command = new ToggleObjectFeatureCommand();
+ $command->setObject($object);
+ $command->setFeature($feature);
+ $command->setEnabled((int) $featureState);
+
+ $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..c27737b
--- /dev/null
+++ b/application/forms/RedisConfigForm.php
@@ -0,0 +1,603 @@
+<?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,
+ ]);
+
+ $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,
+ ]);
+
+ $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();
+ 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..e5d2ac4
--- /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')))));
+}
+$yAxisItemCountPerPage = $yAxisPaginator->getLimit();
+$yAxisTotalItem = $yAxisPaginator->count();
+$totalYAxisPages = ceil($yAxisTotalItem / $yAxisItemCountPerPage);
+$currentYAxisPage = round($yAxisPaginator->getOffset() / $yAxisItemCountPerPage) + 1;
+$prevYAxisPage = $currentYAxisPage > 1 ? $currentYAxisPage - 1 : null;
+$nextYAxisPage = $currentYAxisPage < $totalYAxisPages ? $currentYAxisPage + 1 : null;
+
+$xAxisItemCountPerPage = $xAxisPaginator->getLimit();
+$xAxisTotalItem = $xAxisPaginator->count();
+$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..f186fda
--- /dev/null
+++ b/application/views/scripts/services/grid-flipped.phtml
@@ -0,0 +1,148 @@
+<?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"><?= $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
+ $horizontalItemsPerPage = $this->horizontalPaginator->getLimit();
+ $horizontalTotalPages = ceil($this->horizontalPaginator->count() / $horizontalItemsPerPage);
+
+
+ $verticalItemsPerPage = $this->verticalPaginator->getLimit();
+ $verticalTotalPages = ceil($this->verticalPaginator->count() / $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..560f7c0
--- /dev/null
+++ b/application/views/scripts/services/grid.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"><?= $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
+ $horizontalItemsPerPage = $this->horizontalPaginator->getLimit();
+ $horizontalTotalPages = ceil($this->horizontalPaginator->count() / $horizontalItemsPerPage);
+
+
+ $verticalItemsPerPage = $this->verticalPaginator->getLimit();
+ $verticalTotalPages = ceil($this->verticalPaginator->count() / $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..4104d8a
--- /dev/null
+++ b/configuration.php
@@ -0,0 +1,573 @@
+<?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 */
+
+ $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')) {
+ /*
+ * 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'
+ ]);
+ $auth = Auth::getInstance();
+ $routeDenylist = [];
+ if ($auth->isAuthenticated() && ! $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
+ ]);
+
+
+ $auth = Auth::getInstance();
+ $routeDenylist = [];
+ if ($auth->isAuthenticated() && ! $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');
+
+ $mg = Icinga::app()->getModuleManager();
+ if ($mg->hasEnabled('monitoring')) {
+ $this->provideJsFile('migrate.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..b78987c
--- /dev/null
+++ b/doc/02-Installation.md
@@ -0,0 +1,273 @@
+<!-- {% 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 from_source %} -->
+## Adding Icinga Package Repository
+
+The recommended way to install Icinga DB Web is to use prebuilt packages from our official release repository.
+
+!!! tip
+
+ If you install Icinga DB Web on a node that has Icinga 2, Icinga DB or Icinga Web installed via packages,
+ proceed to [installing the Icinga DB Web package](#installing-icinga-db-web-package) as
+ the repository is already configured.
+
+Here's how to add the official release repository:
+
+<!-- {% if amazon_linux %} -->
+<!-- {% if not icingaDocs %} -->
+### Amazon Linux 2 Repository
+<!-- {% endif %} -->
+!!! info
+
+ A paid repository subscription is required for Amazon Linux 2 repositories. Get more information on
+ [icinga.com/subscription](https://icinga.com/subscription).
+
+ Don't forget to fill in the username and password section with appropriate credentials in the local .repo file.
+
+```bash
+rpm --import https://packages.icinga.com/icinga.key
+wget https://packages.icinga.com/subscription/amazon/ICINGA-release.repo -O /etc/yum.repos.d/ICINGA-release.repo
+```
+<!-- {% endif %} -->
+
+<!-- {% if centos %} -->
+<!-- {% if not icingaDocs %} -->
+### CentOS Repository
+<!-- {% endif %} -->
+```bash
+rpm --import https://packages.icinga.com/icinga.key
+wget https://packages.icinga.com/centos/ICINGA-release.repo -O /etc/yum.repos.d/ICINGA-release.repo
+```
+<!-- {% endif %} -->
+
+<!-- {% if debian %} -->
+<!-- {% if not icingaDocs %} -->
+### Debian Repository
+<!-- {% endif %} -->
+
+```bash
+apt-get update
+apt-get -y install apt-transport-https wget gnupg
+
+wget -O - https://packages.icinga.com/icinga.key | apt-key add -
+
+DIST=$(awk -F"[)(]+" '/VERSION=/ {print $2}' /etc/os-release); \
+ echo "deb https://packages.icinga.com/debian icinga-${DIST} main" > \
+ /etc/apt/sources.list.d/${DIST}-icinga.list
+ echo "deb-src https://packages.icinga.com/debian icinga-${DIST} main" >> \
+ /etc/apt/sources.list.d/${DIST}-icinga.list
+
+apt-get update
+```
+<!-- {% endif %} -->
+
+<!-- {% if rhel %} -->
+<!-- {% if not icingaDocs %} -->
+### RHEL Repository
+<!-- {% endif %} -->
+!!! info
+
+ A paid repository subscription is required for RHEL repositories. Get more information on
+ [icinga.com/subscription](https://icinga.com/subscription).
+
+ Don't forget to fill in the username and password section with appropriate credentials in the local .repo file.
+
+```bash
+rpm --import https://packages.icinga.com/icinga.key
+wget https://packages.icinga.com/subscription/rhel/ICINGA-release.repo -O /etc/yum.repos.d/ICINGA-release.repo
+```
+<!-- {% endif %} -->
+
+<!-- {% if sles %} -->
+<!-- {% if not icingaDocs %} -->
+### SLES Repository
+<!-- {% endif %} -->
+!!! info
+
+ A paid repository subscription is required for SLES repositories. Get more information on
+ [icinga.com/subscription](https://icinga.com/subscription).
+
+ Don't forget to fill in the username and password section with appropriate credentials in the local .repo file.
+
+```bash
+rpm --import https://packages.icinga.com/icinga.key
+
+zypper ar https://packages.icinga.com/subscription/sles/ICINGA-release.repo
+zypper ref
+```
+<!-- {% endif %} -->
+
+<!-- {% if ubuntu %} -->
+<!-- {% if not icingaDocs %} -->
+### Ubuntu Repository
+<!-- {% endif %} -->
+
+```bash
+apt-get update
+apt-get -y install apt-transport-https wget gnupg
+
+wget -O - https://packages.icinga.com/icinga.key | apt-key add -
+
+. /etc/os-release; if [ ! -z ${UBUNTU_CODENAME+x} ]; then DIST="${UBUNTU_CODENAME}"; else DIST="$(lsb_release -c| awk '{print $2}')"; fi; \
+ echo "deb https://packages.icinga.com/ubuntu icinga-${DIST} main" > \
+ /etc/apt/sources.list.d/${DIST}-icinga.list
+ echo "deb-src https://packages.icinga.com/ubuntu icinga-${DIST} main" >> \
+ /etc/apt/sources.list.d/${DIST}-icinga.list
+
+apt-get update
+```
+<!-- {% endif %} -->
+
+## Installing Icinga DB Web Package
+
+Use your distribution's package manager to install the `icingadb-web` package as follows:
+
+<!-- {% if amazon_linux %} -->
+<!-- {% if not icingaDocs %} -->
+#### Amazon Linux 2
+<!-- {% endif %} -->
+```bash
+yum install icingadb-web
+```
+<!-- {% endif %} -->
+
+<!-- {% if centos %} -->
+<!-- {% if not icingaDocs %} -->
+#### CentOS
+<!-- {% endif %} -->
+!!! info
+
+ Note that installing Icinga DB Web is only supported on CentOS 7 as CentOS 8 is EOL.
+
+```bash
+yum install icingadb-web
+```
+<!-- {% endif %} -->
+
+<!-- {% if debian or ubuntu %} -->
+<!-- {% if not icingaDocs %} -->
+#### Debian / Ubuntu
+<!-- {% endif %} -->
+```bash
+apt-get install icingadb-web
+```
+<!-- {% endif %} -->
+
+<!-- {% if rhel %} -->
+#### RHEL 8 or Later
+
+```bash
+dnf install icingadb-web
+```
+
+#### RHEL 7
+
+```bash
+yum install icingadb-web
+```
+<!-- {% endif %} -->
+
+<!-- {% if sles %} -->
+<!-- {% if not icingaDocs %} -->
+#### SLES
+<!-- {% endif %} -->
+```bash
+zypper install icingadb-web
+```
+<!-- {% endif %} -->
+
+<!-- {% else %} --><!-- {# end if not from_source #} -->
+<!-- {% if not icingaDocs %} -->
+## Installing Icinga DB Web from Source
+<!-- {% endif %} -->
+
+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.10)
+* [Icinga PHP Thirdparty](https://github.com/Icinga/icinga-php-thirdparty) (≥0.11)
+<!-- {% endif %} --><!-- {# end else if not from_source #} -->
+
+## Configuring Icinga DB Web
+
+<!-- {% if not from_source %} -->
+The Icinga Web PHP framework is required to configure and run the Icinga DB Web module.
+Package installations of `icingadb-web` already set up the necessary dependencies.
+If Icinga Web has not been installed or set up before,
+you have completed the instructions here and can proceed to
+<!-- {% if amazon_linux %} -->
+[install the web server on Amazon Linux](https://icinga.com/docs/icinga-web-2/latest/doc/02-Installation/06-Amazon-Linux/#install-the-web-server),
+<!-- {% endif %} -->
+<!-- {% if centos %} -->
+[install the web server on CentOS](https://icinga.com/docs/icinga-web-2/latest/doc/02-Installation/03-CentOS/#install-the-web-server),
+<!-- {% endif %} -->
+<!-- {% if debian %} -->
+[install the web server on Debian](https://icinga.com/docs/icinga-web-2/latest/doc/02-Installation/01-Debian/#install-the-web-server),
+<!-- {% endif %} -->
+<!-- {% if rhel %} -->
+[install the web server on RHEL](https://icinga.com/docs/icinga-web-2/latest/doc/02-Installation/04-RHEL/#install-the-web-server),
+<!-- {% endif %} -->
+<!-- {% if sles %} -->
+[install the web server on SLES](https://icinga.com/docs/icinga-web-2/latest/doc/02-Installation/05-SLES/#install-the-web-server),
+<!-- {% endif %} -->
+<!-- {% if ubuntu %} -->
+[install the web server on Ubuntu](https://icinga.com/docs/icinga-web-2/latest/doc/02-Installation/02-Ubuntu/#install-the-web-server),
+<!-- {% endif %} -->
+which will then take you to the web-based setup wizard, which also covers the configuration of Icinga DB Web.
+<!-- {% endif %} --><!-- {# end if not from_source #} -->
+
+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 the Icinga DB Web module:
+
+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.
+3. Configure the connection to the Redis server through the `Configuration → Modules → icingadb → Redis` menu.
+4. 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 appropriate permissions on the Icinga 2 side:
+
+!!! 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.
+
+If you have previously used the monitoring module, there is an option to [migrate](10-Migration.md) some settings.
+<!-- {% endif %} --><!-- {# end else if index #} -->
diff --git a/doc/02-Installation.md.d/01-Amazon-Linux.md b/doc/02-Installation.md.d/01-Amazon-Linux.md
new file mode 100644
index 0000000..c4b2b50
--- /dev/null
+++ b/doc/02-Installation.md.d/01-Amazon-Linux.md
@@ -0,0 +1,3 @@
+# Installing Icinga DB Web on Amazon Linux
+<!-- {% set amazon_linux = True %} -->
+<!-- {% include "02-Installation.md" %} -->
diff --git a/doc/02-Installation.md.d/02-CentOS.md b/doc/02-Installation.md.d/02-CentOS.md
new file mode 100644
index 0000000..5f4a05b
--- /dev/null
+++ b/doc/02-Installation.md.d/02-CentOS.md
@@ -0,0 +1,3 @@
+# Installing Icinga DB Web on CentOS
+<!-- {% set centos = True %} -->
+<!-- {% include "02-Installation.md" %} -->
diff --git a/doc/02-Installation.md.d/03-Debian.md b/doc/02-Installation.md.d/03-Debian.md
new file mode 100644
index 0000000..3097e4d
--- /dev/null
+++ b/doc/02-Installation.md.d/03-Debian.md
@@ -0,0 +1,3 @@
+# Installing Icinga DB Web on Debian
+<!-- {% set debian = True %} -->
+<!-- {% include "02-Installation.md" %} -->
diff --git a/doc/02-Installation.md.d/04-RHEL.md b/doc/02-Installation.md.d/04-RHEL.md
new file mode 100644
index 0000000..bf88576
--- /dev/null
+++ b/doc/02-Installation.md.d/04-RHEL.md
@@ -0,0 +1,3 @@
+# Installing Icinga DB Web on RHEL
+<!-- {% set rhel = True %} -->
+<!-- {% include "02-Installation.md" %} -->
diff --git a/doc/02-Installation.md.d/05-SLES.md b/doc/02-Installation.md.d/05-SLES.md
new file mode 100644
index 0000000..7396fef
--- /dev/null
+++ b/doc/02-Installation.md.d/05-SLES.md
@@ -0,0 +1,3 @@
+# Installing Icinga DB Web on SLES
+<!-- {% set sles = True %} -->
+<!-- {% include "02-Installation.md" %} -->
diff --git a/doc/02-Installation.md.d/06-Ubuntu.md b/doc/02-Installation.md.d/06-Ubuntu.md
new file mode 100644
index 0000000..9f6f9ff
--- /dev/null
+++ b/doc/02-Installation.md.d/06-Ubuntu.md
@@ -0,0 +1,3 @@
+# Installing Icinga DB Web on Ubuntu
+<!-- {% set ubuntu = True %} -->
+<!-- {% include "02-Installation.md" %} -->
diff --git a/doc/02-Installation.md.d/07-From-Source.md b/doc/02-Installation.md.d/07-From-Source.md
new file mode 100644
index 0000000..3fb55f6
--- /dev/null
+++ b/doc/02-Installation.md.d/07-From-Source.md
@@ -0,0 +1,3 @@
+# Installing Icinga DB Web from Source
+<!-- {% set from_source = True %} -->
+<!-- {% include "02-Installation.md" %} -->
diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md
new file mode 100644
index 0000000..3f807cf
--- /dev/null
+++ b/doc/03-Configuration.md
@@ -0,0 +1,63 @@
+# Configuration
+
+Icinga DB Web is configured via the web interface. Below you will find an overview of the necessary 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..c6653d9
--- /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,
+contacts, contactgroups
+
+`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..7bb3efd
--- /dev/null
+++ b/doc/05-Upgrading.md
@@ -0,0 +1,16 @@
+# 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.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/10-Migration.md b/doc/10-Migration.md
new file mode 100644
index 0000000..a7742f3
--- /dev/null
+++ b/doc/10-Migration.md
@@ -0,0 +1,132 @@
+# 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.
+
+## Dashboards and Navigation
+
+![Url Migration Preview](res/url-migration-preview.png)
+
+The dashboard and menu item configuration does not change since these are related
+to Icinga Web 2. However, if you've used the monitoring module's urls and you want
+to update them, this might be straight forward if it's only the url path that needs
+to change. Complex filters though can be cumbersome to rewrite.
+
+That is why we provided the migration widget shown above. It will show up for every
+monitoring module url for which there is a counterpart in Icinga DB Web. You can then
+switch to the respective view in Icinga DB Web with a single click and either use the
+new url from the address bar or add it the usual way to the dashboard and sidebar.
+
+Host and service actions on the other hand are part of the monitoring module. For them
+Icinga DB Web provides their own counterparts. You don't need to migrate them manually
+though. The `migrate` command of Icinga Web 2 (>= v2.9.4) provides an action for that:
+
+`icingacli migrate navigation [--user=<username>] [--delete]`
+
+By default this will migrate the configuration of all users and won't delete the old
+ones. It can be restricted to a single user and the removal of the old configuration
+can be enabled as well.
+
+### Automation
+
+For those who have a plethora of custom dashboards or navigation items there is also
+a way to automate the migration of these urls. There is an API endpoint in Icinga DB
+Web that the very same widget from above utilizes:
+
+`/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 not recognized 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 mentioned earlier also assists you by providing an
+example set of columns conveying the same information shown in the monitoring module lists.
+
+## Restrictions
+
+### `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.
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/url-migration-preview.png b/doc/res/url-migration-preview.png
new file mode 100644
index 0000000..759bfb1
--- /dev/null
+++ b/doc/res/url-migration-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..0792988
--- /dev/null
+++ b/library/Icingadb/Authentication/ObjectAuthorization.php
@@ -0,0 +1,259 @@
+<?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;
+ }
+
+ $inspectedRoles[] = $role->getName();
+ $roleName = $this->getDb()->quoteIdentifier($role->getName());
+
+ 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) {
+ if ($row->$alias) {
+ $rolesWithRestrictions[$alias] = true;
+ $roles[] = $alias;
+ }
+ }
+
+ 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..8bfc2a3
--- /dev/null
+++ b/library/Icingadb/Command/Object/DeleteCommentCommand.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+
+/**
+ * Delete a host or service comment
+ */
+class DeleteCommentCommand extends IcingaCommand
+{
+ use CommandAuthor;
+
+ /**
+ * Name of the comment
+ *
+ * @var string
+ */
+ protected $commentName;
+
+ /**
+ * Get the name of the comment
+ *
+ * @return string
+ */
+ public function getCommentName(): string
+ {
+ if ($this->commentName === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->commentName;
+ }
+
+ /**
+ * Set the name of the comment
+ *
+ * @param string $commentName
+ *
+ * @return $this
+ */
+ public function setCommentName(string $commentName): self
+ {
+ $this->commentName = $commentName;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Command/Object/DeleteDowntimeCommand.php b/library/Icingadb/Command/Object/DeleteDowntimeCommand.php
new file mode 100644
index 0000000..a0867fa
--- /dev/null
+++ b/library/Icingadb/Command/Object/DeleteDowntimeCommand.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+
+/**
+ * Delete a host or service downtime
+ */
+class DeleteDowntimeCommand extends IcingaCommand
+{
+ use CommandAuthor;
+
+ /**
+ * Name of the downtime (Icinga 2.4+)
+ *
+ * Required for removing the downtime via Icinga 2's API.
+ *
+ * @var string
+ */
+ protected $downtimeName;
+
+ /**
+ * Get the name of the downtime (Icinga 2.4+)
+ *
+ * Required for removing the downtime via Icinga 2's API.
+ *
+ * @return string
+ */
+ public function getDowntimeName(): string
+ {
+ if ($this->downtimeName === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->downtimeName;
+ }
+
+ /**
+ * Set the name of the downtime (Icinga 2.4+)
+ *
+ * Required for removing the downtime via Icinga 2's API.
+ *
+ * @param string $downtimeName
+ *
+ * @return $this
+ */
+ public function setDowntimeName(string $downtimeName): self
+ {
+ $this->downtimeName = $downtimeName;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Command/Object/GetObjectCommand.php b/library/Icingadb/Command/Object/GetObjectCommand.php
new file mode 100644
index 0000000..372a555
--- /dev/null
+++ b/library/Icingadb/Command/Object/GetObjectCommand.php
@@ -0,0 +1,71 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use LogicException;
+
+class GetObjectCommand extends ObjectCommand
+{
+ /** @var array */
+ protected $attributes;
+
+ /**
+ * Get the object name used in the Icinga 2 API
+ *
+ * @return string
+ */
+ public function getObjectName(): string
+ {
+ switch (true) {
+ case $this->object instanceof Service:
+ return $this->object->host->name . '!' . $this->object->name;
+ default:
+ return $this->object->name;
+ }
+ }
+
+ /**
+ * Get the sub-route of the endpoint for this object
+ *
+ * @return string
+ */
+ public function getObjectPluralType(): string
+ {
+ switch (true) {
+ case $this->object instanceof Host:
+ return 'hosts';
+ case $this->object instanceof Service:
+ return 'services';
+ default:
+ throw new LogicException(sprintf('Invalid object type %s provided', get_class($this->object)));
+ }
+ }
+
+ /**
+ * 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/ObjectCommand.php b/library/Icingadb/Command/Object/ObjectCommand.php
new file mode 100644
index 0000000..7dae799
--- /dev/null
+++ b/library/Icingadb/Command/Object/ObjectCommand.php
@@ -0,0 +1,51 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use ipl\Orm\Model;
+
+/**
+ * Base class for commands that involve a monitored object, i.e. a host or service
+ */
+abstract class ObjectCommand extends IcingaCommand
+{
+ /**
+ * Involved object
+ *
+ * @var Model
+ */
+ protected $object;
+
+ /**
+ * Set the involved object
+ *
+ * @param Model $object
+ *
+ * @return $this
+ */
+ public function setObject(Model $object): self
+ {
+ $this->object = $object;
+
+ return $this;
+ }
+
+ /**
+ * Get the involved object
+ *
+ * @return Model
+ */
+ public function getObject(): Model
+ {
+ if ($this->object === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->object;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ProcessCheckResultCommand.php b/library/Icingadb/Command/Object/ProcessCheckResultCommand.php
new file mode 100644
index 0000000..3d7e956
--- /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 ObjectCommand
+{
+ /**
+ * 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..49a22a3
--- /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 ObjectCommand
+{
+ use CommandAuthor;
+}
diff --git a/library/Icingadb/Command/Object/ScheduleCheckCommand.php b/library/Icingadb/Command/Object/ScheduleCheckCommand.php
new file mode 100644
index 0000000..c3f2097
--- /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 ObjectCommand
+{
+ /**
+ * 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..699a113
--- /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 ObjectCommand
+{
+ /**
+ * 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..299c998
--- /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 ObjectCommand
+{
+ 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..aa1cdbe
--- /dev/null
+++ b/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php
@@ -0,0 +1,322 @@
+<?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;
+
+/**
+ * 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 Model $object
+ *
+ * @return void
+ */
+ protected function applyFilter(array &$data, Model $object)
+ {
+ if ($object instanceof Host) {
+ $data['host'] = $object->name;
+ } else {
+ /** @var Service $object */
+ $data['service'] = sprintf('%s!%s', $object->host->name, $object->name);
+ }
+ }
+
+ /**
+ * 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
+ {
+ $endpoint = sprintf(
+ 'objects/%s/%s',
+ $command->getObjectPluralType(),
+ rawurlencode($command->getObjectName())
+ );
+
+ $data = [
+ 'all_joins' => 1,
+ 'attrs' => $command->getAttributes() ?: []
+ ];
+
+ 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->getObject());
+ 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->getObject());
+ 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->getObject());
+ 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->getObject());
+ 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->getObject());
+ 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->getObject());
+ 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/';
+ $object = $command->getObject();
+ if ($object instanceof Host) {
+ $endpoint .= 'hosts';
+ } else {
+ /** @var Service $object */
+ $endpoint .= 'services';
+ }
+
+ $data = [
+ 'attrs' => [
+ $attr => $command->getEnabled()
+ ]
+ ];
+
+ $this->applyFilter($data, $object);
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderDeleteComment(DeleteCommentCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/remove-comment';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comment' => $command->getCommentName()
+ ];
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderDeleteDowntime(DeleteDowntimeCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/remove-downtime';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'downtime' => $command->getDowntimeName()
+ ];
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderRemoveAcknowledgement(RemoveAcknowledgementCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/remove-acknowledgement';
+ $data = ['author' => $command->getAuthor()];
+
+ $this->applyFilter($data, $command->getObject());
+ 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..c415d62
--- /dev/null
+++ b/library/Icingadb/Common/Auth.php
@@ -0,0 +1,358 @@
+<?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;
+
+ $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 (! $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..10ddafe
--- /dev/null
+++ b/library/Icingadb/Common/BaseFilter.php
@@ -0,0 +1,50 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Stdlib\Filter\Rule;
+
+/**
+ * @deprecated Use {@see \ipl\Stdlib\BaseFilter} instead. This will be removed with version 1.1
+ */
+trait BaseFilter
+{
+ /** @var Rule Base filter */
+ private $baseFilter;
+
+ /**
+ * Get whether a base filter has been set
+ *
+ * @return bool
+ */
+ public function hasBaseFilter(): bool
+ {
+ return $this->baseFilter !== null;
+ }
+
+ /**
+ * Get the base filter
+ *
+ * @return ?Rule
+ */
+ public function getBaseFilter()
+ {
+ return $this->baseFilter;
+ }
+
+ /**
+ * Set the base filter
+ *
+ * @param Rule $baseFilter
+ *
+ * @return $this
+ */
+ public function setBaseFilter(Rule $baseFilter = null): self
+ {
+ $this->baseFilter = $baseFilter;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Common/BaseItemList.php b/library/Icingadb/Common/BaseItemList.php
new file mode 100644
index 0000000..7eacb28
--- /dev/null
+++ b/library/Icingadb/Common/BaseItemList.php
@@ -0,0 +1,99 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use InvalidArgumentException;
+use ipl\Html\BaseHtmlElement;
+
+/**
+ * Base class for item lists
+ */
+abstract class BaseItemList extends BaseHtmlElement
+{
+ use BaseFilter;
+ use DetailActions;
+
+ protected $baseAttributes = [
+ 'class' => 'item-list',
+ 'data-base-target' => '_next',
+ 'data-pdfexport-page-breaks-at' => '.list-item'
+ ];
+
+ /** @var iterable */
+ protected $data;
+
+ /** @var bool Whether the list contains at least one item with an icon_image */
+ protected $hasIconImages = false;
+
+ protected $tag = 'ul';
+
+ /**
+ * Create a new item list
+ *
+ * @param iterable $data Data source of the list
+ */
+ public function __construct($data)
+ {
+ if (! is_iterable($data)) {
+ throw new InvalidArgumentException('Data must be an array or an instance of Traversable');
+ }
+
+ $this->data = $data;
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->initializeDetailActions();
+ $this->init();
+ }
+
+ abstract protected function getItemClass(): string;
+
+ /**
+ * 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
+ */
+ public function setHasIconImages(bool $hasIconImages)
+ {
+ $this->hasIconImages = $hasIconImages;
+ }
+
+ /**
+ * Initialize the item list
+ *
+ * If you want to adjust the item list after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ protected function assemble()
+ {
+ $itemClass = $this->getItemClass();
+
+ foreach ($this->data as $data) {
+ /** @var BaseListItem|BaseTableRowItem $item */
+ $item = new $itemClass($data, $this);
+
+ $this->add($item);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->add(new EmptyState(t('No items found.')));
+ }
+ }
+}
diff --git a/library/Icingadb/Common/BaseListItem.php b/library/Icingadb/Common/BaseListItem.php
new file mode 100644
index 0000000..c552bb6
--- /dev/null
+++ b/library/Icingadb/Common/BaseListItem.php
@@ -0,0 +1,165 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Stdlib\Filter\Rule;
+use ipl\Web\Filter\QueryString;
+
+/**
+ * Base class for list items
+ */
+abstract class BaseListItem extends BaseHtmlElement
+{
+ protected $baseAttributes = ['class' => 'list-item'];
+
+ /** @var object The associated list item */
+ protected $item;
+
+ /** @var BaseItemList The list where the item is part of */
+ protected $list;
+
+ protected $tag = 'li';
+
+ /**
+ * Create a new list item
+ *
+ * @param object $item
+ * @param BaseItemList $list
+ */
+ public function __construct($item, BaseItemList $list)
+ {
+ $this->item = $item;
+ $this->list = $list;
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ abstract protected function assembleHeader(BaseHtmlElement $header);
+
+ abstract protected function assembleMain(BaseHtmlElement $main);
+
+ protected function assembleFooter(BaseHtmlElement $footer)
+ {
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption)
+ {
+ }
+
+ protected function assembleIconImage(BaseHtmlElement $iconImage)
+ {
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title)
+ {
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ }
+
+ protected function createCaption(): BaseHtmlElement
+ {
+ $caption = Html::tag('section', ['class' => 'caption']);
+
+ $this->assembleCaption($caption);
+
+ return $caption;
+ }
+
+ protected function createHeader(): BaseHtmlElement
+ {
+ $header = Html::tag('header');
+
+ $this->assembleHeader($header);
+
+ return $header;
+ }
+
+ protected function createMain(): BaseHtmlElement
+ {
+ $main = Html::tag('div', ['class' => 'main']);
+
+ $this->assembleMain($main);
+
+ return $main;
+ }
+
+ protected function createFooter(): BaseHtmlElement
+ {
+ $footer = new HtmlElement('footer');
+
+ $this->assembleFooter($footer);
+
+ return $footer;
+ }
+
+ /**
+ * @return ?BaseHtmlElement
+ */
+ protected function createIconImage()
+ {
+ if (! $this->list->hasIconImages()) {
+ return null;
+ }
+
+ $iconImage = HtmlElement::create('div', [
+ 'class' => 'icon-image',
+ ]);
+
+ $this->assembleIconImage($iconImage);
+
+ return $iconImage;
+ }
+
+ protected function createTimestamp()
+ {
+ }
+
+ protected function createTitle(): BaseHtmlElement
+ {
+ $title = HTML::tag('div', ['class' => 'title']);
+
+ $this->assembleTitle($title);
+
+ return $title;
+ }
+
+ /**
+ * @return ?BaseHtmlElement
+ */
+ protected function createVisual()
+ {
+ $visual = Html::tag('div', ['class' => 'visual']);
+
+ $this->assembleVisual($visual);
+
+ return $visual;
+ }
+
+ /**
+ * Initialize the list item
+ *
+ * If you want to adjust the list item after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ $this->createVisual(),
+ $this->createIconImage(),
+ $this->createMain()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Common/BaseOrderedItemList.php b/library/Icingadb/Common/BaseOrderedItemList.php
new file mode 100644
index 0000000..23ae7e9
--- /dev/null
+++ b/library/Icingadb/Common/BaseOrderedItemList.php
@@ -0,0 +1,34 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+
+/**
+ * @method BaseOrderedListItem getItemClass()
+ */
+abstract class BaseOrderedItemList extends BaseItemList
+{
+ protected $tag = 'ol';
+
+ protected function assemble()
+ {
+ $itemClass = $this->getItemClass();
+
+ $i = 0;
+ foreach ($this->data as $data) {
+ $item = new $itemClass($data, $this);
+ $item->setOrder($i++);
+
+ $this->add($item);
+ }
+
+ if ($this->isEmpty()) {
+ $this->setTag('div');
+ $this->add(new EmptyState(t('No items found.')));
+ }
+ }
+}
diff --git a/library/Icingadb/Common/BaseOrderedListItem.php b/library/Icingadb/Common/BaseOrderedListItem.php
new file mode 100644
index 0000000..bf0f2b2
--- /dev/null
+++ b/library/Icingadb/Common/BaseOrderedListItem.php
@@ -0,0 +1,41 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+abstract class BaseOrderedListItem extends BaseListItem
+{
+ /** @var int This element's position */
+ protected $order;
+
+ /**
+ * Set this element's position
+ *
+ * @param int $order
+ *
+ * @return $this
+ */
+ public function setOrder(int $order): self
+ {
+ $this->order = $order;
+
+ return $this;
+ }
+
+ /**
+ * Get this element's position
+ *
+ * @return int
+ */
+ public function getOrder()
+ {
+ if ($this->order === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->order;
+ }
+}
diff --git a/library/Icingadb/Common/BaseStatusBar.php b/library/Icingadb/Common/BaseStatusBar.php
new file mode 100644
index 0000000..7339eb2
--- /dev/null
+++ b/library/Icingadb/Common/BaseStatusBar.php
@@ -0,0 +1,45 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+abstract class BaseStatusBar extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ protected $summary;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'status-bar'];
+
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ abstract protected function assembleTotal(BaseHtmlElement $total);
+
+ abstract protected function createStateBadges(): BaseHtmlElement;
+
+ protected function createCount(): BaseHtmlElement
+ {
+ $total = Html::tag('span', ['class' => 'item-count']);
+
+ $this->assembleTotal($total);
+
+ return $total;
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ $this->createCount(),
+ $this->createStateBadges()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Common/BaseTableRowItem.php b/library/Icingadb/Common/BaseTableRowItem.php
new file mode 100644
index 0000000..d3e0036
--- /dev/null
+++ b/library/Icingadb/Common/BaseTableRowItem.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+
+abstract class BaseTableRowItem extends BaseHtmlElement
+{
+ protected $baseAttributes = ['class' => 'list-item'];
+
+ /** @var object The associated list item */
+ protected $item;
+
+ /** @var BaseItemList The list where the item is part of */
+ protected $list;
+
+ protected $tag = 'li';
+
+ /**
+ * Create a new table row item
+ *
+ * @param object $item
+ * @param BaseItemList $list
+ */
+ public function __construct($item, BaseItemList $list)
+ {
+ $this->item = $item;
+ $this->list = $list;
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ abstract protected function assembleColumns(HtmlDocument $columns);
+
+ abstract protected function assembleTitle(BaseHtmlElement $title);
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ }
+
+ protected function createColumn($content = null): BaseHtmlElement
+ {
+ return Html::tag('div', ['class' => 'col'], $content);
+ }
+
+ protected function createColumns(): HtmlDocument
+ {
+ $columns = new HtmlDocument();
+
+ $this->assembleColumns($columns);
+
+ return $columns;
+ }
+
+ protected function createTitle(): BaseHtmlElement
+ {
+ $title = $this->createColumn()->addAttributes(['class' => 'title']);
+
+ $this->assembleTitle($title);
+
+ return $title;
+ }
+
+ /**
+ * @return ?BaseHtmlElement
+ */
+ protected function createVisual()
+ {
+ $visual = new HtmlElement('div', Attributes::create(['class' => 'visual']));
+
+ $this->assembleVisual($visual);
+
+ return $visual->isEmpty() ? null : $visual;
+ }
+
+ /**
+ * Initialize the list item
+ *
+ * If you want to adjust the list item after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ $this->createVisual(),
+ $this->createTitle(),
+ $this->createColumns()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Common/CaptionDisabled.php b/library/Icingadb/Common/CaptionDisabled.php
new file mode 100644
index 0000000..26344c5
--- /dev/null
+++ b/library/Icingadb/Common/CaptionDisabled.php
@@ -0,0 +1,30 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait CaptionDisabled
+{
+ 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..3283964
--- /dev/null
+++ b/library/Icingadb/Common/CommandActions.php
@@ -0,0 +1,254 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use GuzzleHttp\Psr7\ServerRequest;
+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 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)
+ {
+ if (is_string($form)) {
+ /** @var \Icinga\Module\Icingadb\Forms\Command\CommandForm $form */
+ $form = new $form();
+ }
+
+ $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->setObjects($this->getCommandTargets());
+ $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(ServerRequest::fromGlobals());
+
+ $this->addContent($form);
+ }
+
+ 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();
+ 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..3b8e0e6
--- /dev/null
+++ b/library/Icingadb/Common/DetailActions.php
@@ -0,0 +1,140 @@
+<?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');
+ });
+
+ return $this;
+ }
+
+ /**
+ * Set the url to use for multiple selected list items
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ protected function setMultiselectUrl(Url $url): self
+ {
+ /** @var BaseHtmlElement $this */
+ $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
+ {
+ /** @var BaseHtmlElement $this */
+ $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()
+ ->set('data-action-item', true)
+ ->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..b3a9473
--- /dev/null
+++ b/library/Icingadb/Common/HostStates.php
@@ -0,0 +1,118 @@
+<?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 UNREACHABLE = 2;
+
+ 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 'unreachable':
+ $int = self::UNREACHABLE;
+ 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::UNREACHABLE:
+ $text = 'unreachable';
+ 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::UNREACHABLE:
+ $text = t('unreachable');
+ 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..9473b8c
--- /dev/null
+++ b/library/Icingadb/Common/IcingaRedis.php
@@ -0,0 +1,236 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Exception;
+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 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;
+ }
+
+ /**
+ * Get the last icinga heartbeat from redis
+ *
+ * @param Redis|null $redis
+ *
+ * @return ?float|int
+ */
+ 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
+ ] + static::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('redis');
+ }
+
+ 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
+ ] + static::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..4777c7c
--- /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)
+ {
+ $header->add($this->createTitle());
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main)
+ {
+ $main->add($this->createHeader());
+ if (! $this->isCaptionDisabled()) {
+ $main->add($this->createCaption());
+ }
+ }
+}
diff --git a/library/Icingadb/Common/ListItemDetailedLayout.php b/library/Icingadb/Common/ListItemDetailedLayout.php
new file mode 100644
index 0000000..3db91a3
--- /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)
+ {
+ $header->add($this->createTitle());
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main)
+ {
+ $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..9b0dc5b
--- /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)
+ {
+ $header->add($this->createTitle());
+ if (! $this->isCaptionDisabled()) {
+ $header->add($this->createCaption());
+ }
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main)
+ {
+ $main->add($this->createHeader());
+ }
+}
diff --git a/library/Icingadb/Common/LoadMore.php b/library/Icingadb/Common/LoadMore.php
new file mode 100644
index 0000000..c9ef0a2
--- /dev/null
+++ b/library/Icingadb/Common/LoadMore.php
@@ -0,0 +1,108 @@
+<?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'))
+ ->setAttribute('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..733c116
--- /dev/null
+++ b/library/Icingadb/Common/Macros.php
@@ -0,0 +1,118 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Logger;
+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 $object The host or service used to resolve the macros
+ *
+ * @return string
+ */
+ public function expandMacros(string $input, Model $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 $object The host or service used to resolve the macros
+ *
+ * @return string
+ */
+ public function resolveMacro(string $macro, Model $object): string
+ {
+ if ($object instanceof 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..87c9b52
--- /dev/null
+++ b/library/Icingadb/Common/ObjectInspectionDetail.php
@@ -0,0 +1,330 @@
+<?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\Module\Icingadb\Widget\EmptyState;
+use Icinga\Util\Format;
+use Icinga\Util\Json;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+
+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'
+ ];
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Executed Command'))),
+ new HtmlElement('pre', null, Text::create($command)),
+ 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)
+ {
+ switch (true) {
+ case $this->object instanceof Host:
+ return HostStates::text($state);
+ case $this->object instanceof Service:
+ return ServiceStates::text($state);
+ default:
+ return $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);
+ }
+ } 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..2cb5cc8
--- /dev/null
+++ b/library/Icingadb/Common/StateBadges.php
@@ -0,0 +1,185 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Widget\StateBadge;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+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 $content
+ * @param array $params
+ *
+ * @return Link
+ */
+ public function createLink($content, array $params = null): Link
+ {
+ $url = clone $this->getUrl();
+
+ if (! empty($params)) {
+ $url->getParams()->mergeValues($params);
+ }
+
+ if ($this->hasBaseFilter()) {
+ $url->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter())));
+ }
+
+ 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),
+ [$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),
+ [
+ $this->type . '.state.soft_state' => $this->getStateInt($state),
+ $this->type . '.state.is_handled' => 'n'
+ ]
+ ));
+ }
+
+ if (isset($this->item->$handledKey) && $this->item->$handledKey) {
+ $content[] = Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$handledKey, $state, true),
+ [
+ $this->type . '.state.soft_state' => $this->getStateInt($state),
+ $this->type . '.state.is_handled' => 'y'
+ ]
+ ));
+ }
+
+ 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..0494573
--- /dev/null
+++ b/library/Icingadb/Compat/CompatHost.php
@@ -0,0 +1,106 @@
+<?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'],
+ 'type' => 'bool'
+ ],
+ '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..d9d72a3
--- /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..8d95950
--- /dev/null
+++ b/library/Icingadb/Compat/CompatService.php
@@ -0,0 +1,159 @@
+<?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'],
+ 'type' => 'bool'
+ ],
+ '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..6eef3ca
--- /dev/null
+++ b/library/Icingadb/Compat/UrlMigrator.php
@@ -0,0 +1,1254 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Compat;
+
+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[$url->getPath()]);
+ }
+
+ 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($queryTransformer, $dbRoute) = self::SUPPORTED_PATHS[$url->getPath()];
+
+ $url = clone $url;
+ $url->setPath($dbRoute);
+
+ if (! $url->getParams()->isEmpty()) {
+ $filter = QueryString::parse((string) $url->getParams());
+ $filter = self::transformFilter($filter, $queryTransformer);
+ if ($filter) {
+ $url->setQueryString(QueryString::render($filter));
+ }
+ }
+
+ return $url;
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Rewrite the given filter and legacy columns
+ *
+ * @param Filter\Rule $filter
+ * @param array $legacyColumns
+ * @param Filter\Chain|null $parent
+ *
+ * @return ?mixed
+ */
+ protected function rewrite(Filter\Rule $filter, array $legacyColumns, Filter\Chain $parent = null)
+ {
+ $rewritten = null;
+ if ($filter instanceof Filter\Condition) {
+ $column = $filter->getColumn();
+
+ 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);
+ }
+ } elseif ($column === 'sort') {
+ $column = $filter->getValue();
+ if (isset($legacyColumns[$column])) {
+ if ($legacyColumns[$column] === self::DROP) {
+ return false;
+ } elseif (! is_array($legacyColumns[$column])) {
+ return $rewritten;
+ }
+
+ $column = key($legacyColumns[$column]);
+
+ $rewritten = $filter->setValue($column);
+ }
+
+ if ($parent !== null) {
+ foreach ($parent as $child) {
+ if ($child instanceof Filter\Condition && $child->getColumn() === 'dir') {
+ $dir = $child->getValue();
+
+ $rewritten = $filter->setValue("{$column} {$dir}");
+
+ $parent->remove($child);
+ }
+ }
+ }
+ } elseif ($column === 'dir') {
+ if ($parent !== null) {
+ foreach ($parent as $child) {
+ if ($child instanceof Filter\Condition && $child->getColumn() === 'sort') {
+ return null;
+ }
+ }
+ }
+
+ return false;
+ } elseif (preg_match('/^_(host|service)_([\w.]+)/i', $column, $groups)) {
+ $rewritten = $filter->setColumn($groups[1] . '.vars.' . $groups[2]);
+ }
+ } else {
+ /** @var Filter\Chain $filter */
+ foreach ($filter as $child) {
+ $retVal = $this->rewrite(
+ $child instanceof Filter\Condition ? clone $child : $child,
+ $legacyColumns,
+ $filter
+ );
+ if ($retVal === false) {
+ $filter->remove($child);
+ } elseif ($retVal instanceof Filter\Rule) {
+ $filter->replace($child, $retVal);
+ }
+ }
+ }
+
+ return $rewritten;
+ }
+
+ 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 hostsColumns(): array
+ {
+ return [
+
+ // Extraordinary columns
+ 'addColumns' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ $legacyColumns = array_filter(array_map('trim', explode(',', $filter->getValue())));
+
+ $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) {
+ if (($c = self::transformFilter(Filter::equal($column, 'bogus'), 'hosts')) !== false) {
+ if ($c instanceof Filter\Condition) {
+ $columns[] = $c->getColumn();
+ }
+ }
+ }
+
+ if (empty($columns)) {
+ return false;
+ }
+
+ return Filter::equal('columns', implode(',', $columns));
+ },
+
+ // 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 servicesColumns(): array
+ {
+ return [
+
+ // Extraordinary columns
+ 'addColumns' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ $legacyColumns = array_filter(array_map('trim', explode(',', $filter->getValue())));
+
+ $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) {
+ if (($c = self::transformFilter(Filter::equal($column, 'bogus'), 'services')) !== false) {
+ if ($c instanceof Filter\Condition) {
+ $columns[] = $c->getColumn();
+ }
+ }
+ }
+
+ if (empty($columns)) {
+ return false;
+ }
+
+ return Filter::equal('columns', implode(',', $columns));
+ },
+
+ // 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..24e34a6
--- /dev/null
+++ b/library/Icingadb/Data/CsvResultSet.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Data;
+
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\ResultSet;
+
+class CsvResultSet extends ResultSet
+{
+ protected $isCacheDisabled = true;
+
+ public function current()
+ {
+ return $this->extractKeysAndValues(parent::current());
+ }
+
+ protected function formatValue(string $key, ?string $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) . '"';
+ } 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..e0eb623
--- /dev/null
+++ b/library/Icingadb/Data/JsonResultSet.php
@@ -0,0 +1,70 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Data;
+
+use Icinga\Util\Json;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\ResultSet;
+
+class JsonResultSet extends ResultSet
+{
+ protected $isCacheDisabled = true;
+
+ public function current()
+ {
+ return $this->createObject(parent::current());
+ }
+
+ protected function formatValue(string $key, ?string $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);
+ }
+
+ 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..2819e11
--- /dev/null
+++ b/library/Icingadb/Data/PivotTable.php
@@ -0,0 +1,436 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Data;
+
+use Icinga\Application\Icinga;
+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
+ {
+ $request = Icinga::app()->getRequest();
+
+ $value = $request->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();
+
+ $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();
+ $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..2f8df33
--- /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:
+ /** @var HostActionsHook $hook */
+ $hookName = 'Icingadb\\HostActions';
+ break;
+ case $object instanceof Service:
+ /** @var ServiceActionsHook $hook */
+ $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']));
+ 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/CustomVarRendererHook.php b/library/Icingadb/Hook/CustomVarRendererHook.php
new file mode 100644
index 0000000..be3325b
--- /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, $object) {
+ $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..4f0881d
--- /dev/null
+++ b/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
@@ -0,0 +1,118 @@
+<?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:
+ /** @var HostDetailExtensionHook $hook */
+ $hookName = 'Icingadb\\HostDetailExtension';
+ break;
+ case $object instanceof Service:
+ /** @var ServiceDetailExtensionHook $hook */
+ $hookName = 'Icingadb\\ServiceDetailExtension';
+ break;
+ case $object instanceof User:
+ /** @var UserDetailExtensionHook $hook */
+ $hookName = 'Icingadb\\UserDetailExtension';
+ break;
+ case $object instanceof Usergroup:
+ /** @var UsergroupDetailExtensionHook $hook */
+ $hookName = 'Icingadb\\UsergroupDetailExtension';
+ break;
+ case $object instanceof History:
+ /** @var EventDetailExtensionHook $hook */
+ $hookName = 'Icingadb\\EventDetailExtension';
+ break;
+ default:
+ throw new InvalidArgumentException(
+ sprintf('%s is not a supported object type', get_php_type($object))
+ );
+ }
+
+ $extensions = [];
+ $lastUsedLocations = [];
+ 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..5fe7c6c
--- /dev/null
+++ b/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php
@@ -0,0 +1,102 @@
+<?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\Common\BaseFilter;
+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\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':
+ /** @var HostsDetailExtensionHook $hook */
+ $hookName = 'Icingadb\\HostsDetailExtension';
+ break;
+ case 'service':
+ /** @var ServicesDetailExtensionHook $hook */
+ $hookName = 'Icingadb\\ServicesDetailExtension';
+ break;
+ default:
+ throw new InvalidArgumentException(
+ sprintf('%s is not a supported object type', $objectType)
+ );
+ }
+
+ $extensions = [];
+ $lastUsedLocations = [];
+ 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..cd43a5a
--- /dev/null
+++ b/library/Icingadb/Hook/IcingadbSupportHook.php
@@ -0,0 +1,50 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Application\Icinga;
+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 disabled or icingadb is selected as backend, false otherwise.
+ */
+ final public static function useIcingaDbAsBackend(): bool
+ {
+ return ! Icinga::app()->getModuleManager()->hasEnabled('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..409e4b0
--- /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 Icinga\Module\Icingadb\Model\Behavior\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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 Timestamp([
+ '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/Behavior/Timestamp.php b/library/Icingadb/Model/Behavior/Timestamp.php
new file mode 100644
index 0000000..b365491
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/Timestamp.php
@@ -0,0 +1,37 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+
+class Timestamp extends PropertyBehavior
+{
+ public function fromDb($value, $key, $_)
+ {
+ if ($value === null) {
+ return $value;
+ }
+
+ return $value / 1000.0;
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if ($value === null) {
+ return $value;
+ }
+
+ if (is_string($value) && ! ctype_digit($value)) {
+ $timestamp = strtotime($value);
+ if ($timestamp === false) {
+ return $value;
+ } else {
+ $value = $timestamp;
+ }
+ }
+
+ return $value * 1000.0;
+ }
+}
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..d59d4e3
--- /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..d55506e
--- /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..cbcb926
--- /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..9cea1ce
--- /dev/null
+++ b/library/Icingadb/Model/Comment.php
@@ -0,0 +1,120 @@
+<?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 Icinga\Module\Icingadb\Model\Behavior\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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 Timestamp([
+ '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('host_state', HostState::class)
+ ->setForeignKey('last_comment_id')
+ ->setCandidateKey('id');
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ $relations->belongsTo('service_state', ServiceState::class)
+ ->setForeignKey('last_comment_id')
+ ->setCandidateKey('id');
+ $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..8428a15
--- /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 Icinga\Module\Icingadb\Model\Behavior\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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 Timestamp([
+ '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..64e3a79
--- /dev/null
+++ b/library/Icingadb/Model/CustomvarFlat.php
@@ -0,0 +1,122 @@
+<?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;
+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, $path, $value) use (&$registerValue) {
+ $step = array_shift($path);
+ $pos = null;
+ if (preg_match('/\[(\d+)]$/', $step, $m)) {
+ $step = substr($step, 0, -strlen($m[0]));
+ array_unshift($path, $m[1]);
+ }
+
+ if (! empty($path)) {
+ if (! isset($data[$step])) {
+ $data[$step] = [];
+ }
+
+ $registerValue($data[$step], $path, $value);
+ } else {
+ $data[$step] = $value;
+ }
+ };
+
+ $vars = [];
+ foreach ($flattenedVars as $var) {
+ $path = explode('.', $var->flatname);
+ $registerValue($vars, $path, $var->flatvalue);
+ }
+
+ return $vars;
+ }
+}
diff --git a/library/Icingadb/Model/Downtime.php b/library/Icingadb/Model/Downtime.php
new file mode 100644
index 0000000..07112c5
--- /dev/null
+++ b/library/Icingadb/Model/Downtime.php
@@ -0,0 +1,147 @@
+<?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 Icinga\Module\Icingadb\Model\Behavior\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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 Timestamp([
+ 'entry_time',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'scheduled_duration',
+ 'flexible_duration',
+ 'start_time',
+ 'end_time',
+ 'duration'
+ ]));
+
+ $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..ee4f973
--- /dev/null
+++ b/library/Icingadb/Model/DowntimeHistory.php
@@ -0,0 +1,129 @@
+<?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\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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 Timestamp([
+ 'entry_time',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'flexible_duration',
+ '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..377d2ca
--- /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 Icinga\Module\Icingadb\Model\Behavior\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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 Timestamp([
+ '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..74288d3
--- /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 Icinga\Module\Icingadb\Model\Behavior\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Timestamp([
+ '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..a76cb36
--- /dev/null
+++ b/library/Icingadb/Model/Host.php
@@ -0,0 +1,233 @@
+<?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'];
+ }
+
+ 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');
+ $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..0c83417
--- /dev/null
+++ b/library/Icingadb/Model/HostState.php
@@ -0,0 +1,89 @@
+<?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', Comment::class)
+ ->setCandidateKey('last_comment_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ }
+
+ /**
+ * Get the host state as the textual representation
+ *
+ * @return string
+ */
+ public function getStateText(): string
+ {
+ return HostStates::text($this->soft_state);
+ }
+
+ /**
+ * Get the host state as the translated textual representation
+ *
+ * @return string
+ */
+ 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..1dfae31
--- /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'];
+ }
+
+ 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..6e93df5
--- /dev/null
+++ b/library/Icingadb/Model/Hostgroupsummary.php
@@ -0,0 +1,208 @@
+<?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\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_down_unhandled' => new Expression(
+ 'SUM(CASE WHEN host_state = 1 AND host_handled = \'n\' 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_unreachable' => new Expression(
+ 'SUM(CASE WHEN host_state = 2 THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_unreachable_handled' => new Expression(
+ 'SUM(CASE WHEN host_state = 2 AND host_handled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_unreachable_unhandled' => new Expression(
+ 'SUM(CASE WHEN host_state = 2 AND host_handled = \'n\' 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\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2 AND service_handled = \'n\' 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\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3 AND service_handled = \'n\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1 AND service_handled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1 AND service_handled = \'n\' 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_severity' => 'state.severity',
+ 'service_id' => new Expression('NULL'),
+ 'service_state' => new Expression('NULL'),
+ 'service_handled' => 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_severity' => new Expression('0'),
+ 'service_id' => 'service.id',
+ 'service_state' => 'state.soft_state',
+ 'service_handled' => 'state.is_handled'
+ ]
+ ],
+ [
+ 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_severity' => new Expression('0'),
+ 'service_id' => new Expression('NULL'),
+ 'service_state' => new Expression('NULL'),
+ 'service_handled' => 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..e437c19
--- /dev/null
+++ b/library/Icingadb/Model/HoststateSummary.php
@@ -0,0 +1,89 @@
+<?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\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_down_unhandled' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 1'
+ . ' AND host_state.is_handled = \'n\' 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_unreachable' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 2 THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_unreachable_handled' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 2'
+ . ' AND host_state.is_handled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_unreachable_unhandled' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 2'
+ . ' AND host_state.is_handled = \'n\' 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..23826d0
--- /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 Icinga\Module\Icingadb\Model\Behavior\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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 Timestamp([
+ '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/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..de7b493
--- /dev/null
+++ b/library/Icingadb/Model/NotificationHistory.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\ReRoute;
+use Icinga\Module\Icingadb\Model\Behavior\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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 Timestamp([
+ '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->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..74eda0e
--- /dev/null
+++ b/library/Icingadb/Model/Service.php
@@ -0,0 +1,224 @@
+<?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'];
+ }
+
+ 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');
+ $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..9de2a21
--- /dev/null
+++ b/library/Icingadb/Model/ServiceState.php
@@ -0,0 +1,86 @@
+<?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', Comment::class)
+ ->setCandidateKey('last_comment_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ }
+
+ /**
+ * Get the host state as the textual representation
+ *
+ * @return string
+ */
+ public function getStateText(): string
+ {
+ return ServiceStates::text($this->soft_state);
+ }
+
+ /**
+ * Get the host state as the translated textual representation
+ *
+ * @return string
+ */
+ 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..34f67bd
--- /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'];
+ }
+
+ 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..b43de09
--- /dev/null
+++ b/library/Icingadb/Model/ServicegroupSummary.php
@@ -0,0 +1,159 @@
+<?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\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2 AND service_handled = \'n\' 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\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3 AND service_handled = \'n\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1 AND service_handled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1 AND service_handled = \'n\' 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_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_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..03a012c
--- /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\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 2'
+ . ' AND service_state.is_handled = \'n\' 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\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 3'
+ . ' AND service_state.is_handled = \'n\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_handled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 1'
+ . ' AND service_state.is_handled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 1'
+ . ' AND service_state.is_handled = \'n\' 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..acd3769
--- /dev/null
+++ b/library/Icingadb/Model/State.php
@@ -0,0 +1,83 @@
+<?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\Timestamp;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+
+/**
+ * Base class for the {@link HostState} and {@link ServiceState} models providing common columns.
+ */
+abstract class State extends Model
+{
+ 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 Timestamp([
+ 'execution_time',
+ 'latency',
+ 'last_update',
+ 'last_state_change',
+ 'next_check',
+ 'next_update'
+ ]));
+
+ $behaviors->add(new Binary([
+ $this->getKeyName(),
+ 'environment_id',
+ 'acknowledgement_comment_id',
+ 'last_comment_id'
+ ]));
+ }
+}
diff --git a/library/Icingadb/Model/StateHistory.php b/library/Icingadb/Model/StateHistory.php
new file mode 100644
index 0000000..a4ae3d3
--- /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 Icinga\Module\Icingadb\Model\Behavior\Timestamp;
+use ipl\Orm\Behavior\Binary;
+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 Timestamp([
+ '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..e894e0f
--- /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'];
+ }
+
+ 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..7034770
--- /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'];
+ }
+
+ 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..1b6f8c1
--- /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 < 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 > $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,
+ 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/IcingaHealth.php b/library/Icingadb/ProvidedHook/IcingaHealth.php
new file mode 100644
index 0000000..863c207
--- /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 < 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,
+ '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,
+ '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..9e71154
--- /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 < time() - 60;
+ if (! $outdatedDbHeartbeat || $instance->heartbeat <= $lastIcingaHeartbeat) {
+ $this->setState(self::STATE_OK);
+ $this->setMessage(t('Icinga Redis available and up to date.'));
+ } elseif ($instance->heartbeat > $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..b5898fd
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/SlaReport.php
@@ -0,0 +1,279 @@
+<?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\Icingadb\Widget\EmptyState;
+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 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;
+
+ if (isset($config['breakdown']) && $config['breakdown'] !== 'none') {
+ switch ($config['breakdown']) {
+ 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'),
+ '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'
+ ]);
+ }
+
+ 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/X509/Sni.php b/library/Icingadb/ProvidedHook/X509/Sni.php
new file mode 100644
index 0000000..d6e1415
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/X509/Sni.php
@@ -0,0 +1,57 @@
+<?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
+ {
+ $queryHost = Host::on($this->getDb());
+
+ $queryHost->getSelectBase();
+
+ $hostStatusCols = [
+ 'host_name' => 'name',
+ 'host_address' => 'address',
+ 'host_address6' => 'address6'
+ ];
+
+ $queryHost = $queryHost->columns($hostStatusCols);
+
+ $this->applyRestrictions($queryHost);
+
+ if ($filter !== null) {
+ $queryString = $filter->toQueryString();
+ $filterCondition = QueryString::parse($queryString);
+ $queryHost->filter($filterCondition);
+ }
+
+ $hosts = $this->getdb()->select($queryHost->assembleSelect());
+
+ 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..186022a
--- /dev/null
+++ b/library/Icingadb/Redis/VolatileStateResults.php
@@ -0,0 +1,164 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Redis;
+
+use Exception;
+use Generator;
+use Icinga\Application\Benchmark;
+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 Predis\Client;
+use RuntimeException;
+
+class VolatileStateResults extends ResultSet
+{
+ /** @var Resolver */
+ private $resolver;
+
+ /** @var Client */
+ private $redis;
+
+ /** @var bool Whether Redis updates were applied */
+ private $updatesApplied = false;
+
+ public static function fromQuery(Query $query)
+ {
+ $self = parent::fromQuery($query);
+ $self->resolver = $query->getResolver();
+
+ try {
+ $self->redis = IcingaRedis::instance()->getConnection();
+ } catch (Exception $e) {
+ // The error has already been logged
+ }
+
+ return $self;
+ }
+
+ /**
+ * Get whether Redis is unavailable
+ *
+ * @return bool
+ */
+ public function isRedisUnavailable(): bool
+ {
+ return $this->redis === null;
+ }
+
+ public function current()
+ {
+ if ($this->redis && ! $this->updatesApplied && ! $this->isCacheDisabled) {
+ $this->rewind();
+ }
+
+ return parent::current();
+ }
+
+ public function key(): int
+ {
+ if ($this->redis && ! $this->updatesApplied && ! $this->isCacheDisabled) {
+ $this->rewind();
+ }
+
+ return parent::key();
+ }
+
+ public function rewind(): void
+ {
+ if ($this->redis && ! $this->updatesApplied && ! $this->isCacheDisabled) {
+ $this->updatesApplied = true;
+ $this->advance();
+
+ Benchmark::measure('Applying Redis updates');
+ $this->applyRedisUpdates();
+ Benchmark::measure('Redis updates applied');
+ }
+
+ parent::rewind();
+ }
+
+ protected function applyRedisUpdates()
+ {
+ $type = null;
+ $behaviors = null;
+
+ $keys = [];
+ $hostStateKeys = [];
+
+ $states = [];
+ $hostStates = [];
+ foreach ($this 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 ($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;
+ }
+
+ foreach ($this->fetchStates("icinga:{$type}:state", array_keys($states), $keys) as $id => $data) {
+ foreach ($data as $key => $value) {
+ $data[$key] = $behaviors->retrieveProperty($value, $key);
+ }
+
+ $states[$id]->setProperties($data);
+ }
+
+ if ($type === 'service' && ! empty($hostStates)) {
+ foreach ($this->fetchStates('icinga:host:state', array_keys($hostStates), $hostStateKeys) as $id => $data) {
+ foreach ($data as $key => $value) {
+ $data[$key] = $behaviors->retrieveProperty($value, $key);
+ }
+
+ $hostStates[$id]->setProperties($data);
+ }
+ }
+ }
+
+ protected function fetchStates(string $key, array $ids, array $keys): Generator
+ {
+ $results = $this->redis->hmget($key, $ids);
+ foreach ($results as $i => $json) {
+ if ($json !== null) {
+ $data = json_decode($json, true);
+ $keyMap = array_fill_keys($keys, 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';
+
+ yield $ids[$i] => array_intersect_key(array_merge($keyMap, $data), $keyMap);
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Setup/ApiTransportPage.php b/library/Icingadb/Setup/ApiTransportPage.php
new file mode 100644
index 0000000..310933a
--- /dev/null
+++ b/library/Icingadb/Setup/ApiTransportPage.php
@@ -0,0 +1,127 @@
+<?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')
+ ]);
+ }
+
+ 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..2ad514a
--- /dev/null
+++ b/library/Icingadb/Setup/IcingaDbWizard.php
@@ -0,0 +1,86 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Module\Setup\Forms\SummaryPage;
+use Icinga\Module\Setup\Requirement\PhpModuleRequirement;
+use Icinga\Module\Setup\Requirement\PhpVersionRequirement;
+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();
+
+ $set->add(new PhpVersionRequirement([
+ 'condition' => ['>=', '7.2'],
+ 'description' => sprintf(t('Icinga DB Web requires PHP version %s.'), '7.2')
+ ]));
+
+ $set->add(new WebLibraryRequirement([
+ 'condition' => ['icinga-php-library', '>=', '0.9.0'],
+ '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', '>=', '0.11.0'],
+ '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' => '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..2d83c54
--- /dev/null
+++ b/library/Icingadb/Util/PerfData.php
@@ -0,0 +1,642 @@
+<?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;
+
+ /**
+ * 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) && isset($this->maxValue) && isset($this->value);
+ }
+
+ /**
+ * 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('@^(-?(?:\d+)?(?:\.\d+)?)([a-zA-Z%°]{1,3})$@u', $parts[0], $matches)) {
+ $this->unit = $matches[2];
+ $this->value = $matches[1];
+ } else {
+ $this->value = $parts[0];
+ }
+
+ switch (count($parts)) {
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 5:
+ if ($parts[4] !== '') {
+ $this->maxValue = $parts[4];
+ }
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 4:
+ if ($parts[3] !== '') {
+ $this->minValue = $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->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 array(
+ 'label' => $this->getLabel(),
+ 'value' => $this->format($this->getvalue()),
+ 'min' => isset($this->minValue) && !$this->isPercentage()
+ ? $this->format($this->minValue)
+ : '',
+ 'max' => isset($this->maxValue) && !$this->isPercentage()
+ ? $this->format($this->maxValue)
+ : '',
+ '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;
+ }
+}
diff --git a/library/Icingadb/Util/PerfDataFormat.php b/library/Icingadb/Util/PerfDataFormat.php
new file mode 100644
index 0000000..8c33ae5
--- /dev/null
+++ b/library/Icingadb/Util/PerfDataFormat.php
@@ -0,0 +1,172 @@
+<?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($value, array &$units, int $base): string
+ {
+ $sign = '';
+ $value = (float) $value;
+ 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..6a18d99
--- /dev/null
+++ b/library/Icingadb/Util/PerfDataSet.php
@@ -0,0 +1,159 @@
+<?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
+ *
+ * @return string
+ */
+ protected function readUntil(string $stopChar): string
+ {
+ $start = $this->parserPos;
+ while ($this->parserPos < strlen($this->perfdataStr) && $this->perfdataStr[$this->parserPos] !== $stopChar) {
+ $this->parserPos++;
+ }
+
+ 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..c92842d
--- /dev/null
+++ b/library/Icingadb/Util/ThresholdRange.php
@@ -0,0 +1,180 @@
+<?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;
+
+ /**
+ * 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 = 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:
+ $min = floatval($min);
+ }
+
+ $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 the textual representation of $this, suitable for fromString()
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return (string) $this->raw;
+ }
+}
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..c4bdb6d
--- /dev/null
+++ b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
@@ -0,0 +1,400 @@
+<?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);
+
+ $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;
+ foreach ($this->getDb()->select($this->queryCustomvarConfig($searchTerm)) as $customVar) {
+ $search = $name = $customVar->flatname;
+ if (preg_match('/\w+\[(\d+)]$/', $search, $matches)) {
+ // array vars need to be specifically handled
+ if ($matches[1] !== '0') {
+ continue;
+ }
+
+ $name = substr($search, 0, -3);
+ $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, []);
+ }
+
+ foreach ($models as $path => $targetModel) {
+ /** @var Model $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 */
+ $isHasOne = $relation instanceof HasOne;
+ if (empty($path) || $name === 'state' || $name === 'last_comment') {
+ $relationPath = [$name];
+ if ($isHasOne && 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..669bc32
--- /dev/null
+++ b/library/Icingadb/Web/Control/ViewModeSwitcher.php
@@ -0,0 +1,203 @@
+<?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) {
+ 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');
+ }
+
+ return $viewMode === $this->getViewMode() ? $active : $inactive;
+ });
+
+ $this->addHtml($input, $label);
+ }
+ }
+}
diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php
new file mode 100644
index 0000000..08d69c9
--- /dev/null
+++ b/library/Icingadb/Web/Controller.php
@@ -0,0 +1,566 @@
+<?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\Data\ConfigObject;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use Icinga\Module\Icingadb\Common\SearchControls;
+use Icinga\Module\Icingadb\Data\CsvResultSet;
+use Icinga\Module\Icingadb\Data\JsonResultSet;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ItemTable\BaseItemTable;
+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\Contract\Paginatable;
+use ipl\Stdlib\Filter;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\PaginationControl;
+use ipl\Web\Control\SortControl;
+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;
+ }
+
+ public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwitcher)
+ {
+ // All of that is essentially what `ColumnControl::apply()` should do
+ $columnsDef = $this->params->shift('columns');
+ if (! $columnsDef) {
+ return null;
+ }
+
+ $columns = [];
+ foreach (explode(',', $columnsDef) as $column) {
+ if ($column = trim($column)) {
+ $columns[] = $column;
+ }
+ }
+
+ $query->withColumns($columns);
+
+ if (! $this->getRequest()->getUrl()->hasParam($viewModeSwitcher->getViewModeParam())) {
+ $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 LimitControl
+ *
+ * This automatically shifts the limit URL parameter from {@link $params}.
+ *
+ * @return LimitControl
+ */
+ public function createLimitControl(): LimitControl
+ {
+ $limitControl = new LimitControl(Url::fromRequest());
+ $limitControl->setDefaultLimit($this->getPageSize(null));
+
+ $this->params->shift($limitControl->getLimitParam());
+
+ return $limitControl;
+ }
+
+ /**
+ * Create and return the PaginationControl
+ *
+ * This automatically shifts the pagination URL parameters from {@link $params}.
+ *
+ * @return PaginationControl
+ */
+ public function createPaginationControl(Paginatable $paginatable): PaginationControl
+ {
+ $paginationControl = new PaginationControl($paginatable, Url::fromRequest());
+ $paginationControl->setDefaultPageSize($this->getPageSize(null));
+ $paginationControl->setAttribute('id', $this->getRequest()->protectId('pagination-control'));
+
+ $this->params->shift($paginationControl->getPageParam());
+ $this->params->shift($paginationControl->getPageSizeParam());
+
+ return $paginationControl->apply();
+ }
+
+ /**
+ * Create and return the SortControl
+ *
+ * This automatically shifts the sort URL parameter from {@link $params}.
+ *
+ * @param Query $query
+ * @param array $columns Possible sort columns as sort string-label pairs
+ *
+ * @return SortControl
+ */
+ public function createSortControl(Query $query, array $columns): SortControl
+ {
+ $sortControl = SortControl::create($columns);
+
+ $this->params->shift($sortControl->getSortParam());
+
+ return $sortControl->apply($query);
+ }
+
+ /**
+ * 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
+ */
+ public function createViewModeSwitcher(
+ PaginationControl $paginationControl,
+ LimitControl $limitControl,
+ bool $verticalPagination = false
+ ): ViewModeSwitcher {
+ $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') {
+ $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') {
+ $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());
+
+ if ($viewModeSwitcher->getViewMode() === 'minimal') {
+ $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
+ *
+ * @return void
+ */
+ public function handleSearchRequest(Query $query)
+ {
+ $q = trim($this->params->shift('q', ''), ' *');
+ if (! $q) {
+ return;
+ }
+
+ $filter = Filter::any();
+ $this->prepareSearchFilter($query, $q, $filter);
+
+ $redirectUrl = Url::fromRequest();
+ $redirectUrl->setQueryString(QueryString::render($filter));
+ foreach ($this->params->toArray(false) as $name => $value) {
+ $redirectUrl->getParams()->addEncoded($name, $value);
+ }
+
+ $this->getResponse()->redirectAndExit($redirectUrl);
+ }
+
+ /**
+ * Prepare the given search filter
+ *
+ * @param Query $query
+ * @param string $search
+ * @param Filter\Any $filter
+ *
+ * @return void
+ */
+ protected function prepareSearchFilter(Query $query, string $search, Filter\Any $filter)
+ {
+ foreach ($query->getModel()->getSearchColumns() 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) {
+ $this->content->getAttributes()->add('class', 'full-width');
+ } elseif ($content instanceof BaseItemTable) {
+ $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()
+ {
+ Icinga::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..bebc6be
--- /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\Module\Icingadb\Widget\StateBadge;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\Navigation\Renderer\NavigationItemRenderer;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\Link;
+
+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..51bcb63
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CheckStatistics.php
@@ -0,0 +1,204 @@
+<?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\Module\Icingadb\Widget\EmptyState;
+use Icinga\Util\Format;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Common\Card;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Web\Widget\TimeSince;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Web\Widget\VerticalKeyValue;
+
+class CheckStatistics extends Card
+{
+ protected $object;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'progress-bar check-statistics'];
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ }
+
+ protected function assembleBody(BaseHtmlElement $body)
+ {
+ $hPadding = 10;
+ $durationScale = 80;
+
+ $timeline = Html::tag('div', ['class' => 'check-timeline timeline']);
+
+ $overdueBar = null;
+ $nextCheckTime = $this->object->state->next_check;
+ if ($this->object->state->is_overdue) {
+ $nextCheckTime = $this->object->state->next_update;
+ $leftNow = $durationScale + $hPadding / 2;
+
+ $overdueScale = ($durationScale / 2) * (time() - $nextCheckTime) / (10 * $this->object->check_interval);
+ if ($overdueScale > $durationScale / 2) {
+ $overdueScale = $durationScale / 2;
+ }
+
+ $durationScale -= $overdueScale;
+ $overdueBar = Html::tag('div', [
+ 'class' => 'timeline-overlay check-overdue',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $durationScale,
+ $overdueScale + $hPadding / 2
+ )
+ ]);
+ } else {
+ $leftNow = $durationScale * (1 - ($nextCheckTime - time()) / $this->object->check_interval);
+ if ($leftNow > $durationScale) {
+ $leftNow = $durationScale;
+ } elseif ($leftNow < 0) {
+ $leftNow = 0;
+ }
+ }
+
+ $above = Html::tag('ul', ['class' => 'above']);
+ $now = Html::tag(
+ 'li',
+ [
+ 'class' => 'now positioned',
+ 'style' => sprintf('left: %F%%', $hPadding + $leftNow)
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ Html::tag(
+ 'strong',
+ t('Now')
+ )
+ )
+ );
+ $above->add($now);
+
+ $markerLast = Html::tag('div', [
+ 'class' => 'marker start',
+ 'style' => 'left: ' . $hPadding . '%',
+ 'title' => $this->object->state->last_update !== null
+ ? DateFormatter::formatDateTime($this->object->state->last_update)
+ : null
+ ]);
+ $markerNext = Html::tag('div', [
+ 'class' => 'marker end',
+ 'style' => sprintf('left: %F%%', $hPadding + $durationScale),
+ 'title' => $nextCheckTime !== null ? DateFormatter::formatDateTime($nextCheckTime) : null
+ ]);
+ $markerNow = Html::tag('div', [
+ 'class' => 'marker now',
+ 'style' => sprintf('left: %F%%', $hPadding + $leftNow),
+ ]);
+
+ $timeline->add([
+ $markerLast,
+ $markerNow,
+ $markerNext,
+ $overdueBar
+ ]);
+
+ $lastUpdate = Html::tag(
+ 'li',
+ ['class' => 'start'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('Last update'), $this->object->state->last_update !== null
+ ? new TimeAgo($this->object->state->last_update)
+ : t('PENDING'))
+ )
+ );
+ $interval = Html::tag(
+ 'li',
+ ['class' => 'interval'],
+ new VerticalKeyValue(
+ t('Interval'),
+ $this->object->check_interval
+ ? Format::seconds($this->object->check_interval)
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ );
+ $nextCheck = Html::tag(
+ 'li',
+ ['class' => 'end'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ $this->object->state->is_overdue
+ ? new VerticalKeyValue(t('Overdue'), new TimeSince($nextCheckTime))
+ : new VerticalKeyValue(
+ t('Next Check'),
+ $nextCheckTime !== null ? new TimeUntil($nextCheckTime) : t('PENDING')
+ )
+ )
+ );
+
+ $below = Html::tag(
+ 'ul',
+ [
+ 'class' => 'below',
+ 'style' => sprintf('width: %F%%;', $durationScale)
+ ]
+ );
+ $below->add([
+ $lastUpdate,
+ $interval,
+ $nextCheck
+ ]);
+
+ $body->add([$above, $timeline, $below]);
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer)
+ {
+ $footer->add(new HorizontalKeyValue(
+ t('Scheduling Source') . ':',
+ $this->object->state->scheduling_source ?? (new EmptyState(t('n. a.')))->setTag('span')
+ ));
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $checkSource = (new EmptyState(t('n. a.')))->setTag('span');
+ if ($this->object->state->check_source) {
+ $checkSource = [
+ new StateBall($this->object->state->is_reachable ? 'up' : 'down', StateBall::SIZE_MEDIUM),
+ ' ',
+ $this->object->state->check_source
+ ];
+ }
+
+ $header->add([
+ new VerticalKeyValue(t('Command'), $this->object->checkcommand_name),
+ new VerticalKeyValue(
+ t('Attempts'),
+ new CheckAttempt((int) $this->object->state->check_attempt, (int) $this->object->max_check_attempts)
+ ),
+ new VerticalKeyValue(t('Check Source'), $checkSource),
+ new VerticalKeyValue(
+ t('Execution time'),
+ $this->object->state->execution_time
+ ? Format::seconds($this->object->state->execution_time)
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ ),
+ new VerticalKeyValue(
+ t('Latency'),
+ $this->object->state->latency
+ ? Format::seconds($this->object->state->latency)
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/CommentDetail.php b/library/Icingadb/Widget/Detail/CommentDetail.php
new file mode 100644
index 0000000..eb13523
--- /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)
+ );
+ $details[] = new HorizontalKeyValue(t('Expires'), $this->comment->expire_time != 0
+ ? DateFormatter::formatDateTime($this->comment->expire_time)
+ : t('Never'));
+ } else {
+ if ($this->comment->expire_time != 0) {
+ $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)
+ )
+ );
+ }
+
+ 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->setQueryString(QueryString::render(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..9d3e06b
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CustomVarTable.php
@@ -0,0 +1,267 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\CustomVarRendererHook;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+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\Icon;
+
+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..68cc922
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/DowntimeCard.php
@@ -0,0 +1,336 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Model\Downtime;
+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';
+
+ public function __construct(Downtime $downtime)
+ {
+ $this->downtime = $downtime;
+
+ $this->start = $this->downtime->scheduled_start_time;
+ $this->end = $this->downtime->scheduled_end_time;
+
+ if ($this->downtime->end_time > $this->downtime->scheduled_end_time) {
+ $this->duration = $this->downtime->end_time - $this->downtime->scheduled_start_time;
+ } else {
+ $this->duration = $this->downtime->scheduled_end_time - $this->downtime->scheduled_start_time;
+ }
+ }
+
+ protected function assemble()
+ {
+ $timeline = Html::tag('div', ['class' => 'downtime-timeline timeline']);
+ $hPadding = 10;
+
+ $above = Html::tag('ul', ['class' => 'above']);
+ $below = Html::tag('ul', ['class' => 'below']);
+
+ $flexProgress = null;
+ $markerFlexStart = null;
+ $markerFlexEnd = null;
+
+ if ($this->downtime->scheduled_end_time < time()) {
+ $endTime = new TimeAgo($this->downtime->scheduled_end_time);
+ } else {
+ $endTime = new TimeUntil($this->downtime->scheduled_end_time);
+ }
+
+ if ($this->downtime->is_flexible && $this->downtime->is_in_effect) {
+ $this->addAttributes(['class' => 'flexible in-effect']);
+
+ $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->downtime->start_time);
+ $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->downtime->end_time);
+
+ $evade = false;
+ if ($flexEndLeft - $flexStartLeft < 2) {
+ $flexStartLeft -= 1;
+ $flexEndLeft += 1;
+
+ if ($flexEndLeft > $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)) {
+ $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time) - .5;
+ $flexStartLeft = $flexEndLeft - 2;
+ }
+
+ if ($flexStartLeft < $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time)) {
+ $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time) + .5;
+ $flexEndLeft = $flexStartLeft + 2;
+ }
+
+ $evade = true;
+ }
+
+ $markerFlexStart = Html::tag('div', [
+ 'class' => 'marker flex-start',
+ 'style' => sprintf('left: %F%%', $flexStartLeft)
+ ]);
+
+ $markerFlexEnd = Html::tag('div', [
+ 'class' => 'marker flex-end',
+ 'style' => sprintf('left: %F%%', $flexEndLeft)
+ ]);
+
+ if (time() > $this->downtime->scheduled_end_time) {
+ $timelineProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-elapsed',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $this->calcRelativeLeft($this->downtime->start_time),
+ $this->calcRelativeLeft($this->downtime->scheduled_end_time, $this->downtime->start_time)
+ )
+ ]);
+ $flexProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-overrun',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time),
+ $this->calcRelativeLeft(time(), $this->downtime->scheduled_end_time)
+ )
+ ]);
+ } else {
+ $timelineProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-elapsed',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $flexStartLeft,
+ $hPadding + $this->calcRelativeLeft(time()) - $flexStartLeft
+ )
+ ]);
+ }
+
+ $above->add([
+ Html::tag(
+ 'li',
+ ['class' => 'start positioned'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ new VerticalKeyValue(t('Scheduled Start'), new TimeAgo($this->downtime->scheduled_start_time))
+ )
+ ),
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'end positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)
+ )
+ ],
+ Html::tag('div', ['class' => 'bubble'], new VerticalKeyValue(t('Scheduled End'), $endTime))
+ )
+ ]);
+
+ $below->add([
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'start positioned',
+ 'style' => sprintf('left: %F%%', $flexStartLeft)
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards' . ($evade ? ' left' : '')],
+ new VerticalKeyValue(t('Start'), new TimeAgo($this->downtime->start_time))
+ )
+ ),
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'end positioned',
+ 'style' => sprintf('left: %F%%', $flexEndLeft)
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards' . ($evade ? ' right' : '')],
+ new VerticalKeyValue(t('End'), new TimeUntil($this->downtime->end_time))
+ )
+ )
+ ]);
+ } elseif ($this->downtime->is_flexible) {
+ $this->addAttributes(['class' => 'flexible']);
+
+ $timelineProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-elapsed',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time),
+ $this->calcRelativeLeft(time())
+ )
+ ]);
+
+ $above->add([
+ Html::tag(
+ 'li',
+ ['class' => 'start positioned'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ new VerticalKeyValue(
+ t('Scheduled Start'),
+ time() > $this->downtime->scheduled_start_time
+ ? new TimeAgo($this->downtime->scheduled_start_time)
+ : new TimeUntil($this->downtime->scheduled_start_time)
+ )
+ )
+ ),
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'end positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)
+ )
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ new VerticalKeyValue(t('Scheduled End'), $endTime)
+ )
+ )
+ ]);
+
+ $below = null;
+ } else {
+ $timelineProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-elapsed',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time),
+ $this->calcRelativeLeft(time())
+ )
+ ]);
+
+ $below->add([
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'start positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time)
+ )
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('Start'), new TimeAgo($this->downtime->scheduled_start_time))
+ )
+ ),
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'end positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)
+ )
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('End'), new TimeUntil($this->downtime->scheduled_end_time))
+ )
+ )
+ ]);
+ }
+
+ $now = Html::tag(
+ 'li',
+ [
+ 'class' => 'now positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft(time(), null, null, -$hPadding + 3)
+ )
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ Html::tag('strong', t('Now'))
+ )
+ );
+ $above->add($now);
+
+ $markerStart = Html::tag('div', [
+ 'class' => 'marker start',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time)
+ )
+ ]);
+
+ $markerNow = Html::tag('div', [
+ 'class' => 'marker now',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft(time(), null, null, -$hPadding + 3)
+ )
+ ]);
+
+ $markerEnd = Html::tag('div', [
+ 'class' => 'marker end',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)
+ )
+ ]);
+
+ $timeline->add([
+ $timelineProgress,
+ $flexProgress,
+ $markerStart,
+ $markerEnd,
+ $markerFlexStart,
+ $markerFlexEnd,
+ $markerNow,
+ ]);
+
+ $this->add([
+ $above,
+ $timeline,
+ $below
+ ]);
+ }
+
+ protected function calcRelativeLeft($value, $relativeStart = null, $relativeWidth = null, $min = null, $max = null)
+ {
+ if ($relativeStart === null) {
+ $relativeStart = $this->downtime->scheduled_start_time;
+ }
+
+ if ($relativeWidth === null) {
+ $relativeWidth = $this->duration;
+ }
+
+ $left = round(($value - $relativeStart) / $relativeWidth * 80, 2);
+
+ if ($min !== null && $left < $min) {
+ $left = $min;
+ }
+
+ if ($max !== null && $left > $max) {
+ $left = $max;
+ }
+
+ return $left;
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/DowntimeDetail.php b/library/Icingadb/Widget/Detail/DowntimeDetail.php
new file mode 100644
index 0000000..fbeb069
--- /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\EmptyState;
+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 ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+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->setQueryString(QueryString::render(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)
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Start time'),
+ $this->downtime->start_time
+ ? WebDateFormatter::formatDateTime($this->downtime->start_time)
+ : new EmptyState(t('Not started yet'))
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('End time'),
+ $this->downtime->end_time
+ ? WebDateFormatter::formatDateTime($this->downtime->end_time)
+ : new EmptyState(t('Not started yet'))
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled Start'),
+ WebDateFormatter::formatDateTime($this->downtime->scheduled_start_time)
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled End'),
+ WebDateFormatter::formatDateTime($this->downtime->scheduled_end_time)
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled Duration'),
+ DateFormatter::formatDuration($this->downtime->scheduled_duration)
+ ));
+ if ($this->downtime->is_flexible) {
+ $this->add(new HorizontalKeyValue(
+ t('Flexible Duration'),
+ DateFormatter::formatDuration($this->downtime->flexible_duration)
+ ));
+ }
+
+ $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..d29a8a1
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/EventDetail.php
@@ -0,0 +1,612 @@
+<?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\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemList\UserList;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+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)
+ );
+ }
+
+ $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))
+ ];
+
+ 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 UserList($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)
+ );
+ }
+
+ $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)),
+ 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));
+ $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)
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Scheduled Start'),
+ DateFormatter::formatDateTime($downtime->scheduled_start_time)
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Actual Start'), DateFormatter::formatDateTime($downtime->start_time));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Scheduled End'),
+ DateFormatter::formatDateTime($downtime->scheduled_end_time)
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Actual End'), DateFormatter::formatDateTime($downtime->end_time));
+
+ if ($downtime->is_flexible) {
+ $eventInfo[] = new HorizontalKeyValue(t('Flexible'), t('Yes'));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Duration'),
+ DateFormatter::formatDuration($downtime->flexible_duration)
+ );
+ }
+
+ $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)),
+ 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));
+ $eventInfo[] = new HorizontalKeyValue(t('Author'), [new Icon('user'), $comment->author]);
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Expires On'),
+ $comment->expire_time
+ ? DateFormatter::formatDateTime($comment->expire_time)
+ : 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)
+ );
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Removed by'),
+ [new Icon('user'), $comment->removed_by]
+ );
+ } else {
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Expired On'),
+ DateFormatter::formatDateTime($comment->remove_time)
+ );
+ }
+ }
+
+ $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))
+ ];
+ 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));
+ $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)),
+ 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)
+ : 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 ?: $this->event->event_time)
+ );
+ 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);
+ if ($now <= $expiresOn) {
+ $expired = true;
+ $eventInfo[] = new HorizontalKeyValue(t('Removal Reason'), t(
+ 'The acknowledgement expired on %s',
+ DateFormatter::formatDateTime($acknowledgement->expire_time)
+ ));
+ }
+ }
+
+ 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..8757436
--- /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 Icinga\Module\Icingadb\Widget\EmptyState;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter;
+
+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..15e7da6
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostMetaInfo.php
@@ -0,0 +1,75 @@
+<?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 Icinga\Module\Icingadb\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',
+ DateFormatter::formatDateTime($this->host->state->last_state_change)
+ )
+ );
+
+ $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..53423be
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostStatistics.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Data\Filter\Filter;
+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->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter())));
+ }
+
+ return new Link(
+ new VerticalKeyValue(
+ tp('Host', 'Hosts', $this->summary->hosts_total),
+ $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..b945cc0
--- /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\Common\BaseFilter;
+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\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")
+ ->setQueryString(QueryString::render($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..b8760ee
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectDetail.php
@@ -0,0 +1,585 @@
+<?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\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\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\EmptyState;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+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)
+ );
+
+ 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);
+ }
+
+ foreach ($this->object->action_url->first()->action_url ?? [] as $url) {
+ $url = $this->expandMacros($url, $this->object);
+ $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);
+
+ foreach ($this->object->notes_url->first()->notes_url ?? [] as $url) {
+ $url = $this->expandMacros($url, $this->object);
+ $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));
+ }
+
+ 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 {
+ $renderedExtension = $extension
+ ->setView(Icinga::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
+ );
+ }
+
+ 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->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);
+ $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..2142c8b
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectStatistics.php
@@ -0,0 +1,34 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\BaseFilter;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+
+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;
+
+ 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..f30823a
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectsDetail.php
@@ -0,0 +1,192 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\BaseFilter;
+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\EmptyState;
+use Icinga\Module\Icingadb\Widget\HostStateBadges;
+use Icinga\Module\Icingadb\Widget\ServiceStateBadges;
+use ipl\Html\HtmlElement;
+use ipl\Orm\Query;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use ipl\Web\Filter\QueryString;
+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()->setQueryString(QueryString::render($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()->setQueryString(QueryString::render($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()
+ ->setQueryString(QueryString::render($this->getBaseFilter()))
+ ->getAbsoluteUrl()
+ );
+ } else {
+ $form->setAction(
+ Links::toggleServicesFeatures()
+ ->setQueryString(QueryString::render($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..4e03089
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/PerfDataTable.php
@@ -0,0 +1,133 @@
+<?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 Icinga\Module\Icingadb\Widget\EmptyState;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Table;
+use ipl\Html\Text;
+
+class PerfDataTable extends Table
+{
+ /** @var bool Whether the table contains a sparkline column */
+ protected $containsSparkline = false;
+
+ 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', 'value', 'min', 'max', 'warn', 'crit'];
+ $columns = [];
+ $labels = array_combine(
+ $keys,
+ [
+ '',
+ t('Label'),
+ t('Value'),
+ t('Min'),
+ t('Max'),
+ t('Warning'),
+ t('Critical')
+ ]
+ );
+ foreach ($pieChartData as $perfdata) {
+ if ($perfdata->isVisualizable()) {
+ $columns[''] = '';
+ $this->containsSparkline = true;
+ }
+
+ foreach ($perfdata->toArray() as $column => $value) {
+ if (
+ empty($value) ||
+ $column === 'min' && floatval($value) === 0.0 ||
+ $column === 'max' && $perfdata->isPercentage() && floatval($value) === 100
+ ) {
+ continue;
+ }
+
+ $columns[$column] = $labels[$column];
+ }
+ }
+
+ $headerRow = new HtmlElement('tr');
+ foreach ($keys as $key => $col) {
+ if ((! $this->containsSparkline) && $col == '') {
+ unset($keys[$key]);
+ continue;
+ }
+ if (isset($col)) {
+ $headerRow->addHtml(new HtmlElement('th', Attributes::create([
+ 'class' => ($col == 'label' ? 'title' : null)
+ ]), Text::create($labels[$col])));
+ }
+ }
+
+ $this->getHeader()->addHtml($headerRow);
+
+ foreach ($pieChartData as $count => $perfdata) {
+ if ($this->limit != 0 && $count > $this->limit) {
+ break;
+ } else {
+ $cols = [];
+ if ($this->containsSparkline) {
+ if ($perfdata->isVisualizable()) {
+ $cols[] = Table::td(
+ HtmlString::create($perfdata->asInlinePie($this->color)->render()),
+ [ 'class' => 'sparkline-col']
+ );
+ } else {
+ $cols[] = Table::td('');
+ }
+ }
+
+ foreach ($perfdata->toArray() as $column => $value) {
+ $cols[] = Table::td(
+ new HtmlElement(
+ 'span',
+ Attributes::create([
+ 'class' => ($value ? '' : '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..448252f
--- /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)
+ )
+ );
+
+ $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..0f570b4
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceStatistics.php
@@ -0,0 +1,66 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Data\Filter\Filter;
+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\Filter\QueryString;
+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->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter())));
+ }
+
+ return new Link(
+ new VerticalKeyValue(
+ tp('Service', 'Services', $this->summary->services_total),
+ $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..bfdfa46
--- /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\EmptyState;
+use ipl\Html\Attributes;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemList\UsergroupList;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+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 UsergroupList($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..f2cdebe
--- /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\EmptyState;
+use Icinga\Module\Icingadb\Widget\ItemList\UserList;
+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\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 UserList($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/EmptyState.php b/library/Icingadb/Widget/EmptyState.php
new file mode 100644
index 0000000..4800244
--- /dev/null
+++ b/library/Icingadb/Widget/EmptyState.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+
+class EmptyState extends BaseHtmlElement
+{
+ /** @var mixed Content */
+ protected $content;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'empty-state'];
+
+ public function __construct($content)
+ {
+ $this->content = $content;
+ }
+
+ protected function assemble()
+ {
+ $this->add($this->content);
+ }
+}
diff --git a/library/Icingadb/Widget/Health.php b/library/Icingadb/Widget/Health.php
new file mode 100644
index 0000000..17cf014
--- /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 > 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)
+ )
+ ]));
+ } 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)
+ )
+ ]));
+ }
+
+ $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)
+ ),
+ new VerticalKeyValue(
+ t('Last Heartbeat'),
+ new TimeAgo($this->data->heartbeat)
+ ),
+ 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..0014b5e
--- /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)
+ {
+ $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);
+ }
+}
diff --git a/library/Icingadb/Widget/HostSummaryDonut.php b/library/Icingadb/Widget/HostSummaryDonut.php
new file mode 100644
index 0000000..19f7984
--- /dev/null
+++ b/library/Icingadb/Widget/HostSummaryDonut.php
@@ -0,0 +1,76 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Chart\Donut;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\BaseFilter;
+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\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)
+ {
+ $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_unreachable_handled, ['class' => 'slice-state-unreachable-handled'])
+ ->addSlice($this->summary->hosts_unreachable_unhandled, ['class' => 'slice-state-unreachable'])
+ ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending'])
+ ->setLabelBig($this->summary->hosts_down_unhandled)
+ ->setLabelBigUrl(Links::hosts()->addFilter(
+ Filter::fromQueryString(QueryString::render($this->getBaseFilter()))
+ )->addParams([
+ 'host.state.soft_state' => 1,
+ 'host.state.is_handled' => 'n',
+ '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..5fbd033
--- /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\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\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Common\BaseListItem;
+use ipl\Html\FormattedString;
+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)
+ {
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->text));
+
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->addFrom($markdownLine);
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title)
+ {
+ $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 != 0) {
+ $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)
+ {
+ $visual->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'user-ball']),
+ Text::create($this->item->author[0])
+ ));
+ }
+
+ protected function createTimestamp()
+ {
+ if ($this->item->expire_time) {
+ return Html::tag(
+ 'span',
+ FormattedString::create(t("expires %s"), new TimeUntil($this->item->expire_time))
+ );
+ }
+
+ return Html::tag(
+ 'span',
+ FormattedString::create(t("created %s"), new TimeAgo($this->item->entry_time))
+ );
+ }
+
+ protected function init()
+ {
+ $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..04e8e1b
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
@@ -0,0 +1,217 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\BaseListItem;
+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\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+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()
+ {
+ if ($this->item->is_flexible && $this->item->is_in_effect) {
+ $this->startTime = $this->item->start_time;
+ $this->endTime = $this->item->end_time;
+ } else {
+ $this->startTime = $this->item->scheduled_start_time;
+ $this->endTime = $this->item->scheduled_end_time;
+ }
+
+ $this->currentTime = time();
+
+ $this->isActive = $this->item->is_in_effect
+ || $this->item->is_flexible && $this->item->scheduled_start_time <= $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
+ {
+ $ref = floor(
+ (float) ($this->currentTime - $this->startTime)
+ / (float) ($this->endTime - $this->startTime)
+ * 100
+ );
+
+ $progress = Html::tag(
+ 'div',
+ ['class' => 'progress'],
+ Html::tag(
+ 'div',
+ [
+ 'class' => 'progress-bar',
+ 'style' => sprintf('width: %F%%', $ref)
+ ]
+ )
+ );
+
+ return $progress;
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption)
+ {
+ $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)
+ {
+ 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)
+ {
+ $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()
+ {
+ $dateTime = DateFormatter::formatDateTime($this->endTime);
+
+ 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..f7408d7
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
@@ -0,0 +1,404 @@
+<?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\TicketLinks;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use Icinga\Module\Icingadb\Widget\MarkdownLine;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+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\Common\BaseListItem;
+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\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()
+ {
+ $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)
+ {
+ 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 - $this->item->flapping->start_time
+ )
+ ))
+ ->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)
+ {
+ 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)
+ {
+ 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 (isset($this->item->downtime->cancel_time)) {
+ $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 === '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()
+ {
+ return new TimeAgo($this->item->event_time);
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseHostListItem.php b/library/Icingadb/Widget/ItemList/BaseHostListItem.php
new file mode 100644
index 0000000..99a8c63
--- /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()
+ {
+ 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..962fe3e
--- /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\Common\BaseListItem;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Stdlib\Filter;
+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()
+ {
+ $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)
+ {
+ 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)
+ {
+ 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)
+ {
+ 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()
+ {
+ return new TimeAgo($this->item->send_time);
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseServiceListItem.php b/library/Icingadb/Widget/ItemList/BaseServiceListItem.php
new file mode 100644
index 0000000..208e496
--- /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()
+ {
+ 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..50ae06d
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommandTransportList.php
@@ -0,0 +1,22 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\BaseOrderedItemList;
+use ipl\Web\Url;
+
+class CommandTransportList extends BaseOrderedItemList
+{
+ protected function init()
+ {
+ $this->getAttributes()->add('class', 'command-transport-list');
+ $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..c15e1bc
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommandTransportListItem.php
@@ -0,0 +1,70 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\BaseOrderedListItem;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+class CommandTransportListItem extends BaseOrderedListItem
+{
+ protected function init()
+ {
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ }
+
+ protected function assembleMain(BaseHtmlElement $main)
+ {
+ $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()
+ {
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/CommentList.php b/library/Icingadb/Widget/ItemList/CommentList.php
new file mode 100644
index 0000000..db164ff
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommentList.php
@@ -0,0 +1,44 @@
+<?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\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 Icinga\Module\Icingadb\Common\BaseItemList;
+use ipl\Web\Url;
+
+class CommentList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use ViewMode;
+ use TicketLinks;
+
+ protected $defaultAttributes = ['class' => 'comment-list'];
+
+ protected function getItemClass(): string
+ {
+ $viewMode = $this->getViewMode();
+
+ $this->addAttributes(['class' => $viewMode]);
+
+ if ($viewMode === 'minimal') {
+ return CommentListItemMinimal::class;
+ }
+
+ return CommentListItem::class;
+ }
+
+ protected function init()
+ {
+ $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..fc204b3
--- /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()
+ {
+ 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..964fd20
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/DowntimeList.php
@@ -0,0 +1,44 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use Icinga\Module\Icingadb\Common\CaptionDisabled;
+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\Url;
+
+class DowntimeList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use ViewMode;
+ use TicketLinks;
+
+ protected $defaultAttributes = ['class' => 'downtime-list'];
+
+ protected function getItemClass(): string
+ {
+ $viewMode = $this->getViewMode();
+
+ $this->addAttributes(['class' => $viewMode]);
+
+ if ($viewMode === 'minimal') {
+ return DowntimeListItemMinimal::class;
+ }
+
+ return DowntimeListItem::class;
+ }
+
+ protected function init()
+ {
+ $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..0a00986
--- /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)
+ {
+ 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..58ef3d8
--- /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()
+ {
+ 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..dbb7cea
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HistoryList.php
@@ -0,0 +1,58 @@
+<?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\LoadMore;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use ipl\Orm\ResultSet;
+use ipl\Web\Url;
+
+class HistoryList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ViewMode;
+ use LoadMore;
+ use TicketLinks;
+
+ protected $defaultAttributes = ['class' => 'history-list'];
+
+ /** @var ResultSet */
+ protected $data;
+
+ public function __construct(ResultSet $data)
+ {
+ parent::__construct($data);
+ }
+
+ protected function init()
+ {
+ $this->data = $this->getIterator($this->data);
+ $this->setDetailUrl(Url::fromPath('icingadb/event'));
+ }
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return HistoryListItemMinimal::class;
+ case 'detailed':
+ return HistoryListItemDetailed::class;
+ default:
+ return HistoryListItem::class;
+ }
+ }
+
+ protected function assemble()
+ {
+ $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..b516a4e
--- /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()
+ {
+ 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..0c90fea
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostDetailHeader.php
@@ -0,0 +1,72 @@
+<?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\CheckAttempt;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class HostDetailHeader extends HostListItemMinimal
+{
+ protected function getStateBallSize(): string
+ {
+ return '';
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ 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');
+ }
+ }
+
+ if ($this->state->is_handled) {
+ $currentStateBall = $stateChange->ensureAssembled()->getContent()[1];
+ $currentStateBall->addHtml(new Icon($this->getHandledIcon()));
+ $currentStateBall->getAttributes()->add('class', 'handled');
+ }
+
+ $visual->addHtml($stateChange);
+ }
+
+ protected function assemble()
+ {
+ $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..51d218e
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostList.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;
+
+/**
+ * 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':
+ return HostListItemDetailed::class;
+ case 'objectHeader':
+ return HostDetailHeader::class;
+ default:
+ return HostListItem::class;
+ }
+ }
+
+ protected function init()
+ {
+ $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..a2f1909
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostListItemDetailed.php
@@ -0,0 +1,103 @@
+<?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)
+ {
+ $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));
+ }
+ }
+
+ $footer->addHtml($statusIcons);
+ $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/HostgroupList.php b/library/Icingadb/Widget/ItemList/HostgroupList.php
new file mode 100644
index 0000000..e6c8279
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostgroupList.php
@@ -0,0 +1,33 @@
+<?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\ViewMode;
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use ipl\Web\Url;
+
+class HostgroupList extends BaseItemList
+{
+ use NoSubjectLink;
+ use ViewMode;
+
+ protected $defaultAttributes = ['class' => 'hostgroup-list item-table'];
+
+ protected function init()
+ {
+ parent::init();
+
+ $this->getAttributes()->get('class')->removeValue('item-list');
+ $this->setDetailUrl(Url::fromPath('icingadb/hostgroup'));
+ }
+
+ protected function getItemClass(): string
+ {
+ $this->addAttributes(['class' => $this->getViewMode()]);
+
+ return HostgroupListItem::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostgroupListItem.php b/library/Icingadb/Widget/ItemList/HostgroupListItem.php
new file mode 100644
index 0000000..5df0432
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostgroupListItem.php
@@ -0,0 +1,84 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\BaseTableRowItem;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Model\Hostgroup;
+use Icinga\Module\Icingadb\Widget\Detail\HostStatistics;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceStatistics;
+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\Widget\Link;
+
+/**
+ * Hostgroup item of a hostgroup list. Represents one database row.
+ *
+ * @property Hostgroup $item
+ * @property HostgroupList $list
+ */
+class HostgroupListItem extends BaseTableRowItem
+{
+ use NoSubjectLink;
+
+ protected function init()
+ {
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+
+ protected function assembleColumns(HtmlDocument $columns)
+ {
+ $hostStats = new HostStatistics($this->item);
+
+ $hostStats->setBaseFilter(Filter::equal('hostgroup.name', $this->item->name));
+ if ($this->list->hasBaseFilter()) {
+ $hostStats->setBaseFilter(
+ Filter::all($hostStats->getBaseFilter(), $this->list->getBaseFilter())
+ );
+ }
+
+ $columns->addFrom($hostStats, function (BaseHtmlElement $item) {
+ $item->getAttributes()->add(['class' => 'col']);
+ $item->setTag('div');
+ return $item;
+ });
+
+ $serviceStats = new ServiceStatistics($this->item);
+
+ $serviceStats->setBaseFilter(Filter::equal('hostgroup.name', $this->item->name));
+ if ($this->list->hasBaseFilter()) {
+ $serviceStats->setBaseFilter(
+ Filter::all($serviceStats->getBaseFilter(), $this->list->getBaseFilter())
+ );
+ }
+
+ $columns->addFrom($serviceStats, function (BaseHtmlElement $item) {
+ $item->getAttributes()->add(['class' => 'col']);
+ $item->setTag('div');
+ return $item;
+ });
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title)
+ {
+ $title->addHtml(
+ $this->getNoSubjectLink()
+ ? new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ )
+ : new Link($this->item->display_name, Links::hostgroup($this->item), ['class' => 'subject']),
+ new HtmlElement('br'),
+ Text::create($this->item->name)
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/NotificationList.php b/library/Icingadb/Widget/ItemList/NotificationList.php
new file mode 100644
index 0000000..8c95d26
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/NotificationList.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\CaptionDisabled;
+use Icinga\Module\Icingadb\Common\LoadMore;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use ipl\Orm\ResultSet;
+use ipl\Web\Url;
+
+class NotificationList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ViewMode;
+ use LoadMore;
+
+ protected $defaultAttributes = ['class' => 'notification-list'];
+
+ /** @var ResultSet */
+ protected $data;
+
+ public function __construct(ResultSet $data)
+ {
+ parent::__construct($data);
+ }
+
+ protected function init()
+ {
+ $this->data = $this->getIterator($this->data);
+ $this->setDetailUrl(Url::fromPath('icingadb/event'));
+ }
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return NotificationListItemMinimal::class;
+ case 'detailed':
+ return NotificationListItemDetailed::class;
+ default:
+ return NotificationListItem::class;
+ }
+ }
+
+ protected function assemble()
+ {
+ $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..dcda5fd
--- /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()
+ {
+ 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..6036929
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php
@@ -0,0 +1,72 @@
+<?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\CheckAttempt;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class ServiceDetailHeader extends ServiceListItemMinimal
+{
+ protected function getStateBallSize(): string
+ {
+ return '';
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ 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');
+ }
+ }
+
+ if ($this->state->is_handled) {
+ $currentStateBall = $stateChange->ensureAssembled()->getContent()[1];
+ $currentStateBall->addHtml(new Icon($this->getHandledIcon()));
+ $currentStateBall->getAttributes()->add('class', 'handled');
+ }
+
+ $visual->addHtml($stateChange);
+ }
+
+ protected function assemble()
+ {
+ $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..11febb0
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceList.php
@@ -0,0 +1,33 @@
+<?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':
+ return ServiceListItemDetailed::class;
+ case 'objectHeader':
+ return ServiceDetailHeader::class;
+ default:
+ return ServiceListItem::class;
+ }
+ }
+
+ protected function init()
+ {
+ $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..a068beb
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
@@ -0,0 +1,107 @@
+<?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)
+ {
+ $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));
+ }
+ }
+
+ $footer->addHtml($statusIcons);
+ $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/ServicegroupList.php b/library/Icingadb/Widget/ItemList/ServicegroupList.php
new file mode 100644
index 0000000..fa612f1
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServicegroupList.php
@@ -0,0 +1,33 @@
+<?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\ViewMode;
+use Icinga\Module\Icingadb\Common\BaseItemList;
+use ipl\Web\Url;
+
+class ServicegroupList extends BaseItemList
+{
+ use NoSubjectLink;
+ use ViewMode;
+
+ protected $defaultAttributes = ['class' => 'servicegroup-list item-table'];
+
+ protected function init()
+ {
+ parent::init();
+
+ $this->getAttributes()->get('class')->removeValue('item-list');
+ $this->setDetailUrl(Url::fromPath('icingadb/servicegroup'));
+ }
+
+ protected function getItemClass(): string
+ {
+ $this->addAttributes(['class' => $this->getViewMode()]);
+
+ return ServicegroupListItem::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServicegroupListItem.php b/library/Icingadb/Widget/ItemList/ServicegroupListItem.php
new file mode 100644
index 0000000..6a8320c
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServicegroupListItem.php
@@ -0,0 +1,68 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\BaseTableRowItem;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Model\Servicegroup;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceStatistics;
+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\Widget\Link;
+
+/**
+ * Servicegroup item of a servicegroup list. Represents one database row.
+ *
+ * @property Servicegroup $item
+ * @property ServicegroupList $list
+ */
+class ServicegroupListItem extends BaseTableRowItem
+{
+ use NoSubjectLink;
+
+ protected function init()
+ {
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+
+ protected function assembleColumns(HtmlDocument $columns)
+ {
+ $serviceStats = new ServiceStatistics($this->item);
+
+ $serviceStats->setBaseFilter(Filter::equal('servicegroup.name', $this->item->name));
+ if ($this->list->hasBaseFilter()) {
+ $serviceStats->setBaseFilter(
+ Filter::all($serviceStats->getBaseFilter(), $this->list->getBaseFilter())
+ );
+ }
+
+ $columns->addFrom($serviceStats, function (BaseHtmlElement $item) {
+ $item->getAttributes()->add(['class' => 'col']);
+ $item->setTag('div');
+ return $item;
+ });
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title)
+ {
+ $title->addHtml(
+ $this->getNoSubjectLink()
+ ? new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ )
+ : new Link($this->item->display_name, Links::servicegroup($this->item), ['class' => 'subject']),
+ new HtmlElement('br'),
+ Text::create($this->item->name)
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/StateList.php b/library/Icingadb/Widget/ItemList/StateList.php
new file mode 100644
index 0000000..cf6ec0b
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/StateList.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\BaseItemList;
+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;
+
+abstract class StateList extends BaseItemList
+{
+ use ViewMode;
+ use NoSubjectLink;
+
+ protected function assemble()
+ {
+ $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..d5eb4fd
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/StateListItem.php
@@ -0,0 +1,129 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\BaseListItem;
+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\EmptyState;
+use Icinga\Module\Icingadb\Widget\IconImage;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+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 State The state of the item */
+ protected $state;
+
+ protected function init()
+ {
+ $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;
+
+ protected function assembleCaption(BaseHtmlElement $caption)
+ {
+ 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)
+ {
+ 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)
+ {
+ $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)
+ {
+ $stateBall = new StateBall($this->state->getStateText(), $this->getStateBallSize());
+
+ if ($this->state->is_handled) {
+ $stateBall->addHtml(new Icon($this->getHandledIcon()));
+ $stateBall->getAttributes()->add('class', 'handled');
+ } elseif ($this->state->getStateText() === 'pending' && $this->state->in_downtime) {
+ $stateBall->addHtml(new Icon($this->getHandledIcon()));
+ }
+
+ $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()
+ {
+ if ($this->state->is_overdue) {
+ $since = new TimeSince($this->state->next_update);
+ $since->prepend(t('Overdue') . ' ');
+ $since->prependHtml(new Icon(Icons::WARNING));
+ return $since;
+ } elseif ($this->state->last_state_change > 0) {
+ return new TimeSince($this->state->last_state_change);
+ }
+ }
+
+ protected function getHandledIcon(): string
+ {
+ switch (true) {
+ case $this->state->is_acknowledged:
+ return Icons::IS_ACKNOWLEDGED;
+ case $this->state->in_downtime:
+ return Icons::IN_DOWNTIME;
+ case $this->state->is_flapping:
+ return Icons::IS_FLAPPING;
+ default:
+ return Icons::HOST_DOWN;
+ }
+ }
+
+ protected function assemble()
+ {
+ if ($this->state->is_overdue) {
+ $this->addAttributes(['class' => 'overdue']);
+ }
+
+ parent::assemble();
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/UserList.php b/library/Icingadb/Widget/ItemList/UserList.php
new file mode 100644
index 0000000..826a467
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/UserList.php
@@ -0,0 +1,29 @@
+<?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\BaseItemList;
+use ipl\Web\Url;
+
+class UserList extends BaseItemList
+{
+ use NoSubjectLink;
+
+ protected $defaultAttributes = ['class' => 'user-list item-table'];
+
+ protected function init()
+ {
+ parent::init();
+
+ $this->getAttributes()->get('class')->removeValue('item-list');
+ $this->setDetailUrl(Url::fromPath('icingadb/user'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return UserListItem::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/UserListItem.php b/library/Icingadb/Widget/ItemList/UserListItem.php
new file mode 100644
index 0000000..e43692f
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/UserListItem.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\BaseTableRowItem;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+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\Widget\Link;
+
+/**
+ * User item of a user list. Represents one database row.
+ *
+ * @property User $item
+ * @property UserList $list
+ */
+class UserListItem extends BaseTableRowItem
+{
+ use NoSubjectLink;
+
+ protected function init()
+ {
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ $visual->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'user-ball']),
+ Text::create($this->item->display_name[0])
+ ));
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title)
+ {
+ $title->addHtml(
+ $this->getNoSubjectLink()
+ ? new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ )
+ : new Link($this->item->display_name, Links::user($this->item), ['class' => 'subject']),
+ new HtmlElement('br'),
+ Text::create($this->item->name)
+ );
+ }
+
+ protected function assembleColumns(HtmlDocument $columns)
+ {
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/UsergroupList.php b/library/Icingadb/Widget/ItemList/UsergroupList.php
new file mode 100644
index 0000000..2e95368
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/UsergroupList.php
@@ -0,0 +1,29 @@
+<?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\BaseItemList;
+use ipl\Web\Url;
+
+class UsergroupList extends BaseItemList
+{
+ use NoSubjectLink;
+
+ protected $defaultAttributes = ['class' => 'usergroup-list item-table'];
+
+ protected function init()
+ {
+ parent::init();
+
+ $this->getAttributes()->get('class')->removeValue('item-list');
+ $this->setDetailUrl(Url::fromPath('icingadb/usergroup'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return UsergroupListItem::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/UsergroupListItem.php b/library/Icingadb/Widget/ItemList/UsergroupListItem.php
new file mode 100644
index 0000000..f8c800d
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/UsergroupListItem.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\BaseTableRowItem;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+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\Widget\Link;
+
+/**
+ * Usergroup item of a usergroup list. Represents one database row.
+ *
+ * @property Usergroup $item
+ * @property UsergroupList $list
+ */
+class UsergroupListItem extends BaseTableRowItem
+{
+ use NoSubjectLink;
+
+ protected function init()
+ {
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ $visual->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'usergroup-ball']),
+ Text::create($this->item->display_name[0])
+ ));
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title)
+ {
+ $title->addHtml(
+ $this->getNoSubjectLink()
+ ? new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ )
+ : new Link($this->item->display_name, Links::usergroup($this->item), ['class' => 'subject']),
+ new HtmlElement('br'),
+ Text::create($this->item->name)
+ );
+ }
+
+ protected function assembleColumns(HtmlDocument $columns)
+ {
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/BaseItemTable.php b/library/Icingadb/Widget/ItemTable/BaseItemTable.php
new file mode 100644
index 0000000..d8fd85b
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/BaseItemTable.php
@@ -0,0 +1,198 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Widget\EmptyState;
+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\Icon;
+
+abstract class BaseItemTable extends BaseHtmlElement
+{
+ protected $baseAttributes = [
+ 'class' => '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;
+
+ abstract protected function getVisualLabel();
+
+ 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);
+ }
+
+ 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 EmptyState(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/BaseRowItem.php b/library/Icingadb/Widget/ItemTable/BaseRowItem.php
new file mode 100644
index 0000000..0189619
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/BaseRowItem.php
@@ -0,0 +1,106 @@
+<?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;
+
+abstract class BaseRowItem extends BaseHtmlElement
+{
+ protected $defaultAttributes = ['class' => 'row-item'];
+
+ /** @var Model */
+ protected $item;
+
+ /** @var BaseItemTable */
+ protected $list;
+
+ protected $tag = 'tr';
+
+ /**
+ * Create a new row item
+ *
+ * @param Model $item
+ * @param BaseItemTable $list
+ */
+ public function __construct(Model $item, BaseItemTable $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/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/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/StateItemTable.php b/library/Icingadb/Widget/ItemTable/StateItemTable.php
new file mode 100644
index 0000000..5f9b38a
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/StateItemTable.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\Icon;
+
+abstract class StateItemTable extends BaseItemTable
+{
+ protected function getVisualLabel()
+ {
+ return new Icon('heartbeat', ['title' => t('Severity')]);
+ }
+
+ protected function assembleColumnHeader(BaseHtmlElement $header, string $name, $label): void
+ {
+ parent::assembleColumnHeader($header, $name, $label);
+
+ 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;
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/StateRowItem.php b/library/Icingadb/Widget/ItemTable/StateRowItem.php
new file mode 100644
index 0000000..6e2cb5c
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/StateRowItem.php
@@ -0,0 +1,139 @@
+<?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\Icons;
+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\EmptyState;
+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\Icon;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeSince;
+use ipl\Web\Widget\TimeUntil;
+
+abstract class StateRowItem extends BaseRowItem
+{
+ /** @var StateItemTable */
+ protected $list;
+
+ protected function getHandledIcon(): string
+ {
+ switch (true) {
+ case $this->item->state->in_downtime:
+ return Icons::IN_DOWNTIME;
+ case $this->item->state->is_acknowledged:
+ return Icons::IS_ACKNOWLEDGED;
+ case $this->item->state->is_flapping:
+ return Icons::IS_FLAPPING;
+ default:
+ return Icons::HOST_DOWN;
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ $stateBall = new StateBall($this->item->state->getStateText(), StateBall::SIZE_LARGE);
+
+ if ($this->item->state->is_handled) {
+ $stateBall->addHtml(new Icon($this->getHandledIcon()));
+ $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));
+ break;
+ case $path === 'state.next_check':
+ case $path === 'state.next_update':
+ $column = substr($path, 6);
+ $cell->addHtml(new TimeUntil($this->item->state->$column));
+ 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/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..998ad30
--- /dev/null
+++ b/library/Icingadb/Widget/Notice.php
@@ -0,0 +1,31 @@
+<?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'];
+
+ public function __construct($content)
+ {
+ $this->content = $content;
+ }
+
+ protected function assemble()
+ {
+ $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..fd80835
--- /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)
+ {
+ $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);
+ }
+}
diff --git a/library/Icingadb/Widget/ServiceSummaryDonut.php b/library/Icingadb/Widget/ServiceSummaryDonut.php
new file mode 100644
index 0000000..8141f86
--- /dev/null
+++ b/library/Icingadb/Widget/ServiceSummaryDonut.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\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\BaseFilter;
+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\Web\Common\Card;
+use ipl\Web\Filter\QueryString;
+
+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)
+ {
+ $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()->addFilter(
+ Filter::fromQueryString(QueryString::render($this->getBaseFilter()))
+ )->addParams([
+ 'service.state.soft_state' => 2,
+ 'service.state.is_handled' => 'n',
+ '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..40f2a4d
--- /dev/null
+++ b/library/Icingadb/Widget/ShowMore.php
@@ -0,0 +1,61 @@
+<?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';
+
+ protected $resultSet;
+
+ protected $url;
+
+ 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()
+ {
+ if ($this->resultSet->hasMore()) {
+ $this->add(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..7cc81a9
--- /dev/null
+++ b/library/Icingadb/Widget/StateBadge.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+
+class StateBadge extends BaseHtmlElement
+{
+ protected $defaultAttributes = ['class' => 'state-badge'];
+
+ /** @var mixed Badge content */
+ protected $content;
+
+ /** @var bool Whether the state is handled */
+ protected $isHandled;
+
+ /** @var string Textual representation of a state */
+ protected $state;
+
+ /**
+ * Create a new state badge
+ *
+ * @param mixed $content Content of the badge
+ * @param string $state Textual representation of a state
+ * @param bool $isHandled True if state is handled
+ */
+ public function __construct($content, string $state, bool $isHandled = false)
+ {
+ $this->content = $content;
+ $this->isHandled = $isHandled;
+ $this->state = $state;
+ }
+
+ protected function assemble()
+ {
+ $this->setTag('span');
+
+ $class = "state-{$this->state}";
+ if ($this->isHandled) {
+ $class .= ' handled';
+ }
+
+ $this->addAttributes(['class' => $class]);
+
+ $this->add($this->content);
+ }
+}
diff --git a/library/Icingadb/Widget/StateChange.php b/library/Icingadb/Widget/StateChange.php
new file mode 100644
index 0000000..0bf4fa3
--- /dev/null
+++ b/library/Icingadb/Widget/StateChange.php
@@ -0,0 +1,98 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+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';
+
+ 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;
+ }
+
+ protected function assemble()
+ {
+ if ($this->isRightBiggerThanLeft()) {
+ $this->getAttributes()->add('class', 'reversed-state-balls');
+
+ $this->addHtml(
+ new StateBall($this->state, $this->currentStateBallSize),
+ new StateBall($this->previousState, $this->previousStateBallSize)
+ );
+ } else {
+ $this->addHtml(
+ new StateBall($this->previousState, $this->previousStateBallSize),
+ new StateBall($this->state, $this->currentStateBallSize)
+ );
+ }
+ }
+
+ 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..e815107
--- /dev/null
+++ b/module.info
@@ -0,0 +1,6 @@
+Module: icingadb
+Version: 1.0.2
+Requires:
+ Libraries: icinga-php-library (>=0.10.0), icinga-php-thirdparty (>=0.11.0)
+Description: Icinga DB Web
+ UI for Icinga DB – Provides a graphical interface to your Icinga monitoring
diff --git a/public/css/common.less b/public/css/common.less
new file mode 100644
index 0000000..777cc33
--- /dev/null
+++ b/public/css/common.less
@@ -0,0 +1,357 @@
+/* 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 {
+ padding-left: 1em;
+ padding-right: 1em;
+ }
+}
+
+& > .content.full-height {
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.plugin-output {
+ .monospace();
+ word-break: break-word;
+}
+
+.empty-state {
+ color: @gray-semilight;
+}
+
+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;
+ }
+}
+
+.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;
+ }
+ }
+
+ .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;
+ }
+}
+
+.hostgroup-list.minimal,
+.servicegroup-list.minimal {
+ .col {
+ padding: 0;
+ }
+
+ .title br {
+ display: none;
+ }
+
+ .vertical-key-value {
+ br {
+ display: none;
+ }
+
+ .key {
+ padding-left: .417em;
+ }
+
+ .value {
+ vertical-align: middle;
+ }
+ }
+}
+
+.hostgroup-list > col.object-statistics-graph,
+.servicegropup-list > col.object-statistics-graph {
+ padding-right: .25em;
+}
+
+.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;
+ }
+ }
+}
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..222bc7e
--- /dev/null
+++ b/public/css/list/downtime-list.less
@@ -0,0 +1,93 @@
+// Style
+
+.downtime-list .list-item,
+.downtime-detail .list-item {
+ .progress {
+ .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;
+
+ .progress-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..cd39aac
--- /dev/null
+++ b/public/css/list/item-list.less
@@ -0,0 +1,239 @@
+// Style
+
+.item-list {
+ list-style-type: none;
+
+ > .empty-state {
+ .rounded-corners();
+ background-color: @gray-lighter;
+ }
+
+ .show-more:hover,
+ .page-separator:hover {
+ background: none;
+ }
+
+ > .show-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 {
+ margin: 0;
+ padding: 0;
+
+ .list-item {
+ display: flex;
+
+ &.show-more a {
+ flex: 1;
+ margin: 1.5em 0;
+ padding: .5em 0;
+ }
+
+ .main {
+ flex: 1 1 auto;
+ padding: .5em 0;
+ width: 0;
+ margin-left: .5em;
+ }
+
+ .visual {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ }
+
+ .caption {
+ height: 3em;
+ text-overflow: ellipsis;
+ overflow: hidden;
+
+ .line-clamp();
+
+ img {
+ max-height: 1em;
+ }
+ }
+
+ header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ }
+
+ footer {
+ display: flex;
+ justify-content: space-between;
+ }
+ }
+
+ > .empty-state {
+ margin: 0 1em;
+ padding: 1em;
+ text-align: center;
+ }
+}
+
+.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:not(.detailed) .list-item {
+ .title {
+ display: inline-flex;
+ align-items: baseline;
+ white-space: nowrap;
+ min-width: 0;
+
+ > * {
+ margin: 0 .28125em; // 0 calculated &nbsp; width
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+
+ .subject {
+ .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 .list-item:not(:last-child) {
+ margin-bottom: .5em;
+}
+
+.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/item-table.less b/public/css/list/item-table.less
new file mode 100644
index 0000000..c307416
--- /dev/null
+++ b/public/css/list/item-table.less
@@ -0,0 +1,176 @@
+// Style
+
+.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;
+ }
+ }
+ }
+
+ > .empty-state,
+ > tbody > tr:first-child .empty-state {
+ .rounded-corners();
+ background-color: @gray-lightest;
+ }
+
+ .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.item-table {
+ table-layout: fixed;
+}
+
+.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,
+ > tbody > tr:first-child .empty-state {
+ margin: 0 1em;
+ padding: 1em;
+ text-align: center;
+ }
+}
+
+.content.full-width .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.item-table {
+ > thead > tr > th,
+ > tbody > tr > td {
+ &:nth-child(n+6) {
+ display: none;
+ width: 0;
+ }
+ }
+}
diff --git a/public/css/list/list-item.less b/public/css/list/list-item.less
new file mode 100644
index 0000000..2370b59
--- /dev/null
+++ b/public/css/list/list-item.less
@@ -0,0 +1,139 @@
+// Style
+
+.list-item {
+ color: @text-color-light;
+
+ &.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;
+ }
+
+ &:not(:first-child) > .main {
+ border-top: 1px solid @gray-light;
+ }
+
+ &:not(:first-child) .visual {
+ margin-top: 1px;
+ }
+
+ .caption {
+ i {
+ opacity: 0.8;
+ }
+
+ a {
+ color: @text-color;
+ }
+ }
+
+ .title {
+ span.subject,
+ .state-text {
+ color: @text-color;
+ }
+
+ .state-text {
+ text-transform: uppercase;
+ }
+
+ a {
+ color: @text-color;
+ font-weight: bold;
+
+ &:hover {
+ color: @icinga-blue;
+ text-decoration: none;
+ }
+ }
+ }
+
+ footer {
+ .status-icons {
+ color: @gray-light;
+ }
+ }
+}
+
+@media print {
+ .list-item.page-break-follows + .list-item {
+ .main {
+ border-top: 1px solid transparent;
+ }
+ }
+}
+
+// Layout
+
+.list-item {
+ &.overdue time {
+ margin-right: -.5em;
+ padding: 0 0.5em;
+ }
+
+ .visual {
+ padding: .5em 0;
+ width: 2.5em;
+
+ .check-attempt {
+ margin-top: .5em;
+ }
+ }
+
+ .caption {
+ p {
+ display: inline-block;
+ }
+
+ &.plugin-output, .plugin-output {
+ font-size: 11/12em;
+ line-height: 1.5*12/11em;
+ }
+ }
+
+ .title {
+ margin-right: 1em;
+
+ p {
+ margin: 0;
+ }
+ }
+
+ time {
+ white-space: nowrap;
+ }
+
+ footer {
+ > * {
+ font-size: .857em;
+ line-height: 1.5*.857em;
+ }
+
+ .status-icons {
+ display: flex;
+ align-items: center;
+ }
+
+ .performance-data {
+ .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..aece148
--- /dev/null
+++ b/public/css/list/state-item-table.less
@@ -0,0 +1,34 @@
+// Layout
+
+#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..532ca2c
--- /dev/null
+++ b/public/css/mixin/progress-bar.less
@@ -0,0 +1,179 @@
+.progress-bar() {
+ &.progress-bar {
+ .above,
+ .below {
+ list-style-type: none;
+ position: relative;
+ margin: 0;
+ }
+
+ .above {
+ padding: ~"calc(1em + 1px) 10%";
+ }
+
+ .below {
+ padding: 1.25em 10%;
+ }
+
+ .positioned {
+ position: absolute;
+ }
+
+ :not(.positioned).end .bubble {
+ // to move the center of the bubble to the end of the wrapper (for check-statistics end bubble only).
+ transform: translate(50%, 0);
+ }
+
+ .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: -.5em;
+ transform: rotate(225deg);
+ }
+
+ &.right {
+ // This is (.675em (:before placement) + .5em (half :before width)) + 1px (:before border)
+ transform: translate(~"calc(-1.175em - 1px)", 0);
+ }
+
+ &.right:before {
+ bottom: auto;
+ left: 1.175em;
+ top: -.5em;
+ }
+
+ &.left {
+ // 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);
+ }
+
+ &.left:before {
+ bottom: auto;
+ left: auto;
+ right: .675em;
+ top: -.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 {
+ .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;
+ margin-top: -1/12em;
+ width: .857em;
+ z-index: 2;
+
+ position: absolute;
+ top: 2/12em;
+
+ &.now {
+ background-color: @gray;
+ }
+
+ &.start,
+ &.end {
+ background-color: @icinga-blue;
+ }
+ }
+ }
+
+ .timeline-overlay {
+ height: 100%;
+ opacity: .6;
+ position: absolute;
+
+ &:before,
+ &:after {
+ content: "";
+ display: block;
+ height: 1em;
+ width: .5em;
+
+ position: absolute;
+ top: 0;
+ }
+
+ &:before {
+ border-bottom-left-radius: .5em;
+ border-top-left-radius: .5em;
+ left: -.5em;
+ }
+
+ &:after {
+ border-bottom-right-radius: .5em;
+ border-top-right-radius: .5em;
+ right: -.5em;
+ }
+ }
+ }
+}
diff --git a/public/css/mixin/state-badges.less b/public/css/mixin/state-badges.less
new file mode 100644
index 0000000..61116cf
--- /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:last-child {
+ margin-left: 1px;
+ }
+ }
+}
diff --git a/public/css/mixins.less b/public/css/mixins.less
new file mode 100644
index 0000000..c6607c2
--- /dev/null
+++ b/public/css/mixins.less
@@ -0,0 +1,22 @@
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+.line-clamp(@numOfLines: 2) when (@numOfLines > 1) {
+ display: -webkit-box;
+ -webkit-line-clamp: @numOfLines;
+ -webkit-box-orient: vertical;
+}
+.line-clamp(@numOfLines: 2) when (@numOfLines = "reset") {
+ display: block; // Would have used "revert", but browser support is still poor and it's not final yet
+ -webkit-line-clamp: initial;
+ -webkit-box-orient: initial;
+}
+
+.text-ellipsis() {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.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/cancel-button.less b/public/css/widget/cancel-button.less
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/css/widget/cancel-button.less
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..e2b5ac0
--- /dev/null
+++ b/public/css/widget/check-statistics.less
@@ -0,0 +1,55 @@
+.check-statistics {
+ .card();
+ .progress-bar();
+ .card-footer {
+ display: flex;
+ justify-content: center;
+ border-top: 1px solid @gray-light;
+
+ .key {
+ width: auto;
+ margin-right: .28125em; //calculated &nbsp; width
+ font-size: .83333333em;
+ }
+ }
+
+ .check-attempt {
+ display: inline-flex;
+ }
+
+ &.progress-bar .below {
+ padding: 0;
+ margin-left: 10%;
+ margin-right: auto;
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ &:before {
+ background-color: @gray;
+ content: "";
+ display: block;
+ height: .25em;
+ width: 100%;
+
+ position: absolute;
+ top: ~"calc(50% - .125em)";
+ }
+ }
+
+ .interval {
+ background-color: @body-bg-color;
+ position: relative;
+ }
+
+ .check-overdue {
+ background-color: @color-down;
+ opacity: 1;
+
+ &:before,
+ &:after {
+ background-color: @color-down;
+ }
+ }
+}
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..bffaf22
--- /dev/null
+++ b/public/css/widget/downtime-card.less
@@ -0,0 +1,47 @@
+.downtime-progress {
+ .progress-bar();
+
+ &.flexible.in-effect .marker {
+ &.start,
+ &.end {
+ background-color: @gray-light;
+ }
+
+ &.flex-start,
+ &.flex-end {
+ background-color: @icinga-blue;
+ }
+ }
+
+ .downtime-elapsed {
+ background-color: @color-ok;
+
+ &:before,
+ &:after {
+ background-color: @color-ok;
+ }
+ }
+
+ .downtime-overrun {
+ background-color: @color-down;
+
+ &:before,
+ &:after {
+ background-color: @color-down;
+ }
+ }
+
+ .downtime-elapsed + .downtime-overrun {
+ &:before {
+ display: none;
+ }
+ }
+}
+
+.downtime-progress.progress-bar {
+ // This requires more specificity, otherwise it has no effect
+ .above,
+ .below {
+ padding: 2em 10%;
+ }
+}
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..f7adb22
--- /dev/null
+++ b/public/css/widget/migrate-popup.less
@@ -0,0 +1,163 @@
+#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 {
+ 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 {
+ margin-bottom: .5em;
+ color: @text-color-light;
+ }
+
+ & > button.close {
+ float: right;
+ margin-top: 1em;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ ul {
+ padding: 0;
+ margin: .5em 0 0;
+ list-style-type: none;
+ }
+
+ li {
+ margin: .5em 0;
+ display: flex;
+
+ &:last-of-type {
+ margin-bottom: 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 {
+ margin-top: 0.5em;
+ width: 100%;
+
+ .control-group {
+ display: flex;
+ align-items: center;
+
+ .control-label-group {
+ margin-right: .5em;
+ }
+
+ label {
+ margin-left: auto;
+ }
+ }
+ }
+ }
+}
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..a1a7b6e
--- /dev/null
+++ b/public/css/widget/performance-data-table.less
@@ -0,0 +1,57 @@
+/* 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;
+ }
+
+ .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-badge.less b/public/css/widget/state-badge.less
new file mode 100644
index 0000000..2f81c1e
--- /dev/null
+++ b/public/css/widget/state-badge.less
@@ -0,0 +1,47 @@
+.state-badge {
+ .rounded-corners();
+ color: @text-color-on-icinga-blue;
+ display: inline-block;
+ font-size: 1em;
+ min-width: 2em;
+ padding: .25em;
+ text-align: center;
+
+ &.handled {
+ opacity: .8;
+ }
+
+ &.state-critical {
+ background-color: @color-critical;
+ }
+
+ &.state-down {
+ background-color: @color-down;
+ }
+
+ &.state-ok {
+ background-color: @color-ok;
+ }
+
+ &.state-pending {
+ background-color: @color-pending;
+ }
+
+ &.state-unknown {
+ background-color: @color-unknown;
+ }
+
+ &.state-up {
+ background-color: @color-up;
+ }
+
+ &.state-warning {
+ background-color: @color-warning;
+ }
+}
+
+a .state-badge {
+ &:not(.disabled):hover {
+ filter: brightness(80%);
+ }
+}
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/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..e980ef3
--- /dev/null
+++ b/public/js/action-list.js
@@ -0,0 +1,212 @@
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+;(function () {
+
+ "use strict";
+
+ /**
+ * Parse the filter query contained in the given URL query string
+ *
+ * @param {string} queryString
+ *
+ * @returns {array}
+ */
+ var parseSelectionQuery = function (queryString) {
+ return queryString.split('|');
+ };
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ var ActionList = function (icinga) {
+ Icinga.EventListener.call(this, 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', this.onColumnClose, this);
+
+ this.on('rendered', '.container', this.onRendered, this);
+ };
+
+ ActionList.prototype = new Icinga.EventListener();
+
+ ActionList.prototype.onClick = function (event) {
+ var _this = event.data.self;
+ var $activeItems;
+ var $target = $(event.currentTarget);
+ var $item = $target.closest('[data-action-item]');
+ var $list = $item.closest('.action-list');
+
+ if ($target.is('a') && (! $target.is('.subject') || event.ctrlKey || event.metaKey)) {
+ return true;
+ }
+
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ event.stopPropagation();
+
+ var container = $list.closest('.container');
+
+ if ($list.is('[data-icinga-multiselect-url]')) {
+ if (event.ctrlKey || event.metaKey) {
+ $item.toggleClass('active');
+ } else if (event.shiftKey) {
+ document.getSelection().removeAllRanges();
+
+ $activeItems = $list.find('[data-action-item].active');
+
+ var $firstActiveItem = $activeItems.first();
+
+ $activeItems.removeClass('active');
+
+ $firstActiveItem.addClass('active');
+ $item.addClass('active');
+
+ if ($item.index() > $firstActiveItem.index()) {
+ $item.prevUntil($firstActiveItem).addClass('active');
+ } else {
+ var $lastActiveItem = $activeItems.last();
+
+ $lastActiveItem.addClass('active');
+ $item.nextUntil($lastActiveItem).addClass('active');
+ }
+ } else {
+ $list.find('[data-action-item].active').removeClass('active');
+ $item.addClass('active');
+ }
+
+ // For items that do not have a bottom status bar like Downtimes, Comments...
+ if (! container.children('.footer').length) {
+ container.append('<div class="footer" data-action-list-automatically-added></div>');
+ }
+ } else {
+ $list.find('[data-action-item].active').removeClass('active');
+ $item.addClass('active');
+ }
+
+ $activeItems = $list.find('[data-action-item].active');
+ var footer = container.children('.footer');
+
+ if ($activeItems.length === 0) {
+ if (footer.length) {
+ if (typeof footer.data('action-list-automatically-added') !== 'undefined') {
+ footer.remove();
+ } else {
+ footer.children('.selection-count').remove();
+ }
+ }
+
+ if (_this.icinga.loader.getLinkTargetFor($target).attr('id') === 'col2') {
+ _this.icinga.ui.layout1col();
+ }
+ } else {
+ var url;
+
+ if ($activeItems.length === 1) {
+ url = $target.is('a') ? $target.attr('href') : $activeItems.find('[href]').first().attr('href');
+ } else {
+ var filters = $activeItems.map(function () {
+ return $(this).attr('data-icinga-multiselect-filter');
+ });
+
+ url = $list.attr('data-icinga-multiselect-url') + '?' + filters.toArray().join('|');
+ }
+
+ if ($list.is('[data-icinga-multiselect-url]')) {
+ if (! footer.children('.selection-count').length) {
+ footer.prepend('<div class="selection-count"></div>');
+ }
+
+ var label = $list.data('icinga-multiselect-count-label').replace('%d', $activeItems.length);
+ var selectedItems = footer.find('.selection-count > .selected-items');
+ if (selectedItems.length) {
+ selectedItems.text(label);
+ } else {
+ footer.children('.selection-count').append('<span class="selected-items">' + label + '</span>');
+ }
+ }
+
+ _this.icinga.loader.loadUrl(
+ url, _this.icinga.loader.getLinkTargetFor($target)
+ );
+ }
+ };
+
+ ActionList.prototype.onColumnClose = function (event) {
+ var $target = $(event.target);
+
+ if ($target.attr('id') !== 'col2') {
+ return;
+ }
+
+ var $list = $('#col1').find('.action-list');
+ if ($list.length && $list.is('[data-icinga-multiselect-url]')) {
+ var _this = event.data.self;
+ var detailUrl = _this.icinga.utils.parseUrl(_this.icinga.history.getCol2State().replace(/^#!/, ''));
+
+ if ($list.attr('data-icinga-multiselect-url') === detailUrl.path) {
+ $.each(parseSelectionQuery(detailUrl.query.slice(1)), function (i, filter) {
+ $list.find(
+ '[data-icinga-multiselect-filter="' + filter + '"]'
+ ).removeClass('active');
+ });
+ } else if ($list.attr('data-icinga-detail-url') === detailUrl.path) {
+ $list.find(
+ '[data-icinga-detail-filter="' + detailUrl.query.slice(1) + '"]'
+ ).removeClass('active');
+ }
+
+ var footer = $list.closest('.container').children('.footer');
+
+ if (footer.length) {
+ if (typeof footer.data('action-list-automatically-added') !== 'undefined') {
+ footer.remove();
+ } else {
+ footer.children('.selection-count').remove();
+ }
+ }
+ }
+ };
+
+ ActionList.prototype.onRendered = function (event) {
+ var $target = $(event.target);
+
+ if ($target.attr('id') !== 'col1') {
+ return;
+ }
+
+ var $list = $target.find('.action-list');
+
+ if ($list.length && $list.is('[data-icinga-multiselect-url], [data-icinga-detail-url]')) {
+ var _this = event.data.self;
+ var detailUrl = _this.icinga.utils.parseUrl(_this.icinga.history.getCol2State().replace(/^#!/, ''));
+
+ if ($list.attr('data-icinga-multiselect-url') === detailUrl.path) {
+ $.each(parseSelectionQuery(detailUrl.query.slice(1)), function (i, filter) {
+ $list.find(
+ '[data-icinga-multiselect-filter="' + filter + '"]'
+ ).addClass('active');
+ });
+ } else if ($list.attr('data-icinga-detail-url') === detailUrl.path) {
+ $list.find(
+ '[data-icinga-detail-filter="' + detailUrl.query.slice(1) + '"]'
+ ).addClass('active');
+ }
+ }
+
+ if ($list.length && $list.is('[data-icinga-multiselect-url]')) {
+ var $activeItems = $list.find('[data-action-item].active');
+
+ if ($activeItems.length) {
+ if (! $target.children('.footer').length) {
+ $target.append('<div class="footer" data-action-list-automatically-added></div>');
+ }
+
+ var label = $list.data('icinga-multiselect-count-label').replace('%d', $activeItems.length);
+ $target.children('.footer').prepend(
+ '<div class="selection-count"><span class="selected-items">' + label + '</span></div>'
+ );
+ }
+ }
+ };
+
+ Icinga.Behaviors.ActionList = ActionList;
+}());
diff --git a/public/js/loadmore.js b/public/js/loadmore.js
new file mode 100644
index 0000000..964a7b5
--- /dev/null
+++ b/public/js/loadmore.js
@@ -0,0 +1,85 @@
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+;(function(Icinga, $) {
+
+ 'use strict';
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ /**
+ * Icinga DB Load More behavior.
+ *
+ * @param icinga {Icinga} The current Icinga Object
+ */
+ var LoadMore = function(icinga) {
+ Icinga.EventListener.call(this, icinga);
+
+ this.icinga = icinga;
+
+ this.on('click', '.show-more[data-no-icinga-ajax] a', this.onClick, this);
+ this.on('keypress', '.show-more[data-no-icinga-ajax] a', this.onKeyPress, this);
+ };
+
+ LoadMore.prototype = new Icinga.EventListener();
+
+ LoadMore.prototype.onClick = function(event) {
+ var _this = event.data.self;
+ var $anchor = $(event.target);
+ var $showMore = $anchor.parent();
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ var progressTimer = _this.icinga.timer.register(function () {
+ var label = $anchor.html();
+
+ 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.html(label + dots);
+ }, null, 250);
+
+ var url = $anchor.attr('href');
+ var req = _this.icinga.loader.loadUrl(
+ // Add showCompact, we don't want controls in paged results
+ _this.icinga.utils.addUrlFlag(url, 'showCompact'),
+ $showMore.parent(),
+ 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 false;
+ };
+
+ LoadMore.prototype.onKeyPress = function(event) {
+ if (event.which === 32) {
+ event.data.self.onClick(event);
+ }
+ };
+
+ Icinga.Behaviors.LoadMore = LoadMore;
+
+})(Icinga, jQuery);
diff --git a/public/js/migrate.js b/public/js/migrate.js
new file mode 100644
index 0000000..4198e16
--- /dev/null
+++ b/public/js/migrate.js
@@ -0,0 +1,585 @@
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+;(function(Icinga, $) {
+
+ 'use strict';
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ const ANIMATION_LENGTH = 350;
+
+ const POPUP_HTML = '<div class="icinga-module module-icingadb">\n' +
+ ' <div id="migrate-popup">\n' +
+ ' <div class="suggestion-area">\n' +
+ ' <p>Preview this in Icinga DB</p>\n' +
+ ' <ul></ul>\n' +
+ ' <button type="button" class="close">Don\'t show this again</button>\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 DB Migration behavior.
+ *
+ * @param icinga {Icinga} The current Icinga Object
+ */
+ var Migrate = function(icinga) {
+ Icinga.EventListener.call(this, icinga);
+
+ this.icinga = icinga;
+ this.knownMigrations = {};
+ this.knownBackendSupport = {};
+ this.urlMigrationReadyState = null;
+ this.backendSupportReadyState = 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);
+ };
+
+ Migrate.prototype = new Icinga.EventListener();
+
+ Migrate.prototype.update = function (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;
+ });
+ };
+
+ Migrate.prototype.onRendered = function(event) {
+ var _this = event.data.self;
+ var $target = $(event.target);
+
+ 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;
+ }
+
+ 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;
+ }
+
+ 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);
+ };
+
+ Migrate.prototype.prepareMigration = function($target) {
+ let urls = {};
+ let modules = {}
+
+ $target.each((_, container) => {
+ let $container = $(container);
+ let href = $container.data('icingaUrl');
+ let containerId = $container.attr('id');
+
+ if (typeof href !== 'undefined' && href.match(this.isMonitoringUrl)) {
+ if (
+ typeof this.previousMigrations[containerId] !== 'undefined'
+ && this.previousMigrations[containerId] === href
+ ) {
+ delete this.previousMigrations[containerId];
+ } else {
+ urls[containerId] = href;
+ }
+ }
+
+ let moduleName = $container.data('icingaModule');
+ if (!! moduleName && moduleName !== 'default' && moduleName !== 'monitoring' && moduleName !== 'icingadb') {
+ modules[containerId] = moduleName;
+ }
+ });
+
+ if (Object.keys(urls).length) {
+ this.setUrlMigrationReadyState(false);
+ this.migrateMonitoringUrls(urls);
+ } else {
+ this.setUrlMigrationReadyState(null);
+ }
+
+ if (Object.keys(modules).length) {
+ this.setBackendSupportReadyState(false);
+ this.prepareBackendCheckboxForm(modules);
+ } else {
+ this.setBackendSupportReadyState(null);
+ }
+
+ if (this.urlMigrationReadyState === null && this.backendSupportReadyState === null) {
+ this.cleanupPopup();
+ }
+ };
+
+ Migrate.prototype.onColumnClose = function(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 = $container.data('icingaUrl');
+ }
+
+ if (suggestionUrl !== containerUrl) {
+ var $newContainer = $('#main > .container').filter(function () {
+ return $(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();
+ };
+
+ Migrate.prototype.onClose = function(event) {
+ var _this = event.data.self;
+ _this.tempStorage.set('closed', true);
+ _this.hidePopup();
+ };
+
+ Migrate.prototype.onDecision = function(event) {
+ var _this = event.data.self;
+ var $button = $(event.target).closest('button');
+ var $suggestion = $button.parent();
+ var $container = $('#' + $suggestion.data('containerId'));
+ var containerUrl = $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.hidePopup(function () {
+ // Let the transition finish first, looks cleaner
+ $suggestion.remove();
+ });
+ } else {
+ $suggestion.remove();
+ }
+ };
+
+ Migrate.prototype.onHandleClicked = function(event) {
+ var _this = event.data.self;
+ if (_this.togglePopup()) {
+ _this.storage.set('minimized', true);
+ } else {
+ _this.storage.remove('minimized');
+ }
+ };
+
+ Migrate.prototype.onMinimized = function(isMinimized, oldValue) {
+ if (isMinimized && isMinimized !== oldValue && this.isShown()) {
+ this.minimizePopup();
+ }
+ };
+
+ Migrate.prototype.migrateMonitoringUrls = function(urls) {
+ var _this = this,
+ containerIds = [],
+ containerUrls = [];
+
+ $.each(urls, function (containerId, containerUrl) {
+ if (typeof _this.knownMigrations[containerUrl] === 'undefined') {
+ containerUrls.push(containerUrl);
+ containerIds.push(containerId);
+ }
+ });
+
+ if (containerUrls.length) {
+ var req = $.ajax({
+ context : this,
+ type : 'post',
+ url : this.icinga.config.baseUrl + '/icingadb/migrate/monitoring-url',
+ headers : { 'Accept': 'application/json' },
+ contentType : 'application/json',
+ data : JSON.stringify(containerUrls)
+ });
+
+ req.urls = urls;
+ req.urlIndexToContainerId = containerIds;
+ req.done(this.processUrlMigrationResults);
+ req.always(() => this.changeUrlMigrationReadyState(true));
+ } else {
+ // All urls have already been migrated once, show popup immediately
+ this.addSuggestions(urls);
+ this.changeUrlMigrationReadyState(true);
+ }
+ };
+
+ Migrate.prototype.processUrlMigrationResults = function(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);
+ };
+
+ Migrate.prototype.prepareBackendCheckboxForm = function(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);
+ }
+ };
+
+ Migrate.prototype.processBackendSupportResults = function(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);
+ };
+
+ Migrate.prototype.setupBackendCheckboxForm = function(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);
+ }
+ };
+
+ Migrate.prototype.setCheckboxState = function(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('.suggestion-area > ul').after($form);
+ } else {
+ let $newForm = $(html);
+ $form.find('[name=backend]').prop('checked', $newForm.find('[name=backend]').is(':checked'));
+ }
+
+ this.showPopup();
+ }
+
+ Migrate.prototype.addSuggestions = function(urls) {
+ var _this = this,
+ hasSuggestions = false,
+ $ul = this.Popup().find('.suggestion-area > ul');
+ $.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();
+ }
+ };
+
+ Migrate.prototype.cleanupSuggestions = function() {
+ var _this = this,
+ toBeRemoved = [];
+ this.Popup().find('li').each(function () {
+ var $suggestion = $(this);
+ var $container = $('#' + $suggestion.data('containerId'));
+ var containerUrl = $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]
+ ) {
+ toBeRemoved.push($suggestion);
+ }
+ });
+
+ return toBeRemoved;
+ };
+
+ Migrate.prototype.cleanupBackendForm = function () {
+ 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;
+ };
+
+ Migrate.prototype.cleanupPopup = function () {
+ let toBeRemoved = this.cleanupSuggestions();
+ let hasBackendForm = this.cleanupBackendForm();
+
+ if (hasBackendForm !== true && this.Popup().find('li').length === toBeRemoved.length) {
+ this.hidePopup(function () {
+ // 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();
+ }
+ }
+ };
+
+ Migrate.prototype.showPopup = function() {
+ var $popup = this.Popup();
+ if (this.storage.get('minimized')) {
+ $popup.addClass('active minimized hidden');
+ } else {
+ $popup.addClass('active');
+ }
+ };
+
+ Migrate.prototype.hidePopup = function (after) {
+ this.Popup().removeClass('active minimized hidden');
+
+ if (typeof after === 'function') {
+ setTimeout(after, ANIMATION_LENGTH);
+ }
+ };
+
+ Migrate.prototype.isShown = function() {
+ return this.Popup().is('.active');
+ };
+
+ Migrate.prototype.minimizePopup = function() {
+ var $popup = this.Popup();
+ $popup.addClass('minimized');
+ setTimeout(function () {
+ $popup.addClass('hidden');
+ }, ANIMATION_LENGTH);
+ };
+
+ Migrate.prototype.maximizePopup = function() {
+ this.Popup().removeClass('minimized hidden');
+ };
+
+ Migrate.prototype.togglePopup = function() {
+ if (this.Popup().is('.minimized')) {
+ this.maximizePopup();
+ return false;
+ } else {
+ this.minimizePopup();
+ return true;
+ }
+ };
+
+ Migrate.prototype.setUrlMigrationReadyState = function (state) {
+ this.urlMigrationReadyState = state;
+ };
+
+ Migrate.prototype.changeUrlMigrationReadyState = function (state) {
+ this.setUrlMigrationReadyState(state);
+
+ if (this.backendSupportReadyState !== false) {
+ this.backendSupportReadyState = null;
+ this.urlMigrationReadyState = null;
+ this.cleanupPopup();
+ }
+ };
+
+ Migrate.prototype.setBackendSupportReadyState = function (state) {
+ this.backendSupportReadyState = state;
+ };
+
+ Migrate.prototype.changeBackendSupportReadyState = function (state) {
+ this.setBackendSupportReadyState(state);
+
+ if (this.urlMigrationReadyState !== false) {
+ this.backendSupportReadyState = null;
+ this.urlMigrationReadyState = null;
+ this.cleanupPopup();
+ }
+ };
+
+ Migrate.prototype.Popup = function() {
+ // 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/run.php b/run.php
new file mode 100644
index 0000000..eb9c71d
--- /dev/null
+++ b/run.php
@@ -0,0 +1,34 @@
+<?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/ServiceSlaReport');
+
+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/library/Icingadb/Common/MacrosTest.php b/test/php/library/Icingadb/Common/MacrosTest.php
new file mode 100644
index 0000000..dd01a01
--- /dev/null
+++ b/test/php/library/Icingadb/Common/MacrosTest.php
@@ -0,0 +1,105 @@
+<?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\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()
+ {
+ $mock = \Mockery::mock(Host::class);
+ $mock->name = 'test';
+ $mock->address = '1.1.1.1';
+ $mock->address6 = '::1';
+ $mock->vars = self::VARS;
+
+ $mock->hostgroup = new Query();
+
+ $this->assertEquals($mock->name, $this->expandMacros('$host.name$', $mock));
+ $this->assertEquals($mock->name, $this->expandMacros('$name$', $mock));
+ $this->assertEquals($mock->address, $this->expandMacros('$host.address$', $mock));
+ $this->assertEquals($mock->address6, $this->expandMacros('$host.address6$', $mock));
+
+ // A Host can have more than one hostgroups
+ $this->assertEquals('$host.hostgroup$', $this->expandMacros('$host.hostgroup$', $mock));
+ $this->assertEquals('$host.hostgroup.name$', $this->expandMacros('$host.hostgroup.name$', $mock));
+
+ // Host custom vars
+ $this->assertEquals($mock->vars['os'], $this->expandMacros('$host.vars.os$', $mock));
+ $this->assertEquals($mock->vars['os'], $this->expandMacros('$vars.os$', $mock));
+ $this->assertEquals($mock->vars['days[2]'], $this->expandMacros('$vars.days[2]$', $mock));
+ $this->assertEquals($mock->vars['days[4]'], $this->expandMacros('$host.vars.days[4]$', $mock));
+
+ // Host to service relation
+ $this->assertEquals('$service.name$', $this->expandMacros('$service.name$', $mock));
+ $this->assertEquals('$service.address$', $this->expandMacros('$service.address$', $mock));
+
+ // Service custom vars
+ $this->assertEquals('$service.vars.os$', $this->expandMacros('$service.vars.os$', $mock));
+ $this->assertEquals('$service.vars.days[0]$', $this->expandMacros('$service.vars.days[0]$', $mock));
+ $this->assertEquals('$service.vars.days[2]$', $this->expandMacros('$service.vars.days[2]$', $mock));
+ }
+
+ public function testServiceMacros()
+ {
+ $mock = \Mockery::mock(Service::class);
+ $mock->name = 'test-service';
+ $mock->description = 'A test service';
+ $mock->vars = self::VARS;
+
+ $mock->servicegroup = new Query();
+
+ $hostMock = \Mockery::mock(Host::class);
+ $hostMock->name = 'test';
+ $hostMock->address = '1.1.1.1';
+ $hostMock->hostgroup = new ResultSet(new \ArrayIterator());
+ $hostMock->vars = self::VARS;
+
+ $mock->host = $hostMock;
+
+ $this->assertEquals($mock->name, $this->expandMacros('$service.name$', $mock));
+ $this->assertEquals($mock->name, $this->expandMacros('$name$', $mock));
+ $this->assertEquals($mock->description, $this->expandMacros('$service.description$', $mock));
+
+ // A Service can have more than one hostgroups
+ $this->assertEquals('$service.servicegroup$', $this->expandMacros('$service.servicegroup$', $mock));
+ $this->assertEquals('$service.servicegroup.name$', $this->expandMacros('$service.servicegroup.name$', $mock));
+
+ // Service custom vars
+ $this->assertEquals($mock->vars['os'], $this->expandMacros('$service.vars.os$', $mock));
+ $this->assertEquals($mock->vars['os'], $this->expandMacros('$vars.os$', $mock));
+ $this->assertEquals($mock->vars['days[2]'], $this->expandMacros('$vars.days[2]$', $mock));
+ $this->assertEquals($mock->vars['days[4]'], $this->expandMacros('$service.vars.days[4]$', $mock));
+
+ $this->assertEquals($hostMock->name, $this->expandMacros('$host.name$', $mock));
+ $this->assertEquals($hostMock->address, $this->expandMacros('$host.address$', $mock));
+
+ // Host custom vars
+ $this->assertEquals($hostMock->vars['os'], $this->expandMacros('$host.vars.os$', $mock));
+ $this->assertEquals($hostMock->vars['days[0]'], $this->expandMacros('$host.vars.days[0]$', $mock));
+ $this->assertEquals($hostMock->vars['days[3]'], $this->expandMacros('$host.vars.days[3]$', $mock));
+
+ // A Host can have more than one hostgroups
+ $this->assertEquals('$host.hostgroup$', $this->expandMacros('$host.hostgroup$', $mock));
+ $this->assertEquals('$host.hostgroup.name$', $this->expandMacros('$host.hostgroup.name$', $mock));
+ }
+}