summaryrefslogtreecommitdiffstats
path: root/library
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Icingadb/Authentication/ObjectAuthorization.php261
-rw-r--r--library/Icingadb/Command/IcingaApiCommand.php128
-rw-r--r--library/Icingadb/Command/IcingaCommand.php22
-rw-r--r--library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php109
-rw-r--r--library/Icingadb/Command/Object/AcknowledgeProblemCommand.php140
-rw-r--r--library/Icingadb/Command/Object/AddCommentCommand.php42
-rw-r--r--library/Icingadb/Command/Object/CommandAuthor.php45
-rw-r--r--library/Icingadb/Command/Object/DeleteCommentCommand.php13
-rw-r--r--library/Icingadb/Command/Object/DeleteDowntimeCommand.php13
-rw-r--r--library/Icingadb/Command/Object/GetObjectCommand.php35
-rw-r--r--library/Icingadb/Command/Object/ObjectsCommand.php67
-rw-r--r--library/Icingadb/Command/Object/ProcessCheckResultCommand.php140
-rw-r--r--library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php42
-rw-r--r--library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php13
-rw-r--r--library/Icingadb/Command/Object/ScheduleCheckCommand.php86
-rw-r--r--library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php42
-rw-r--r--library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php196
-rw-r--r--library/Icingadb/Command/Object/SendCustomNotificationCommand.php44
-rw-r--r--library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php108
-rw-r--r--library/Icingadb/Command/Object/WithCommentCommand.php50
-rw-r--r--library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php353
-rw-r--r--library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php12
-rw-r--r--library/Icingadb/Command/Transport/ApiCommandException.php14
-rw-r--r--library/Icingadb/Command/Transport/ApiCommandTransport.php353
-rw-r--r--library/Icingadb/Command/Transport/CommandTransport.php130
-rw-r--r--library/Icingadb/Command/Transport/CommandTransportConfig.php31
-rw-r--r--library/Icingadb/Command/Transport/CommandTransportException.php14
-rw-r--r--library/Icingadb/Command/Transport/CommandTransportInterface.php23
-rw-r--r--library/Icingadb/Common/Auth.php389
-rw-r--r--library/Icingadb/Common/BaseFilter.php13
-rw-r--r--library/Icingadb/Common/BaseStatusBar.php55
-rw-r--r--library/Icingadb/Common/CaptionDisabled.php31
-rw-r--r--library/Icingadb/Common/CommandActions.php308
-rw-r--r--library/Icingadb/Common/Database.php102
-rw-r--r--library/Icingadb/Common/DetailActions.php145
-rw-r--r--library/Icingadb/Common/HostLink.php27
-rw-r--r--library/Icingadb/Common/HostLinks.php76
-rw-r--r--library/Icingadb/Common/HostStates.php107
-rw-r--r--library/Icingadb/Common/IcingaRedis.php323
-rw-r--r--library/Icingadb/Common/Icons.php30
-rw-r--r--library/Icingadb/Common/Links.php143
-rw-r--r--library/Icingadb/Common/ListItemCommonLayout.php26
-rw-r--r--library/Icingadb/Common/ListItemDetailedLayout.php23
-rw-r--r--library/Icingadb/Common/ListItemMinimalLayout.php26
-rw-r--r--library/Icingadb/Common/LoadMore.php111
-rw-r--r--library/Icingadb/Common/Macros.php120
-rw-r--r--library/Icingadb/Common/NoSubjectLink.php35
-rw-r--r--library/Icingadb/Common/ObjectInspectionDetail.php348
-rw-r--r--library/Icingadb/Common/ObjectLinkDisabled.php35
-rw-r--r--library/Icingadb/Common/SearchControls.php69
-rw-r--r--library/Icingadb/Common/ServiceLink.php40
-rw-r--r--library/Icingadb/Common/ServiceLinks.php108
-rw-r--r--library/Icingadb/Common/ServiceStates.php129
-rw-r--r--library/Icingadb/Common/StateBadges.php195
-rw-r--r--library/Icingadb/Common/TicketLinks.php56
-rw-r--r--library/Icingadb/Common/ViewMode.php35
-rw-r--r--library/Icingadb/Compat/CompatHost.php103
-rw-r--r--library/Icingadb/Compat/CompatObject.php373
-rw-r--r--library/Icingadb/Compat/CompatService.php156
-rw-r--r--library/Icingadb/Compat/UrlMigrator.php1353
-rw-r--r--library/Icingadb/Data/CsvResultSet.php85
-rw-r--r--library/Icingadb/Data/JsonResultSet.php80
-rw-r--r--library/Icingadb/Data/PivotTable.php441
-rw-r--r--library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php83
-rw-r--r--library/Icingadb/Hook/Common/HookUtils.php39
-rw-r--r--library/Icingadb/Hook/Common/TotalSlaReportUtils.php56
-rw-r--r--library/Icingadb/Hook/CustomVarRendererHook.php102
-rw-r--r--library/Icingadb/Hook/EventDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php146
-rw-r--r--library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php121
-rw-r--r--library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php101
-rw-r--r--library/Icingadb/Hook/HostActionsHook.php21
-rw-r--r--library/Icingadb/Hook/HostDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Hook/HostsDetailExtensionHook.php28
-rw-r--r--library/Icingadb/Hook/IcingadbSupportHook.php52
-rw-r--r--library/Icingadb/Hook/PluginOutputHook.php63
-rw-r--r--library/Icingadb/Hook/ServiceActionsHook.php21
-rw-r--r--library/Icingadb/Hook/ServiceDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Hook/ServicesDetailExtensionHook.php28
-rw-r--r--library/Icingadb/Hook/TabHook.php82
-rw-r--r--library/Icingadb/Hook/TabHook/HookActions.php148
-rw-r--r--library/Icingadb/Hook/UserDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Hook/UsergroupDetailExtensionHook.php21
-rw-r--r--library/Icingadb/Model/AcknowledgementHistory.php102
-rw-r--r--library/Icingadb/Model/ActionUrl.php62
-rw-r--r--library/Icingadb/Model/Behavior/ActionAndNoteUrl.php52
-rw-r--r--library/Icingadb/Model/Behavior/Bitmask.php83
-rw-r--r--library/Icingadb/Model/Behavior/BoolCast.php31
-rw-r--r--library/Icingadb/Model/Behavior/FlattenedObjectVars.php77
-rw-r--r--library/Icingadb/Model/Behavior/ReRoute.php83
-rw-r--r--library/Icingadb/Model/Checkcommand.php86
-rw-r--r--library/Icingadb/Model/CheckcommandArgument.php75
-rw-r--r--library/Icingadb/Model/CheckcommandCustomvar.php52
-rw-r--r--library/Icingadb/Model/CheckcommandEnvvar.php61
-rw-r--r--library/Icingadb/Model/Comment.php114
-rw-r--r--library/Icingadb/Model/CommentHistory.php107
-rw-r--r--library/Icingadb/Model/Customvar.php72
-rw-r--r--library/Icingadb/Model/CustomvarFlat.php167
-rw-r--r--library/Icingadb/Model/Downtime.php144
-rw-r--r--library/Icingadb/Model/DowntimeHistory.php128
-rw-r--r--library/Icingadb/Model/Endpoint.php69
-rw-r--r--library/Icingadb/Model/Environment.php105
-rw-r--r--library/Icingadb/Model/Eventcommand.php86
-rw-r--r--library/Icingadb/Model/EventcommandArgument.php75
-rw-r--r--library/Icingadb/Model/EventcommandCustomvar.php52
-rw-r--r--library/Icingadb/Model/EventcommandEnvvar.php61
-rw-r--r--library/Icingadb/Model/FlappingHistory.php91
-rw-r--r--library/Icingadb/Model/History.php127
-rw-r--r--library/Icingadb/Model/Host.php234
-rw-r--r--library/Icingadb/Model/HostCustomvar.php52
-rw-r--r--library/Icingadb/Model/HostState.php81
-rw-r--r--library/Icingadb/Model/Hostgroup.php92
-rw-r--r--library/Icingadb/Model/HostgroupCustomvar.php52
-rw-r--r--library/Icingadb/Model/HostgroupMember.php53
-rw-r--r--library/Icingadb/Model/Hostgroupsummary.php213
-rw-r--r--library/Icingadb/Model/HoststateSummary.php78
-rw-r--r--library/Icingadb/Model/IconImage.php55
-rw-r--r--library/Icingadb/Model/Instance.php78
-rw-r--r--library/Icingadb/Model/LastHostComment.php19
-rw-r--r--library/Icingadb/Model/LastServiceComment.php19
-rw-r--r--library/Icingadb/Model/NotesUrl.php62
-rw-r--r--library/Icingadb/Model/Notification.php130
-rw-r--r--library/Icingadb/Model/NotificationCustomvar.php52
-rw-r--r--library/Icingadb/Model/NotificationHistory.php115
-rw-r--r--library/Icingadb/Model/NotificationUser.php49
-rw-r--r--library/Icingadb/Model/NotificationUsergroup.php49
-rw-r--r--library/Icingadb/Model/Notificationcommand.php87
-rw-r--r--library/Icingadb/Model/NotificationcommandArgument.php75
-rw-r--r--library/Icingadb/Model/NotificationcommandCustomvar.php52
-rw-r--r--library/Icingadb/Model/NotificationcommandEnvvar.php61
-rw-r--r--library/Icingadb/Model/Service.php225
-rw-r--r--library/Icingadb/Model/ServiceCustomvar.php52
-rw-r--r--library/Icingadb/Model/ServiceState.php76
-rw-r--r--library/Icingadb/Model/Servicegroup.php91
-rw-r--r--library/Icingadb/Model/ServicegroupCustomvar.php52
-rw-r--r--library/Icingadb/Model/ServicegroupMember.php49
-rw-r--r--library/Icingadb/Model/ServicegroupSummary.php167
-rw-r--r--library/Icingadb/Model/ServicestateSummary.php99
-rw-r--r--library/Icingadb/Model/State.php177
-rw-r--r--library/Icingadb/Model/StateHistory.php101
-rw-r--r--library/Icingadb/Model/Timeperiod.php91
-rw-r--r--library/Icingadb/Model/TimeperiodCustomvar.php52
-rw-r--r--library/Icingadb/Model/TimeperiodOverrideExclude.php51
-rw-r--r--library/Icingadb/Model/TimeperiodOverrideInclude.php51
-rw-r--r--library/Icingadb/Model/TimeperiodRange.php58
-rw-r--r--library/Icingadb/Model/User.php134
-rw-r--r--library/Icingadb/Model/UserCustomvar.php52
-rw-r--r--library/Icingadb/Model/Usergroup.php95
-rw-r--r--library/Icingadb/Model/UsergroupCustomvar.php52
-rw-r--r--library/Icingadb/Model/UsergroupMember.php49
-rw-r--r--library/Icingadb/Model/Vars.php28
-rw-r--r--library/Icingadb/Model/Zone.php82
-rw-r--r--library/Icingadb/ProvidedHook/ApplicationState.php111
-rw-r--r--library/Icingadb/ProvidedHook/CreateHostSlaReport.php37
-rw-r--r--library/Icingadb/ProvidedHook/CreateHostsSlaReport.php39
-rw-r--r--library/Icingadb/ProvidedHook/CreateServiceSlaReport.php40
-rw-r--r--library/Icingadb/ProvidedHook/CreateServicesSlaReport.php38
-rw-r--r--library/Icingadb/ProvidedHook/IcingaHealth.php115
-rw-r--r--library/Icingadb/ProvidedHook/RedisHealth.php55
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php68
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php72
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/SlaReport.php297
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php19
-rw-r--r--library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php19
-rw-r--r--library/Icingadb/ProvidedHook/X509/Sni.php55
-rw-r--r--library/Icingadb/Redis/VolatileStateResults.php170
-rw-r--r--library/Icingadb/Setup/ApiTransportPage.php128
-rw-r--r--library/Icingadb/Setup/ApiTransportStep.php102
-rw-r--r--library/Icingadb/Setup/DbResourcePage.php145
-rw-r--r--library/Icingadb/Setup/DbResourceStep.php148
-rw-r--r--library/Icingadb/Setup/IcingaDbWizard.php89
-rw-r--r--library/Icingadb/Setup/RedisPage.php68
-rw-r--r--library/Icingadb/Setup/RedisStep.php205
-rw-r--r--library/Icingadb/Setup/WelcomePage.php56
-rw-r--r--library/Icingadb/Util/FeatureStatus.php50
-rw-r--r--library/Icingadb/Util/ObjectSuggestionsCursor.php25
-rw-r--r--library/Icingadb/Util/PerfData.php703
-rw-r--r--library/Icingadb/Util/PerfDataFormat.php171
-rw-r--r--library/Icingadb/Util/PerfDataSet.php172
-rw-r--r--library/Icingadb/Util/PluginOutput.php260
-rw-r--r--library/Icingadb/Util/ThresholdRange.php213
-rw-r--r--library/Icingadb/Web/Control/GridViewModeSwitcher.php38
-rw-r--r--library/Icingadb/Web/Control/ProblemToggle.php74
-rw-r--r--library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php406
-rw-r--r--library/Icingadb/Web/Control/ViewModeSwitcher.php219
-rw-r--r--library/Icingadb/Web/Controller.php542
-rw-r--r--library/Icingadb/Web/Navigation/Action.php134
-rw-r--r--library/Icingadb/Web/Navigation/IcingadbHostAction.php9
-rw-r--r--library/Icingadb/Web/Navigation/IcingadbServiceAction.php9
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php35
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php173
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php36
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php66
-rw-r--r--library/Icingadb/Widget/AttemptBall.php31
-rw-r--r--library/Icingadb/Widget/CheckAttempt.php54
-rw-r--r--library/Icingadb/Widget/Detail/CheckStatistics.php373
-rw-r--r--library/Icingadb/Widget/Detail/CommentDetail.php140
-rw-r--r--library/Icingadb/Widget/Detail/CustomVarTable.php268
-rw-r--r--library/Icingadb/Widget/Detail/DowntimeCard.php258
-rw-r--r--library/Icingadb/Widget/Detail/DowntimeDetail.php206
-rw-r--r--library/Icingadb/Widget/Detail/EventDetail.php651
-rw-r--r--library/Icingadb/Widget/Detail/HostDetail.php58
-rw-r--r--library/Icingadb/Widget/Detail/HostInspectionDetail.php21
-rw-r--r--library/Icingadb/Widget/Detail/HostMetaInfo.php77
-rw-r--r--library/Icingadb/Widget/Detail/HostStatistics.php61
-rw-r--r--library/Icingadb/Widget/Detail/MultiselectQuickActions.php194
-rw-r--r--library/Icingadb/Widget/Detail/ObjectDetail.php596
-rw-r--r--library/Icingadb/Widget/Detail/ObjectStatistics.php59
-rw-r--r--library/Icingadb/Widget/Detail/ObjectsDetail.php190
-rw-r--r--library/Icingadb/Widget/Detail/PerfDataTable.php130
-rw-r--r--library/Icingadb/Widget/Detail/QuickActions.php148
-rw-r--r--library/Icingadb/Widget/Detail/ServiceDetail.php37
-rw-r--r--library/Icingadb/Widget/Detail/ServiceInspectionDetail.php21
-rw-r--r--library/Icingadb/Widget/Detail/ServiceMetaInfo.php61
-rw-r--r--library/Icingadb/Widget/Detail/ServiceStatistics.php64
-rw-r--r--library/Icingadb/Widget/Detail/UserDetail.php188
-rw-r--r--library/Icingadb/Widget/Detail/UsergroupDetail.php98
-rw-r--r--library/Icingadb/Widget/Health.php66
-rw-r--r--library/Icingadb/Widget/HostStateBadges.php45
-rw-r--r--library/Icingadb/Widget/HostStatusBar.php21
-rw-r--r--library/Icingadb/Widget/HostSummaryDonut.php78
-rw-r--r--library/Icingadb/Widget/IconImage.php74
-rw-r--r--library/Icingadb/Widget/ItemList/BaseCommentListItem.php131
-rw-r--r--library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php212
-rw-r--r--library/Icingadb/Widget/ItemList/BaseHistoryListItem.php405
-rw-r--r--library/Icingadb/Widget/ItemList/BaseHostListItem.php56
-rw-r--r--library/Icingadb/Widget/ItemList/BaseNotificationListItem.php189
-rw-r--r--library/Icingadb/Widget/ItemList/BaseServiceListItem.php70
-rw-r--r--library/Icingadb/Widget/ItemList/CommandTransportList.php26
-rw-r--r--library/Icingadb/Widget/ItemList/CommandTransportListItem.php71
-rw-r--r--library/Icingadb/Widget/ItemList/CommentList.php49
-rw-r--r--library/Icingadb/Widget/ItemList/CommentListItem.php12
-rw-r--r--library/Icingadb/Widget/ItemList/CommentListItemMinimal.php21
-rw-r--r--library/Icingadb/Widget/ItemList/DowntimeList.php49
-rw-r--r--library/Icingadb/Widget/ItemList/DowntimeListItem.php23
-rw-r--r--library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php21
-rw-r--r--library/Icingadb/Widget/ItemList/HistoryList.php57
-rw-r--r--library/Icingadb/Widget/ItemList/HistoryListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php18
-rw-r--r--library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php27
-rw-r--r--library/Icingadb/Widget/ItemList/HostDetailHeader.php67
-rw-r--r--library/Icingadb/Widget/ItemList/HostList.php39
-rw-r--r--library/Icingadb/Widget/ItemList/HostListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/HostListItemDetailed.php108
-rw-r--r--library/Icingadb/Widget/ItemList/HostListItemMinimal.php18
-rw-r--r--library/Icingadb/Widget/ItemList/NotificationList.php55
-rw-r--r--library/Icingadb/Widget/ItemList/NotificationListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php18
-rw-r--r--library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php27
-rw-r--r--library/Icingadb/Widget/ItemList/PageSeparatorItem.php36
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceDetailHeader.php67
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceList.php36
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceListItem.php18
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php112
-rw-r--r--library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php18
-rw-r--r--library/Icingadb/Widget/ItemList/StateList.php60
-rw-r--r--library/Icingadb/Widget/ItemList/StateListItem.php140
-rw-r--r--library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php60
-rw-r--r--library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php60
-rw-r--r--library/Icingadb/Widget/ItemTable/BaseStateRowItem.php107
-rw-r--r--library/Icingadb/Widget/ItemTable/GridCellLayout.php39
-rw-r--r--library/Icingadb/Widget/ItemTable/HostItemTable.php31
-rw-r--r--library/Icingadb/Widget/ItemTable/HostRowItem.php51
-rw-r--r--library/Icingadb/Widget/ItemTable/HostgroupGridCell.php114
-rw-r--r--library/Icingadb/Widget/ItemTable/HostgroupTable.php38
-rw-r--r--library/Icingadb/Widget/ItemTable/HostgroupTableRow.php55
-rw-r--r--library/Icingadb/Widget/ItemTable/ServiceItemTable.php31
-rw-r--r--library/Icingadb/Widget/ItemTable/ServiceRowItem.php64
-rw-r--r--library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php204
-rw-r--r--library/Icingadb/Widget/ItemTable/ServicegroupTable.php38
-rw-r--r--library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php42
-rw-r--r--library/Icingadb/Widget/ItemTable/StateItemTable.php216
-rw-r--r--library/Icingadb/Widget/ItemTable/StateRowItem.php124
-rw-r--r--library/Icingadb/Widget/ItemTable/TableRowLayout.php26
-rw-r--r--library/Icingadb/Widget/ItemTable/UserTable.php27
-rw-r--r--library/Icingadb/Widget/ItemTable/UserTableRow.php61
-rw-r--r--library/Icingadb/Widget/ItemTable/UsergroupTable.php27
-rw-r--r--library/Icingadb/Widget/ItemTable/UsergroupTableRow.php61
-rw-r--r--library/Icingadb/Widget/MarkdownLine.php28
-rw-r--r--library/Icingadb/Widget/MarkdownText.php28
-rw-r--r--library/Icingadb/Widget/Notice.php36
-rw-r--r--library/Icingadb/Widget/PluginOutputContainer.php22
-rw-r--r--library/Icingadb/Widget/ServiceStateBadges.php46
-rw-r--r--library/Icingadb/Widget/ServiceStatusBar.php24
-rw-r--r--library/Icingadb/Widget/ServiceSummaryDonut.php81
-rw-r--r--library/Icingadb/Widget/ShowMore.php64
-rw-r--r--library/Icingadb/Widget/StateBadge.php10
-rw-r--r--library/Icingadb/Widget/StateChange.php133
-rw-r--r--library/Icingadb/Widget/TagList.php35
289 files changed, 29194 insertions, 0 deletions
diff --git a/library/Icingadb/Authentication/ObjectAuthorization.php b/library/Icingadb/Authentication/ObjectAuthorization.php
new file mode 100644
index 0000000..988e8f0
--- /dev/null
+++ b/library/Icingadb/Authentication/ObjectAuthorization.php
@@ -0,0 +1,261 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Authentication;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use InvalidArgumentException;
+use ipl\Orm\Compat\FilterProcessor;
+use ipl\Orm\Model;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+use ipl\Stdlib\Filter;
+
+class ObjectAuthorization
+{
+ use Auth;
+ use Database;
+
+ /** @var array */
+ protected static $knownGrants = [];
+
+ /**
+ * Caches already applied filters to an object
+ *
+ * @var array
+ */
+ protected static $matchedFilters = [];
+
+ /**
+ * Check whether the permission is granted on the object
+ *
+ * @param string $permission
+ * @param Model $for The object
+ *
+ * @return bool
+ */
+ public static function grantsOn(string $permission, Model $for): bool
+ {
+ $self = new static();
+
+ $tableName = $for->getTableName();
+ $uniqueId = $for->{$for->getKeyName()};
+ if (! isset($uniqueId)) {
+ return false;
+ }
+
+ if (! isset(self::$knownGrants[$tableName][$uniqueId])) {
+ $self->loadGrants(
+ get_class($for),
+ Filter::equal($for->getKeyName(), $uniqueId),
+ $uniqueId,
+ false
+ );
+ }
+
+ return $self->checkGrants($permission, self::$knownGrants[$tableName][$uniqueId]);
+ }
+
+ /**
+ * Check whether the permission is granted on objects matching the type and filter
+ *
+ * The check will be performed on every object matching the filter. Though the result
+ * only allows to determine whether the permission is granted on **any** or *none*
+ * of the objects in question. Any subsequent call to {@see ObjectAuthorization::grantsOn}
+ * will make use of the underlying results the check has determined in order to avoid
+ * unnecessary queries.
+ *
+ * @param string $permission
+ * @param string $type
+ * @param Filter\Rule $filter
+ * @param bool $cache Pass `false` to not perform the check on every object
+ *
+ * @return bool
+ */
+ public static function grantsOnType(string $permission, string $type, Filter\Rule $filter, bool $cache = true): bool
+ {
+ switch ($type) {
+ case 'host':
+ $for = Host::class;
+ break;
+ case 'service':
+ $for = Service::class;
+ break;
+ default:
+ throw new InvalidArgumentException(sprintf('Unknown type "%s"', $type));
+ }
+
+ $self = new static();
+
+ $uniqueId = spl_object_hash($filter);
+ if (! isset(self::$knownGrants[$type][$uniqueId])) {
+ $self->loadGrants($for, $filter, $uniqueId, $cache);
+ }
+
+ return $self->checkGrants($permission, self::$knownGrants[$type][$uniqueId]);
+ }
+
+ /**
+ * Check whether the given filter matches on the given object
+ *
+ * @param string $queryString
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public static function matchesOn(string $queryString, Model $object): bool
+ {
+ $self = new static();
+
+ $uniqueId = $object->{$object->getKeyName()};
+ if (! isset(self::$matchedFilters[$queryString][$uniqueId])) {
+ $restriction = 'icingadb/filter/services';
+ if ($object instanceof Host) {
+ $restriction = 'icingadb/filter/hosts';
+ }
+
+ $filter = $self->parseRestriction($queryString, $restriction);
+
+ $query = $object::on($self->getDb());
+ $query
+ ->filter($filter)
+ ->filter(Filter::equal($object->getKeyName(), $uniqueId))
+ ->columns([new Expression('1')]);
+
+ $result = $query->execute()->hasResult();
+ self::$matchedFilters[$queryString][$uniqueId] = $result;
+
+ return $result;
+ }
+
+ return self::$matchedFilters[$queryString][$uniqueId];
+ }
+
+ /**
+ * Load all the user's roles that grant access to at least one object matching the filter
+ *
+ * @param string $model The class path to the object model
+ * @param Filter\Rule $filter
+ * @param string $cacheKey
+ * @param bool $cache Pass `false` to not populate the cache with the matching objects
+ *
+ * @return void
+ */
+ protected function loadGrants(string $model, Filter\Rule $filter, string $cacheKey, bool $cache = true)
+ {
+ /** @var Model $model */
+ $query = $model::on($this->getDb());
+ $tableName = $query->getModel()->getTableName();
+
+ $inspectedRoles = [];
+ $roleExpressions = [];
+ $rolesWithoutRestrictions = [];
+
+ foreach ($this->getAuth()->getUser()->getRoles() as $role) {
+ $roleFilter = Filter::all();
+ if (($restriction = $role->getRestrictions('icingadb/filter/objects'))) {
+ $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/objects'));
+ }
+
+ if ($tableName === 'host' || $tableName === 'service') {
+ if (($restriction = $role->getRestrictions('icingadb/filter/hosts'))) {
+ $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/hosts'));
+ }
+ }
+
+ if ($tableName === 'service' && ($restriction = $role->getRestrictions('icingadb/filter/services'))) {
+ $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/services'));
+ }
+
+ if ($roleFilter->isEmpty()) {
+ $rolesWithoutRestrictions[] = $role->getName();
+ continue;
+ }
+
+ $roleName = str_replace('.', '_', $role->getName());
+ $inspectedRoles[$roleName] = $role->getName();
+
+ $roleName = $this->getDb()->quoteIdentifier($roleName);
+
+ if ($cache) {
+ FilterProcessor::apply($roleFilter, $query);
+ $where = $query->getSelectBase()->getWhere();
+ $query->getSelectBase()->resetWhere();
+
+ $values = [];
+ $rendered = $this->getDb()->getQueryBuilder()->buildCondition($where, $values);
+ $roleExpressions[$roleName] = new Expression($rendered, null, ...$values);
+ } else {
+ $subQuery = clone $query;
+ $roleExpressions[$roleName] = $subQuery
+ ->columns([new Expression('1')])
+ ->filter($roleFilter)
+ ->filter($filter)
+ ->limit(1)
+ ->assembleSelect()
+ ->resetOrderBy();
+ }
+ }
+
+ $rolesWithRestrictions = [];
+ if (! empty($roleExpressions)) {
+ if ($cache) {
+ $query->columns('id')->withColumns($roleExpressions);
+ $query->filter($filter);
+ } else {
+ $query = [$this->getDb()->fetchOne((new Select())->columns($roleExpressions))];
+ }
+
+ foreach ($query as $row) {
+ $roles = $rolesWithoutRestrictions;
+ foreach ($inspectedRoles as $alias => $roleName) {
+ if ($row->$alias) {
+ $rolesWithRestrictions[$roleName] = true;
+ $roles[] = $roleName;
+ }
+ }
+
+ if ($cache) {
+ self::$knownGrants[$tableName][$row->id] = $roles;
+ }
+ }
+ }
+
+ self::$knownGrants[$tableName][$cacheKey] = array_merge(
+ $rolesWithoutRestrictions,
+ array_keys($rolesWithRestrictions)
+ );
+ }
+
+ /**
+ * Check if any of the given roles grants the permission
+ *
+ * @param string $permission
+ * @param array $roles
+ *
+ * @return bool
+ */
+ protected function checkGrants(string $permission, array $roles): bool
+ {
+ if (empty($roles)) {
+ return false;
+ }
+
+ $granted = false;
+ foreach ($this->getAuth()->getUser()->getRoles() as $role) {
+ if ($role->denies($permission)) {
+ return false;
+ } elseif ($granted || ! $role->grants($permission)) {
+ continue;
+ }
+
+ $granted = in_array($role->getName(), $roles, true);
+ }
+
+ return $granted;
+ }
+}
diff --git a/library/Icingadb/Command/IcingaApiCommand.php b/library/Icingadb/Command/IcingaApiCommand.php
new file mode 100644
index 0000000..f3f0c33
--- /dev/null
+++ b/library/Icingadb/Command/IcingaApiCommand.php
@@ -0,0 +1,128 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command;
+
+class IcingaApiCommand
+{
+ /**
+ * Command data
+ *
+ * @var array
+ */
+ protected $data;
+
+ /**
+ * Name of the endpoint
+ *
+ * @var string
+ */
+ protected $endpoint;
+
+ /**
+ * HTTP method to use
+ *
+ * @var string
+ */
+ protected $method = 'POST';
+
+ /**
+ * Create a new Icinga 2 API command
+ *
+ * @param string $endpoint
+ * @param array $data
+ *
+ * @return static
+ */
+ public static function create(string $endpoint, array $data): self
+ {
+ return (new static())
+ ->setEndpoint($endpoint)
+ ->setData($data);
+ }
+
+ /**
+ * Get the command data
+ *
+ * @return array
+ */
+ public function getData(): array
+ {
+ if ($this->data === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->data;
+ }
+
+ /**
+ * Set the command data
+ *
+ * @param array $data
+ *
+ * @return $this
+ */
+ public function setData(array $data): self
+ {
+ $this->data = $data;
+
+ return $this;
+ }
+
+ /**
+ * Get the name of the endpoint
+ *
+ * @return string
+ */
+ public function getEndpoint(): string
+ {
+ if ($this->endpoint === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->endpoint;
+ }
+
+ /**
+ * Set the name of the endpoint
+ *
+ * @param string $endpoint
+ *
+ * @return $this
+ */
+ public function setEndpoint(string $endpoint): self
+ {
+ $this->endpoint = $endpoint;
+
+ return $this;
+ }
+
+ /**
+ * Get the HTTP method to use
+ *
+ * @return string
+ */
+ public function getMethod(): string
+ {
+ return $this->method;
+ }
+
+ /**
+ * Set the HTTP method to use
+ *
+ * @param string $method All uppercase HTTP method name. Case-sensitive.
+ *
+ * @return $this
+ */
+ public function setMethod(string $method): self
+ {
+ $this->method = $method;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Command/IcingaCommand.php b/library/Icingadb/Command/IcingaCommand.php
new file mode 100644
index 0000000..7b5c5cf
--- /dev/null
+++ b/library/Icingadb/Command/IcingaCommand.php
@@ -0,0 +1,22 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command;
+
+/**
+ * Base class for commands sent to an Icinga instance
+ */
+abstract class IcingaCommand
+{
+ /**
+ * Get the name of the command
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ $nsParts = explode('\\', get_called_class());
+ return substr_replace(end($nsParts), '', -7); // Remove 'Command' Suffix
+ }
+}
diff --git a/library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php b/library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php
new file mode 100644
index 0000000..d275d9b
--- /dev/null
+++ b/library/Icingadb/Command/Instance/ToggleInstanceFeatureCommand.php
@@ -0,0 +1,109 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Instance;
+
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+
+/**
+ * Enable or disable a feature of an Icinga instance
+ */
+class ToggleInstanceFeatureCommand extends IcingaCommand
+{
+ /**
+ * Feature for enabling or disabling active host checks on an Icinga instance
+ */
+ const FEATURE_ACTIVE_HOST_CHECKS = 'active_host_checks_enabled';
+
+ /**
+ * Feature for enabling or disabling active service checks on an Icinga instance
+ */
+ const FEATURE_ACTIVE_SERVICE_CHECKS = 'active_service_checks_enabled';
+
+ /**
+ * Feature for enabling or disabling host and service event handlers on an Icinga instance
+ */
+ const FEATURE_EVENT_HANDLERS = 'event_handlers_enabled';
+
+ /**
+ * Feature for enabling or disabling host and service flap detection on an Icinga instance
+ */
+ const FEATURE_FLAP_DETECTION = 'flap_detection_enabled';
+
+ /**
+ * Feature for enabling or disabling host and service notifications on an Icinga instance
+ */
+ const FEATURE_NOTIFICATIONS = 'notifications_enabled';
+
+ /**
+ * Feature for enabling or disabling the processing of host and service performance data on an Icinga instance
+ */
+ const FEATURE_PERFORMANCE_DATA = 'process_performance_data';
+
+ /**
+ * Feature that is to be enabled or disabled
+ *
+ * @var string
+ */
+ protected $feature;
+
+ /**
+ * Whether the feature should be enabled or disabled
+ *
+ * @var bool
+ */
+ protected $enabled;
+
+ /**
+ * Set the feature that is to be enabled or disabled
+ *
+ * @param string $feature
+ *
+ * @return $this
+ */
+ public function setFeature(string $feature): self
+ {
+ $this->feature = $feature;
+
+ return $this;
+ }
+
+ /**
+ * Get the feature that is to be enabled or disabled
+ *
+ * @return string
+ */
+ public function getFeature(): string
+ {
+ if ($this->feature === null) {
+ throw new \LogicException('You have to set the feature first before getting it.');
+ }
+
+ return $this->feature;
+ }
+
+ /**
+ * Set whether the feature should be enabled or disabled
+ *
+ * @param bool $enabled
+ *
+ * @return $this
+ */
+ public function setEnabled(bool $enabled = true): self
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the feature should be enabled or disabled
+ *
+ * @return ?bool
+ */
+ public function getEnabled()
+ {
+ return $this->enabled;
+ }
+}
diff --git a/library/Icingadb/Command/Object/AcknowledgeProblemCommand.php b/library/Icingadb/Command/Object/AcknowledgeProblemCommand.php
new file mode 100644
index 0000000..baae24c
--- /dev/null
+++ b/library/Icingadb/Command/Object/AcknowledgeProblemCommand.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Acknowledge a host or service problem
+ */
+class AcknowledgeProblemCommand extends WithCommentCommand
+{
+ /**
+ * Whether the acknowledgement is sticky
+ *
+ * Sticky acknowledgements remain until the host or service recovers. Non-sticky acknowledgements will be
+ * automatically removed when the host or service state changes.
+ *
+ * @var bool
+ */
+ protected $sticky = false;
+
+ /**
+ * Whether to send a notification about the acknowledgement
+
+ * @var bool
+ */
+ protected $notify = false;
+
+ /**
+ * Whether the comment associated with the acknowledgement is persistent
+ *
+ * Persistent comments are not lost the next time the monitoring host restarts.
+ *
+ * @var bool
+ */
+ protected $persistent = false;
+
+ /**
+ * Optional time when the acknowledgement should expire
+ *
+ * @var int
+ */
+ protected $expireTime;
+
+ /**
+ * Set whether the acknowledgement is sticky
+ *
+ * @param bool $sticky
+ *
+ * @return $this
+ */
+ public function setSticky(bool $sticky = true): self
+ {
+ $this->sticky = $sticky;
+
+ return $this;
+ }
+
+ /**
+ * Is the acknowledgement sticky?
+ *
+ * @return bool
+ */
+ public function getSticky(): bool
+ {
+ return $this->sticky;
+ }
+
+ /**
+ * Set whether to send a notification about the acknowledgement
+ *
+ * @param bool $notify
+ *
+ * @return $this
+ */
+ public function setNotify(bool $notify = true): self
+ {
+ $this->notify = $notify;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to send a notification about the acknowledgement
+ *
+ * @return bool
+ */
+ public function getNotify(): bool
+ {
+ return $this->notify;
+ }
+
+ /**
+ * Set whether the comment associated with the acknowledgement is persistent
+ *
+ * @param bool $persistent
+ *
+ * @return $this
+ */
+ public function setPersistent(bool $persistent = true): self
+ {
+ $this->persistent = $persistent;
+
+ return $this;
+ }
+
+ /**
+ * Is the comment associated with the acknowledgement is persistent?
+ *
+ * @return bool
+ */
+ public function getPersistent(): bool
+ {
+ return $this->persistent;
+ }
+
+ /**
+ * Set the time when the acknowledgement should expire
+ *
+ * @param int $expireTime
+ *
+ * @return $this
+ */
+ public function setExpireTime(int $expireTime): self
+ {
+ $this->expireTime = $expireTime;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the acknowledgement should expire
+ *
+ * @return ?int
+ */
+ public function getExpireTime()
+ {
+ return $this->expireTime;
+ }
+}
diff --git a/library/Icingadb/Command/Object/AddCommentCommand.php b/library/Icingadb/Command/Object/AddCommentCommand.php
new file mode 100644
index 0000000..c853b25
--- /dev/null
+++ b/library/Icingadb/Command/Object/AddCommentCommand.php
@@ -0,0 +1,42 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Add a comment to a host or service
+ */
+class AddCommentCommand extends WithCommentCommand
+{
+ /**
+ * Optional time when the acknowledgement should expire
+ *
+ * @var int
+ */
+ protected $expireTime;
+
+ /**
+ * Set the time when the acknowledgement should expire
+ *
+ * @param int $expireTime
+ *
+ * @return $this
+ */
+ public function setExpireTime(int $expireTime): self
+ {
+ $this->expireTime = $expireTime;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the acknowledgement should expire
+ *
+ * @return ?int
+ */
+ public function getExpireTime()
+ {
+ return $this->expireTime;
+ }
+}
diff --git a/library/Icingadb/Command/Object/CommandAuthor.php b/library/Icingadb/Command/Object/CommandAuthor.php
new file mode 100644
index 0000000..f323b63
--- /dev/null
+++ b/library/Icingadb/Command/Object/CommandAuthor.php
@@ -0,0 +1,45 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+trait CommandAuthor
+{
+ /**
+ * Author of the command
+ *
+ * @var string
+ */
+ protected $author;
+
+ /**
+ * Set the author
+ *
+ * @param string $author
+ *
+ * @return $this
+ */
+ public function setAuthor(string $author): self
+ {
+ $this->author = $author;
+
+ return $this;
+ }
+
+ /**
+ * Get the author
+ *
+ * @return string
+ */
+ public function getAuthor(): string
+ {
+ if ($this->author === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->author;
+ }
+}
diff --git a/library/Icingadb/Command/Object/DeleteCommentCommand.php b/library/Icingadb/Command/Object/DeleteCommentCommand.php
new file mode 100644
index 0000000..c06a73c
--- /dev/null
+++ b/library/Icingadb/Command/Object/DeleteCommentCommand.php
@@ -0,0 +1,13 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Delete a host or service comment
+ */
+class DeleteCommentCommand extends ObjectsCommand
+{
+ use CommandAuthor;
+}
diff --git a/library/Icingadb/Command/Object/DeleteDowntimeCommand.php b/library/Icingadb/Command/Object/DeleteDowntimeCommand.php
new file mode 100644
index 0000000..7b4c282
--- /dev/null
+++ b/library/Icingadb/Command/Object/DeleteDowntimeCommand.php
@@ -0,0 +1,13 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Delete a host or service downtime
+ */
+class DeleteDowntimeCommand extends ObjectsCommand
+{
+ use CommandAuthor;
+}
diff --git a/library/Icingadb/Command/Object/GetObjectCommand.php b/library/Icingadb/Command/Object/GetObjectCommand.php
new file mode 100644
index 0000000..8448f8d
--- /dev/null
+++ b/library/Icingadb/Command/Object/GetObjectCommand.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+class GetObjectCommand extends ObjectsCommand
+{
+ /** @var array */
+ protected $attributes;
+
+ /**
+ * Get the attributes to query
+ *
+ * @return ?array
+ */
+ public function getAttributes()
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Set the attributes to query
+ *
+ * @param array $attributes
+ *
+ * @return $this
+ */
+ public function setAttributes(array $attributes): self
+ {
+ $this->attributes = $attributes;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ObjectsCommand.php b/library/Icingadb/Command/Object/ObjectsCommand.php
new file mode 100644
index 0000000..3de6c83
--- /dev/null
+++ b/library/Icingadb/Command/Object/ObjectsCommand.php
@@ -0,0 +1,67 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+use ArrayIterator;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use ipl\Orm\Model;
+use Traversable;
+
+/**
+ * Base class for commands that involve monitored objects, i.e. hosts or services
+ */
+abstract class ObjectsCommand extends IcingaCommand
+{
+ /**
+ * Involved objects
+ *
+ * @var Traversable<Model>
+ */
+ protected $objects;
+
+ /**
+ * Set the involved objects
+ *
+ * @param Traversable<Model> $objects
+ *
+ * @return $this
+ */
+ public function setObjects(Traversable $objects): self
+ {
+ $this->objects = $objects;
+
+ return $this;
+ }
+
+ /**
+ * Set the involved object
+ *
+ * @param Model $object
+ *
+ * @return $this
+ *
+ * @deprecated Use setObjects() instead
+ */
+ public function setObject(Model $object): self
+ {
+ return $this->setObjects(new ArrayIterator([$object]));
+ }
+
+ /**
+ * Get the involved objects
+ *
+ * @return Traversable
+ */
+ public function getObjects(): Traversable
+ {
+ if ($this->objects === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->objects;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ProcessCheckResultCommand.php b/library/Icingadb/Command/Object/ProcessCheckResultCommand.php
new file mode 100644
index 0000000..24ae2f3
--- /dev/null
+++ b/library/Icingadb/Command/Object/ProcessCheckResultCommand.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Submit a passive check result for a host or service
+ */
+class ProcessCheckResultCommand extends ObjectsCommand
+{
+ /**
+ * Host up
+ */
+ const HOST_UP = 0;
+
+ /**
+ * Host down
+ */
+ const HOST_DOWN = 1;
+
+ /**
+ * Service ok
+ */
+ const SERVICE_OK = 0;
+
+ /**
+ * Service warning
+ */
+ const SERVICE_WARNING = 1;
+
+ /**
+ * Service critical
+ */
+ const SERVICE_CRITICAL = 2;
+
+ /**
+ * Service unknown
+ */
+ const SERVICE_UNKNOWN = 3;
+
+ /**
+ * Status code of the host or service check result
+ *
+ * @var int
+ */
+ protected $status;
+
+ /**
+ * Text output of the host or service check result
+ *
+ * @var string
+ */
+ protected $output;
+
+ /**
+ * Optional performance data of the host or service check result
+ *
+ * @var string
+ */
+ protected $performanceData;
+
+ /**
+ * Set the status code of the host or service check result
+ *
+ * @param int $status
+ *
+ * @return $this
+ */
+ public function setStatus(int $status): self
+ {
+ $this->status = $status;
+
+ return $this;
+ }
+
+ /**
+ * Get the status code of the host or service check result
+ *
+ * @return int
+ */
+ public function getStatus(): int
+ {
+ if ($this->status === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->status;
+ }
+
+ /**
+ * Set the text output of the host or service check result
+ *
+ * @param string $output
+ *
+ * @return $this
+ */
+ public function setOutput(string $output): self
+ {
+ $this->output = $output;
+
+ return $this;
+ }
+
+ /**
+ * Get the text output of the host or service check result
+ *
+ * @return ?string
+ */
+ public function getOutput()
+ {
+ return $this->output;
+ }
+
+ /**
+ * Set the performance data of the host or service check result
+ *
+ * @param string|null $performanceData
+ *
+ * @return $this
+ */
+ public function setPerformanceData(string $performanceData = null): self
+ {
+ $this->performanceData = $performanceData;
+
+ return $this;
+ }
+
+ /**
+ * Get the performance data of the host or service check result
+ *
+ * @return ?string
+ */
+ public function getPerformanceData()
+ {
+ return $this->performanceData;
+ }
+}
diff --git a/library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php b/library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php
new file mode 100644
index 0000000..88964fb
--- /dev/null
+++ b/library/Icingadb/Command/Object/PropagateHostDowntimeCommand.php
@@ -0,0 +1,42 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Schedule and propagate host downtime
+ */
+class PropagateHostDowntimeCommand extends ScheduleHostDowntimeCommand
+{
+ /**
+ * Whether the downtime for child hosts are all set to be triggered by this' host downtime
+ *
+ * @var bool
+ */
+ protected $triggered = false;
+
+ /**
+ * Set whether the downtime for child hosts are all set to be triggered by this' host downtime
+ *
+ * @param bool $triggered
+ *
+ * @return $this
+ */
+ public function setTriggered(bool $triggered = true): self
+ {
+ $this->triggered = $triggered;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the downtime for child hosts are all set to be triggered by this' host downtime
+ *
+ * @return bool
+ */
+ public function getTriggered(): bool
+ {
+ return $this->triggered;
+ }
+}
diff --git a/library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php b/library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php
new file mode 100644
index 0000000..4c4a9b3
--- /dev/null
+++ b/library/Icingadb/Command/Object/RemoveAcknowledgementCommand.php
@@ -0,0 +1,13 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Remove a problem acknowledgement from a host or service
+ */
+class RemoveAcknowledgementCommand extends ObjectsCommand
+{
+ use CommandAuthor;
+}
diff --git a/library/Icingadb/Command/Object/ScheduleCheckCommand.php b/library/Icingadb/Command/Object/ScheduleCheckCommand.php
new file mode 100644
index 0000000..88a7fd3
--- /dev/null
+++ b/library/Icingadb/Command/Object/ScheduleCheckCommand.php
@@ -0,0 +1,86 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Schedule a check
+ */
+class ScheduleCheckCommand extends ObjectsCommand
+{
+ /**
+ * Time when the next check of a host or service is to be scheduled
+ *
+ * If active checks are disabled on a host- or service-specific or program-wide basis or the host or service is
+ * already scheduled to be checked at an earlier time, etc. The check may not actually be scheduled at the time
+ * specified. This behaviour can be overridden by setting `ScheduledCheck::$forced' to true.
+ *
+ * @var int Unix timestamp
+ */
+ protected $checkTime;
+
+ /**
+ * Whether the check is forced
+ *
+ * Forced checks are performed regardless of what time it is (e.g. time period restrictions are ignored) and whether
+ * or not active checks are enabled on a host- or service-specific or program-wide basis.
+ *
+ * @var bool
+ */
+ protected $forced = false;
+
+ /**
+ * Set the time when the next check of a host or service is to be scheduled
+ *
+ * @param int $checkTime Unix timestamp
+ *
+ * @return $this
+ */
+ public function setCheckTime(int $checkTime): self
+ {
+ $this->checkTime = $checkTime;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the next check of a host or service is to be scheduled
+ *
+ * @return int Unix timestamp
+ */
+ public function getCheckTime(): int
+ {
+ if ($this->checkTime === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->checkTime;
+ }
+
+ /**
+ * Set whether the check is forced
+ *
+ * @param bool $forced
+ *
+ * @return $this
+ */
+ public function setForced(bool $forced = true): self
+ {
+ $this->forced = $forced;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the check is forced
+ *
+ * @return bool
+ */
+ public function getForced(): bool
+ {
+ return $this->forced;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php b/library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php
new file mode 100644
index 0000000..0e4d84f
--- /dev/null
+++ b/library/Icingadb/Command/Object/ScheduleHostDowntimeCommand.php
@@ -0,0 +1,42 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Schedule a host downtime
+ */
+class ScheduleHostDowntimeCommand extends ScheduleServiceDowntimeCommand
+{
+ /**
+ * Whether to schedule a downtime for all services associated with a particular host
+ *
+ * @var bool
+ */
+ protected $forAllServices = false;
+
+ /**
+ * Set whether to schedule a downtime for all services associated with a particular host
+ *
+ * @param bool $forAllServices
+ *
+ * @return $this
+ */
+ public function setForAllServices(bool $forAllServices = true): self
+ {
+ $this->forAllServices = $forAllServices;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to schedule a downtime for all services associated with a particular host
+ *
+ * @return bool
+ */
+ public function getForAllServices(): bool
+ {
+ return $this->forAllServices;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php b/library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php
new file mode 100644
index 0000000..3bad28e
--- /dev/null
+++ b/library/Icingadb/Command/Object/ScheduleServiceDowntimeCommand.php
@@ -0,0 +1,196 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Schedule a service downtime
+ */
+class ScheduleServiceDowntimeCommand extends AddCommentCommand
+{
+ /**
+ * Downtime starts at the exact time specified
+ *
+ * If `Downtime::$fixed' is set to false, the time between `Downtime::$start' and `Downtime::$end' at which a
+ * host or service transitions to a problem state determines the time at which the downtime actually starts.
+ * The downtime will then last for `Downtime::$duration' seconds.
+ *
+ * @var int Unix timestamp
+ */
+ protected $start;
+
+ /**
+ * Downtime ends at the exact time specified
+ *
+ * If `Downtime::$fixed' is set to false, the time between `Downtime::$start' and `Downtime::$end' at which a
+ * host or service transitions to a problem state determines the time at which the downtime actually starts.
+ * The downtime will then last for `Downtime::$duration' seconds.
+ *
+ * @var int Unix timestamp
+ */
+ protected $end;
+
+ /**
+ * Whether it's a fixed or flexible downtime
+ *
+ * @var bool
+ */
+ protected $fixed = true;
+
+ /**
+ * ID of the downtime which triggers this downtime
+ *
+ * The start of this downtime is triggered by the start of the other scheduled host or service downtime.
+ *
+ * @var int|null
+ */
+ protected $triggerId;
+
+ /**
+ * The duration in seconds the downtime must last if it's a flexible downtime
+ *
+ * If `Downtime::$fixed' is set to false, the downtime will last for the duration in seconds specified, even
+ * if the host or service recovers before the downtime expires.
+ *
+ * @var int|null
+ */
+ protected $duration;
+
+ /**
+ * Set the time when the downtime should start
+ *
+ * @param int $start Unix timestamp
+ *
+ * @return $this
+ */
+ public function setStart(int $start): self
+ {
+ $this->start = $start;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the downtime should start
+ *
+ * @return int Unix timestamp
+ */
+ public function getStart(): int
+ {
+ if ($this->start === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->start;
+ }
+
+ /**
+ * Set the time when the downtime should end
+ *
+ * @param int $end Unix timestamp
+ *
+ * @return $this
+ */
+ public function setEnd(int $end): self
+ {
+ $this->end = $end;
+
+ return $this;
+ }
+
+ /**
+ * Get the time when the downtime should end
+ *
+ * @return int Unix timestamp
+ */
+ public function getEnd(): int
+ {
+ if ($this->start === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->end;
+ }
+
+ /**
+ * Set whether it's a fixed or flexible downtime
+ *
+ * @param boolean $fixed
+ *
+ * @return $this
+ */
+ public function setFixed(bool $fixed = true): self
+ {
+ $this->fixed = $fixed;
+
+ return $this;
+ }
+
+ /**
+ * Is the downtime fixed?
+ *
+ * @return boolean
+ */
+ public function getFixed(): bool
+ {
+ return $this->fixed;
+ }
+
+ /**
+ * Set the ID of the downtime which triggers this downtime
+ *
+ * @param int $triggerId
+ *
+ * @return $this
+ */
+ public function setTriggerId(int $triggerId): self
+ {
+ $this->triggerId = $triggerId;
+
+ return $this;
+ }
+
+ /**
+ * Get the ID of the downtime which triggers this downtime
+ *
+ * @return int|null
+ */
+ public function getTriggerId()
+ {
+ return $this->triggerId;
+ }
+
+ /**
+ * Set the duration in seconds the downtime must last if it's a flexible downtime
+ *
+ * @param int $duration
+ *
+ * @return $this
+ */
+ public function setDuration(int $duration): self
+ {
+ $this->duration = $duration;
+
+ return $this;
+ }
+
+ /**
+ * Get the duration in seconds the downtime must last if it's a flexible downtime
+ *
+ * @return int|null
+ */
+ public function getDuration()
+ {
+ return $this->duration;
+ }
+
+ public function getName(): string
+ {
+ return 'ScheduleDowntime';
+ }
+}
diff --git a/library/Icingadb/Command/Object/SendCustomNotificationCommand.php b/library/Icingadb/Command/Object/SendCustomNotificationCommand.php
new file mode 100644
index 0000000..de90620
--- /dev/null
+++ b/library/Icingadb/Command/Object/SendCustomNotificationCommand.php
@@ -0,0 +1,44 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Send custom notifications for a host or service
+ */
+class SendCustomNotificationCommand extends WithCommentCommand
+{
+ /**
+ * Whether the notification is forced
+ *
+ * Forced notifications are sent out regardless of time restrictions and whether or not notifications are enabled.
+ *
+ * @var bool
+ */
+ protected $forced;
+
+ /**
+ * Get whether to force the notification
+ *
+ * @return ?bool
+ */
+ public function getForced()
+ {
+ return $this->forced;
+ }
+
+ /**
+ * Set whether to force the notification
+ *
+ * @param bool $forced
+ *
+ * @return $this
+ */
+ public function setForced(bool $forced = true): self
+ {
+ $this->forced = $forced;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php b/library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php
new file mode 100644
index 0000000..ec66289
--- /dev/null
+++ b/library/Icingadb/Command/Object/ToggleObjectFeatureCommand.php
@@ -0,0 +1,108 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Enable or disable a feature of an Icinga object, i.e. host or service
+ */
+class ToggleObjectFeatureCommand extends ObjectsCommand
+{
+ /**
+ * Feature for enabling or disabling active checks of a host or service
+ */
+ const FEATURE_ACTIVE_CHECKS = 'active_checks_enabled';
+
+ /**
+ * Feature for enabling or disabling passive checks of a host or service
+ */
+ const FEATURE_PASSIVE_CHECKS = 'passive_checks_enabled';
+
+ /**
+ * Feature for enabling or disabling notifications for a host or service
+ *
+ * Notifications will be sent out only if notifications are enabled on a program-wide basis as well.
+ */
+ const FEATURE_NOTIFICATIONS = 'notifications_enabled';
+
+ /**
+ * Feature for enabling or disabling event handler for a host or service
+ */
+ const FEATURE_EVENT_HANDLER = 'event_handler_enabled';
+
+ /**
+ * Feature for enabling or disabling flap detection for a host or service.
+ *
+ * In order to enable flap detection flap detection must be enabled on a program-wide basis as well.
+ */
+ const FEATURE_FLAP_DETECTION = 'flapping_enabled';
+
+ /**
+ * Feature that is to be enabled or disabled
+ *
+ * @var string
+ */
+ protected $feature;
+
+ /**
+ * Whether the feature should be enabled or disabled
+ *
+ * @var bool
+ */
+ protected $enabled;
+
+ /**
+ * Set the feature that is to be enabled or disabled
+ *
+ * @param string $feature
+ *
+ * @return $this
+ */
+ public function setFeature(string $feature): self
+ {
+ $this->feature = $feature;
+
+ return $this;
+ }
+
+ /**
+ * Get the feature that is to be enabled or disabled
+ *
+ * @return string
+ */
+ public function getFeature(): string
+ {
+ if ($this->feature === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->feature;
+ }
+
+ /**
+ * Set whether the feature should be enabled or disabled
+ *
+ * @param bool $enabled
+ *
+ * @return $this
+ */
+ public function setEnabled(bool $enabled = true): self
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the feature should be enabled or disabled
+ *
+ * @return ?bool
+ */
+ public function getEnabled()
+ {
+ return $this->enabled;
+ }
+}
diff --git a/library/Icingadb/Command/Object/WithCommentCommand.php b/library/Icingadb/Command/Object/WithCommentCommand.php
new file mode 100644
index 0000000..fc92eb1
--- /dev/null
+++ b/library/Icingadb/Command/Object/WithCommentCommand.php
@@ -0,0 +1,50 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Object;
+
+/**
+ * Base class for commands adding comments
+ */
+abstract class WithCommentCommand extends ObjectsCommand
+{
+ use CommandAuthor;
+
+ /**
+ * Comment
+ *
+ * @var string
+ */
+ protected $comment;
+
+ /**
+ * Set the comment
+ *
+ * @param string $comment
+ *
+ * @return $this
+ */
+ public function setComment(string $comment): self
+ {
+ $this->comment = $comment;
+
+ return $this;
+ }
+
+ /**
+ * Get the comment
+ *
+ * @return string
+ */
+ public function getComment(): string
+ {
+ if ($this->comment === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->comment;
+ }
+}
diff --git a/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php b/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php
new file mode 100644
index 0000000..37075c9
--- /dev/null
+++ b/library/Icingadb/Command/Renderer/IcingaApiCommandRenderer.php
@@ -0,0 +1,353 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Renderer;
+
+use Icinga\Module\Icingadb\Command\IcingaApiCommand;
+use Icinga\Module\Icingadb\Command\Object\GetObjectCommand;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Command\Instance\ToggleInstanceFeatureCommand;
+use Icinga\Module\Icingadb\Command\Object\AcknowledgeProblemCommand;
+use Icinga\Module\Icingadb\Command\Object\AddCommentCommand;
+use Icinga\Module\Icingadb\Command\Object\DeleteCommentCommand;
+use Icinga\Module\Icingadb\Command\Object\DeleteDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\ProcessCheckResultCommand;
+use Icinga\Module\Icingadb\Command\Object\PropagateHostDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\RemoveAcknowledgementCommand;
+use Icinga\Module\Icingadb\Command\Object\ScheduleHostDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\ScheduleCheckCommand;
+use Icinga\Module\Icingadb\Command\Object\ScheduleServiceDowntimeCommand;
+use Icinga\Module\Icingadb\Command\Object\SendCustomNotificationCommand;
+use Icinga\Module\Icingadb\Command\Object\ToggleObjectFeatureCommand;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use InvalidArgumentException;
+use ipl\Orm\Model;
+use LogicException;
+use Traversable;
+
+/**
+ * Icinga command renderer for the Icinga command file
+ */
+class IcingaApiCommandRenderer implements IcingaCommandRendererInterface
+{
+ /**
+ * Name of the Icinga application object
+ *
+ * @var string
+ */
+ protected $app = 'app';
+
+ /**
+ * Get the name of the Icinga application object
+ *
+ * @return string
+ */
+ public function getApp(): string
+ {
+ return $this->app;
+ }
+
+ /**
+ * Set the name of the Icinga application object
+ *
+ * @param string $app
+ *
+ * @return $this
+ */
+ public function setApp(string $app): self
+ {
+ $this->app = $app;
+
+ return $this;
+ }
+
+ /**
+ * Apply filter to query data
+ *
+ * @param array $data
+ * @param Traversable<Model> $objects
+ *
+ * @return ?Model Any of the objects (useful for further type-dependent handling)
+ */
+ protected function applyFilter(array &$data, Traversable $objects): ?Model
+ {
+ $object = null;
+
+ foreach ($objects as $object) {
+ if ($object instanceof Service) {
+ $data['services'][] = sprintf('%s!%s', $object->host->name, $object->name);
+ } else {
+ $data['hosts'][] = $object->name;
+ }
+ }
+
+ return $object;
+ }
+
+ /**
+ * Get the sub-route of the endpoint for an object
+ *
+ * @param Model $object
+ *
+ * @return string
+ */
+ protected function getObjectPluralType(Model $object): string
+ {
+ if ($object instanceof Host) {
+ return 'hosts';
+ }
+
+ if ($object instanceof Service) {
+ return 'services';
+ }
+
+ throw new LogicException(sprintf('Invalid object type %s provided', get_class($object)));
+ }
+
+ /**
+ * Render a command
+ *
+ * @param IcingaCommand $command
+ *
+ * @return IcingaApiCommand
+ */
+ public function render(IcingaCommand $command): IcingaApiCommand
+ {
+ $renderMethod = 'render' . $command->getName();
+ if (! method_exists($this, $renderMethod)) {
+ throw new InvalidArgumentException(
+ sprintf('Can\'t render command. Method %s not found', $renderMethod)
+ );
+ }
+
+ return $this->$renderMethod($command);
+ }
+
+ public function renderGetObject(GetObjectCommand $command): IcingaApiCommand
+ {
+ $data = [
+ 'all_joins' => 1,
+ 'attrs' => $command->getAttributes() ?: []
+ ];
+
+ $endpoint = 'objects/' . $this->getObjectPluralType($this->applyFilter($data, $command->getObjects()));
+
+ return IcingaApiCommand::create($endpoint, $data)->setMethod('GET');
+ }
+
+ public function renderAddComment(AddCommentCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/add-comment';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comment' => $command->getComment()
+ ];
+
+ if ($command->getExpireTime() !== null) {
+ $data['expiry'] = $command->getExpireTime();
+ }
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderSendCustomNotification(SendCustomNotificationCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/send-custom-notification';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comment' => $command->getComment(),
+ 'force' => $command->getForced()
+ ];
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderProcessCheckResult(ProcessCheckResultCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/process-check-result';
+ $data = [
+ 'exit_status' => $command->getStatus(),
+ 'plugin_output' => $command->getOutput(),
+ 'performance_data' => $command->getPerformanceData()
+ ];
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderScheduleCheck(ScheduleCheckCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/reschedule-check';
+ $data = [
+ 'next_check' => $command->getCheckTime(),
+ 'force' => $command->getForced()
+ ];
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderScheduleDowntime(ScheduleServiceDowntimeCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/schedule-downtime';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comment' => $command->getComment(),
+ 'start_time' => $command->getStart(),
+ 'end_time' => $command->getEnd(),
+ 'duration' => $command->getDuration(),
+ 'fixed' => $command->getFixed(),
+ 'trigger_name' => $command->getTriggerId()
+ ];
+
+ if ($command instanceof PropagateHostDowntimeCommand) {
+ $data['child_options'] = $command->getTriggered() ? 1 : 2;
+ }
+
+ if ($command instanceof ScheduleHostDowntimeCommand && $command->getForAllServices()) {
+ $data['all_services'] = true;
+ }
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderAcknowledgeProblem(AcknowledgeProblemCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/acknowledge-problem';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comment' => $command->getComment(),
+ 'sticky' => $command->getSticky(),
+ 'notify' => $command->getNotify(),
+ 'persistent' => $command->getPersistent()
+ ];
+
+ if ($command->getExpireTime() !== null) {
+ $data['expiry'] = $command->getExpireTime();
+ }
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderToggleObjectFeature(ToggleObjectFeatureCommand $command): IcingaApiCommand
+ {
+ switch ($command->getFeature()) {
+ case ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS:
+ $attr = 'enable_active_checks';
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS:
+ $attr = 'enable_passive_checks';
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS:
+ $attr = 'enable_notifications';
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER:
+ $attr = 'enable_event_handler';
+ break;
+ case ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION:
+ $attr = 'enable_flapping';
+ break;
+ default:
+ throw new InvalidArgumentException($command->getFeature());
+ }
+
+ $endpoint = 'objects/';
+ $objects = $command->getObjects();
+
+ $data = [
+ 'attrs' => [
+ $attr => $command->getEnabled()
+ ]
+ ];
+
+
+ $endpoint .= $this->getObjectPluralType($this->applyFilter($data, $objects));
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderDeleteComment(DeleteCommentCommand $command): IcingaApiCommand
+ {
+ $comments = [];
+
+ foreach ($command->getObjects() as $object) {
+ $comments[] = $object->name;
+ }
+
+ $endpoint = 'actions/remove-comment';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'comments' => $comments
+ ];
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderDeleteDowntime(DeleteDowntimeCommand $command): IcingaApiCommand
+ {
+ $downtimes = [];
+
+ foreach ($command->getObjects() as $object) {
+ $downtimes[] = $object->name;
+ }
+
+ $endpoint = 'actions/remove-downtime';
+ $data = [
+ 'author' => $command->getAuthor(),
+ 'downtimes' => $downtimes
+ ];
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderRemoveAcknowledgement(RemoveAcknowledgementCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'actions/remove-acknowledgement';
+ $data = ['author' => $command->getAuthor()];
+
+ $this->applyFilter($data, $command->getObjects());
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+
+ public function renderToggleInstanceFeature(ToggleInstanceFeatureCommand $command): IcingaApiCommand
+ {
+ $endpoint = 'objects/icingaapplications/' . $this->getApp();
+
+ switch ($command->getFeature()) {
+ case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_HOST_CHECKS:
+ $attr = 'enable_host_checks';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_ACTIVE_SERVICE_CHECKS:
+ $attr = 'enable_service_checks';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_EVENT_HANDLERS:
+ $attr = 'enable_event_handlers';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_FLAP_DETECTION:
+ $attr = 'enable_flapping';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_NOTIFICATIONS:
+ $attr = 'enable_notifications';
+ break;
+ case ToggleInstanceFeatureCommand::FEATURE_PERFORMANCE_DATA:
+ $attr = 'enable_perfdata';
+ break;
+ default:
+ throw new InvalidArgumentException($command->getFeature());
+ }
+
+ $data = [
+ 'attrs' => [
+ $attr => $command->getEnabled()
+ ]
+ ];
+
+ return IcingaApiCommand::create($endpoint, $data);
+ }
+}
diff --git a/library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php b/library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php
new file mode 100644
index 0000000..50dd90c
--- /dev/null
+++ b/library/Icingadb/Command/Renderer/IcingaCommandRendererInterface.php
@@ -0,0 +1,12 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Renderer;
+
+/**
+ * Interface for Icinga command renderer
+ */
+interface IcingaCommandRendererInterface
+{
+}
diff --git a/library/Icingadb/Command/Transport/ApiCommandException.php b/library/Icingadb/Command/Transport/ApiCommandException.php
new file mode 100644
index 0000000..5449a7d
--- /dev/null
+++ b/library/Icingadb/Command/Transport/ApiCommandException.php
@@ -0,0 +1,14 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Exception thrown if a command was not successful
+ */
+class ApiCommandException extends IcingaException
+{
+}
diff --git a/library/Icingadb/Command/Transport/ApiCommandTransport.php b/library/Icingadb/Command/Transport/ApiCommandTransport.php
new file mode 100644
index 0000000..370d705
--- /dev/null
+++ b/library/Icingadb/Command/Transport/ApiCommandTransport.php
@@ -0,0 +1,353 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+use Icinga\Application\Hook\AuditHook;
+use Icinga\Application\Logger;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Command\IcingaApiCommand;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+use Icinga\Module\Icingadb\Command\Renderer\IcingaApiCommandRenderer;
+use Icinga\Util\Json;
+
+/**
+ * Command transport over Icinga 2's REST API
+ */
+class ApiCommandTransport implements CommandTransportInterface
+{
+ /**
+ * Transport identifier
+ */
+ const TRANSPORT = 'api';
+
+ /**
+ * API host
+ *
+ * @var string
+ */
+ protected $host;
+
+ /**
+ * API password
+ *
+ * @var string
+ */
+ protected $password;
+
+ /**
+ * API port
+ *
+ * @var int
+ */
+ protected $port = 5665;
+
+ /**
+ * Command renderer
+ *
+ * @var IcingaApiCommandRenderer
+ */
+ protected $renderer;
+
+ /**
+ * API username
+ *
+ * @var string
+ */
+ protected $username;
+
+ /**
+ * Create a new API command transport
+ */
+ public function __construct()
+ {
+ $this->renderer = new IcingaApiCommandRenderer();
+ }
+
+ /**
+ * Set the name of the Icinga application object
+ *
+ * @param string $app
+ *
+ * @return $this
+ */
+ public function setApp(string $app): self
+ {
+ $this->renderer->setApp($app);
+
+ return $this;
+ }
+
+ /**
+ * Get the API host
+ *
+ * @return string
+ */
+ public function getHost(): string
+ {
+ if ($this->host === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->host;
+ }
+
+ /**
+ * Set the API host
+ *
+ * @param string $host
+ *
+ * @return $this
+ */
+ public function setHost(string $host): self
+ {
+ $this->host = $host;
+
+ return $this;
+ }
+
+ /**
+ * Get the API password
+ *
+ * @return string
+ */
+ public function getPassword(): string
+ {
+ if ($this->password === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->password;
+ }
+
+ /**
+ * Set the API password
+ *
+ * @param string $password
+ *
+ * @return $this
+ */
+ public function setPassword(string $password): self
+ {
+ $this->password = $password;
+
+ return $this;
+ }
+
+ /**
+ * Get the API port
+ *
+ * @return int
+ */
+ public function getPort(): int
+ {
+ return $this->port;
+ }
+
+ /**
+ * Set the API port
+ *
+ * @param int $port
+ *
+ * @return $this
+ */
+ public function setPort(int $port): self
+ {
+ $this->port = $port;
+
+ return $this;
+ }
+
+ /**
+ * Get the API username
+ *
+ * @return string
+ */
+ public function getUsername(): string
+ {
+ if ($this->username === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->username;
+ }
+
+ /**
+ * Set the API username
+ *
+ * @param string $username
+ *
+ * @return $this
+ */
+ public function setUsername(string $username): self
+ {
+ $this->username = $username;
+
+ return $this;
+ }
+
+ /**
+ * Get URI for endpoint
+ *
+ * @param string $endpoint
+ *
+ * @return string
+ */
+ protected function getUriFor(string $endpoint): string
+ {
+ return sprintf('https://%s:%u/v1/%s', $this->getHost(), $this->getPort(), $endpoint);
+ }
+
+ /**
+ * Send the given command to the icinga2's REST API
+ *
+ * @param IcingaApiCommand $command
+ *
+ * @return mixed
+ */
+ protected function sendCommand(IcingaApiCommand $command)
+ {
+ Logger::debug(
+ 'Sending Icinga command "%s" to the API "%s:%u"',
+ $command->getEndpoint(),
+ $this->getHost(),
+ $this->getPort()
+ );
+
+ $data = $command->getData();
+ $payload = Json::encode($data);
+ AuditHook::logActivity(
+ 'monitoring/command',
+ "Issued command {$command->getEndpoint()} with the following payload: $payload",
+ $data
+ );
+
+ $headers = ['Accept' => 'application/json'];
+ if ($command->getMethod() !== 'POST') {
+ $headers['X-HTTP-Method-Override'] = $command->getMethod();
+ }
+
+ try {
+ $response = (new Client())
+ ->post($this->getUriFor($command->getEndpoint()), [
+ 'auth' => [$this->getUsername(), $this->getPassword()],
+ 'headers' => $headers,
+ 'json' => $command->getData(),
+ 'http_errors' => false,
+ 'verify' => false
+ ]);
+ } catch (GuzzleException $e) {
+ throw new CommandTransportException(
+ 'Can\'t connect to the Icinga 2 API: %u %s',
+ $e->getCode(),
+ $e->getMessage()
+ );
+ }
+
+ try {
+ $responseData = Json::decode((string) $response->getBody(), true);
+ } catch (JsonDecodeException $e) {
+ throw new CommandTransportException(
+ 'Got invalid JSON response from the Icinga 2 API: %s',
+ $e->getMessage()
+ );
+ }
+
+ if (! isset($responseData['results']) || empty($responseData['results'])) {
+ if (isset($responseData['error'])) {
+ throw new ApiCommandException(
+ 'Can\'t send external Icinga command: %u %s',
+ $responseData['error'],
+ $responseData['status']
+ );
+ }
+
+ return;
+ }
+
+ $errorResult = $responseData['results'][0];
+ if (isset($errorResult['code']) && ($errorResult['code'] < 200 || $errorResult['code'] >= 300)) {
+ throw new ApiCommandException(
+ 'Can\'t send external Icinga command: %u %s',
+ $errorResult['code'],
+ $errorResult['status']
+ );
+ }
+
+ return $responseData['results'];
+ }
+
+ /**
+ * Send the Icinga command over the Icinga 2 API
+ *
+ * @param IcingaCommand $command
+ * @param int|null $now
+ *
+ * @throws CommandTransportException
+ *
+ * @return mixed
+ */
+ public function send(IcingaCommand $command, int $now = null)
+ {
+ return $this->sendCommand($this->renderer->render($command));
+ }
+
+ /**
+ * Try to connect to the API
+ *
+ * @return void
+ *
+ * @throws CommandTransportException In case the connection was not successful
+ */
+ public function probe()
+ {
+ try {
+ $response = (new Client(['timeout' => 15]))
+ ->get($this->getUriFor(''), [
+ 'auth' => [$this->getUsername(), $this->getPassword()],
+ 'headers' => ['Accept' => 'application/json'],
+ 'http_errors' => false,
+ 'verify' => false
+ ]);
+ } catch (GuzzleException $e) {
+ throw new CommandTransportException(
+ 'Can\'t connect to the Icinga 2 API: %u %s',
+ $e->getCode(),
+ $e->getMessage()
+ );
+ }
+
+ try {
+ $responseData = Json::decode((string) $response->getBody(), true);
+ } catch (JsonDecodeException $e) {
+ throw new CommandTransportException(
+ 'Got invalid JSON response from the Icinga 2 API: %s',
+ $e->getMessage()
+ );
+ }
+
+ if (! isset($responseData['results']) || empty($responseData['results'])) {
+ throw new CommandTransportException(
+ 'Got invalid response from the Icinga 2 API: %s',
+ JSON::encode($responseData)
+ );
+ }
+
+ $result = array_pop($responseData['results']);
+ if (! isset($result['user']) || $result['user'] !== $this->getUsername()) {
+ throw new CommandTransportException(
+ 'Got invalid response from the Icinga 2 API: %s',
+ JSON::encode($responseData)
+ );
+ }
+ }
+}
diff --git a/library/Icingadb/Command/Transport/CommandTransport.php b/library/Icingadb/Command/Transport/CommandTransport.php
new file mode 100644
index 0000000..ea125bc
--- /dev/null
+++ b/library/Icingadb/Command/Transport/CommandTransport.php
@@ -0,0 +1,130 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+
+/**
+ * Command transport
+ */
+class CommandTransport implements CommandTransportInterface
+{
+ /**
+ * Transport configuration
+ *
+ * @var Config
+ */
+ protected static $config;
+
+ /**
+ * Get transport configuration
+ *
+ * @return Config
+ *
+ * @throws ConfigurationError
+ */
+ public static function getConfig(): Config
+ {
+ if (static::$config === null) {
+ $config = Config::module('icingadb', 'commandtransports');
+ if ($config->isEmpty()) {
+ throw new ConfigurationError(
+ t('No command transports have been configured in "%s".'),
+ $config->getConfigFile()
+ );
+ }
+
+ static::$config = $config;
+ }
+
+ return static::$config;
+ }
+
+ /**
+ * Create a transport from config
+ *
+ * @param ConfigObject $config
+ *
+ * @return ApiCommandTransport
+ *
+ * @throws ConfigurationError
+ */
+ public static function createTransport(ConfigObject $config): ApiCommandTransport
+ {
+ $config = clone $config;
+ switch (strtolower($config->transport)) {
+ case ApiCommandTransport::TRANSPORT:
+ $transport = new ApiCommandTransport();
+ break;
+ default:
+ throw new ConfigurationError(
+ t('Cannot create command transport "%s". Invalid transport defined in "%s". Use one of: %s.'),
+ $config->transport,
+ static::getConfig()->getConfigFile(),
+ join(', ', [ApiCommandTransport::TRANSPORT])
+ );
+ }
+
+ unset($config->transport);
+ foreach ($config as $key => $value) {
+ $method = 'set' . ucfirst($key);
+ if (! method_exists($transport, $method)) {
+ // Ignore settings from config that don't have a setter on the transport instead of throwing an
+ // exception here because the transport should throw an exception if it's not fully set up
+ // when being about to send a command
+ continue;
+ }
+
+ $transport->$method($value);
+ }
+
+ return $transport;
+ }
+
+ /**
+ * Send the given command over an appropriate Icinga command transport
+ *
+ * This will try one configured transport after another until the command has been successfully sent.
+ *
+ * @param IcingaCommand $command The command to send
+ * @param int|null $now Timestamp of the command or null for now
+ *
+ * @throws CommandTransportException If sending the Icinga command failed
+ *
+ * @return mixed
+ */
+ public function send(IcingaCommand $command, int $now = null)
+ {
+ $errors = [];
+
+ foreach (static::getConfig() as $name => $transportConfig) {
+ $transport = static::createTransport($transportConfig);
+
+ try {
+ $result = $transport->send($command, $now);
+ } catch (CommandTransportException $e) {
+ Logger::error($e);
+ $errors[] = sprintf('%s: %s.', $name, rtrim($e->getMessage(), '.'));
+ continue; // Try the next transport
+ }
+
+ return $result; // The command was successfully sent
+ }
+
+ if (! empty($errors)) {
+ throw new CommandTransportException(implode("\n", $errors));
+ }
+
+ throw new CommandTransportException(t(
+ 'Failed to send external Icinga command. No transport has been configured'
+ . ' for this instance. Please contact your Icinga Web administrator.'
+ ));
+ }
+}
diff --git a/library/Icingadb/Command/Transport/CommandTransportConfig.php b/library/Icingadb/Command/Transport/CommandTransportConfig.php
new file mode 100644
index 0000000..e17fa04
--- /dev/null
+++ b/library/Icingadb/Command/Transport/CommandTransportConfig.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Icinga\Repository\IniRepository;
+
+class CommandTransportConfig extends IniRepository
+{
+ protected $configs = [
+ 'transport' => [
+ 'name' => 'commandtransports',
+ 'module' => 'icingadb',
+ 'keyColumn' => 'name'
+ ]
+ ];
+
+ protected $queryColumns = [
+ 'transport' => [
+ 'name',
+ 'transport',
+
+ // API options
+ 'host',
+ 'port',
+ 'username',
+ 'password'
+ ]
+ ];
+}
diff --git a/library/Icingadb/Command/Transport/CommandTransportException.php b/library/Icingadb/Command/Transport/CommandTransportException.php
new file mode 100644
index 0000000..2ca89d9
--- /dev/null
+++ b/library/Icingadb/Command/Transport/CommandTransportException.php
@@ -0,0 +1,14 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Exception thrown if a command was not sent
+ */
+class CommandTransportException extends IcingaException
+{
+}
diff --git a/library/Icingadb/Command/Transport/CommandTransportInterface.php b/library/Icingadb/Command/Transport/CommandTransportInterface.php
new file mode 100644
index 0000000..ad07cb9
--- /dev/null
+++ b/library/Icingadb/Command/Transport/CommandTransportInterface.php
@@ -0,0 +1,23 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Command\Transport;
+
+use Icinga\Module\Icingadb\Command\IcingaCommand;
+
+/**
+ * Interface for Icinga command transports
+ */
+interface CommandTransportInterface
+{
+ /**
+ * Send an Icinga command over the Icinga command transport
+ *
+ * @param IcingaCommand $command The command to send
+ * @param int|null $now Timestamp of the command or null for now
+ *
+ * @throws CommandTransportException If sending the Icinga command failed
+ */
+ public function send(IcingaCommand $command, int $now = null);
+}
diff --git a/library/Icingadb/Common/Auth.php b/library/Icingadb/Common/Auth.php
new file mode 100644
index 0000000..d25526e
--- /dev/null
+++ b/library/Icingadb/Common/Auth.php
@@ -0,0 +1,389 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Authentication\Auth as IcingaAuth;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Icingadb\Authentication\ObjectAuthorization;
+use Icinga\Util\StringHelper;
+use ipl\Orm\Compat\FilterProcessor;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\UnionQuery;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+
+trait Auth
+{
+ public function getAuth(): IcingaAuth
+ {
+ return IcingaAuth::getInstance();
+ }
+
+ /**
+ * Check whether access to the given route is permitted
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function isPermittedRoute(string $name): bool
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return true;
+ }
+
+ // The empty array is for PHP pre 7.4, older versions require at least a single param for array_merge
+ $routeDenylist = array_flip(array_merge([], ...array_map(function ($restriction) {
+ return StringHelper::trimSplit($restriction);
+ }, $this->getAuth()->getRestrictions('icingadb/denylist/routes'))));
+
+ return ! array_key_exists($name, $routeDenylist);
+ }
+
+ /**
+ * Check whether the permission is granted on the object
+ *
+ * @param string $permission
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public function isGrantedOn(string $permission, Model $object): bool
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return $this->getAuth()->hasPermission($permission);
+ }
+
+ return ObjectAuthorization::grantsOn($permission, $object);
+ }
+
+ /**
+ * Check whether the permission is granted on objects matching the type and filter
+ *
+ * The check will be performed on every object matching the filter. Though the result
+ * only allows to determine whether the permission is granted on **any** or *none*
+ * of the objects in question. Any subsequent call to {@see Auth::isGrantedOn} will
+ * make use of the underlying results the check has determined in order to avoid
+ * unnecessary queries.
+ *
+ * @param string $permission
+ * @param string $type
+ * @param Filter\Rule $filter
+ * @param bool $cache Pass `false` to not perform the check on every object
+ *
+ * @return bool
+ */
+ public function isGrantedOnType(string $permission, string $type, Filter\Rule $filter, bool $cache = true): bool
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return $this->getAuth()->hasPermission($permission);
+ }
+
+ return ObjectAuthorization::grantsOnType($permission, $type, $filter, $cache);
+ }
+
+ /**
+ * Check whether the filter matches the given object
+ *
+ * @param string $queryString
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public function isMatchedOn(string $queryString, Model $object): bool
+ {
+ return ObjectAuthorization::matchesOn($queryString, $object);
+ }
+
+ /**
+ * Apply Icinga DB Web's restrictions depending on what is queried
+ *
+ * This will apply `icingadb/filter/objects` in any case. `icingadb/filter/services` is only
+ * applied to queries fetching services and `icingadb/filter/hosts` is applied to queries
+ * fetching either hosts or services. It also applies custom variable restrictions and
+ * obfuscations. (`icingadb/denylist/variables` and `icingadb/protect/variables`)
+ *
+ * @param Query $query
+ *
+ * @return void
+ */
+ public function applyRestrictions(Query $query)
+ {
+ if ($this->getAuth()->getUser()->isUnrestricted()) {
+ return;
+ }
+
+ if ($query instanceof UnionQuery) {
+ $queries = $query->getUnions();
+ } else {
+ $queries = [$query];
+ }
+
+ $orgQuery = $query;
+ foreach ($queries as $query) {
+ $relations = [$query->getModel()->getTableName()];
+ foreach ($query->getWith() as $relationPath => $relation) {
+ $relations[$relationPath] = $relation->getTarget()->getTableName();
+ }
+
+ $customVarRelationName = array_search('customvar_flat', $relations, true);
+ $applyServiceRestriction = in_array('service', $relations, true);
+ $applyHostRestriction = in_array('host', $relations, true)
+ // Hosts and services have a special relation as a service can't exist without its host.
+ // Hence why the hosts restriction is also applied if only services are queried.
+ || $applyServiceRestriction;
+
+ $hostStateRelation = array_search('host_state', $relations, true);
+ $serviceStateRelation = array_search('service_state', $relations, true);
+
+ $resolver = $query->getResolver();
+
+ $queryFilter = Filter::any();
+ $obfuscationRules = Filter::any();
+ foreach ($this->getAuth()->getUser()->getRoles() as $role) {
+ $roleFilter = Filter::all();
+
+ if ($customVarRelationName !== false) {
+ if (($restriction = $role->getRestrictions('icingadb/denylist/variables'))) {
+ $roleFilter->add($this->parseDenylist(
+ $restriction,
+ $customVarRelationName
+ ? $resolver->qualifyColumn('flatname', $customVarRelationName)
+ : 'flatname'
+ ));
+ }
+
+ if (($restriction = $role->getRestrictions('icingadb/protect/variables'))) {
+ $obfuscationRules->add($this->parseDenylist(
+ $restriction,
+ $customVarRelationName
+ ? $resolver->qualifyColumn('flatname', $customVarRelationName)
+ : 'flatname'
+ ));
+ }
+ }
+
+ if ($customVarRelationName === false || count($relations) > 1) {
+ if (($restriction = $role->getRestrictions('icingadb/filter/objects'))) {
+ $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/objects'));
+ }
+
+ if ($applyHostRestriction && ($restriction = $role->getRestrictions('icingadb/filter/hosts'))) {
+ $hostFilter = $this->parseRestriction($restriction, 'icingadb/filter/hosts');
+ if ($orgQuery instanceof UnionQuery) {
+ $this->forceQueryOptimization($hostFilter, 'hostgroup.name');
+ }
+
+ $roleFilter->add($hostFilter);
+ }
+
+ if (
+ $applyServiceRestriction
+ && ($restriction = $role->getRestrictions('icingadb/filter/services'))
+ ) {
+ $serviceFilter = $this->parseRestriction($restriction, 'icingadb/filter/services');
+ if ($orgQuery instanceof UnionQuery) {
+ $this->forceQueryOptimization($serviceFilter, 'servicegroup.name');
+ }
+
+ $roleFilter->add(Filter::any(Filter::unlike('service.id', '*'), $serviceFilter));
+ }
+ }
+
+ if (! $roleFilter->isEmpty()) {
+ $queryFilter->add($roleFilter);
+ }
+ }
+
+ if (! $this->getAuth()->hasPermission('icingadb/object/show-source')) {
+ // In case the user does not have permission to see the object's `Source` tab, then the user must be
+ // restricted from accessing the executed command for the object.
+ $columns = $query->getColumns();
+ $commandColumns = [];
+ if ($hostStateRelation !== false) {
+ $commandColumns[] = $resolver->qualifyColumn('check_commandline', $hostStateRelation);
+ }
+
+ if ($serviceStateRelation !== false) {
+ $commandColumns[] = $resolver->qualifyColumn('check_commandline', $serviceStateRelation);
+ }
+
+ if (! empty($columns)) {
+ foreach ($commandColumns as $commandColumn) {
+ $commandColumnPath = array_search($commandColumn, $columns, true);
+ if ($commandColumnPath !== false) {
+ $columns[$commandColumn] = new Expression("'***'");
+ unset($columns[$commandColumnPath]);
+ }
+ }
+
+ $query->columns($columns);
+ } else {
+ $query->withoutColumns($commandColumns);
+ }
+ }
+
+ if (! $obfuscationRules->isEmpty()) {
+ $flatvaluePath = $customVarRelationName
+ ? $resolver->qualifyColumn('flatvalue', $customVarRelationName)
+ : 'flatvalue';
+
+ $columns = $query->getColumns();
+ if (empty($columns)) {
+ $columns = [
+ $customVarRelationName
+ ? $resolver->qualifyColumn('flatname', $customVarRelationName)
+ : 'flatname',
+ $flatvaluePath
+ ];
+ }
+
+ $flatvalue = null;
+ if (isset($columns[$flatvaluePath])) {
+ $flatvalue = $columns[$flatvaluePath];
+ } else {
+ $flatvaluePathAt = array_search($flatvaluePath, $columns, true);
+ if ($flatvaluePathAt !== false) {
+ $flatvalue = $columns[$flatvaluePathAt];
+ if (is_int($flatvaluePathAt)) {
+ unset($columns[$flatvaluePathAt]);
+ } else {
+ $flatvaluePath = $flatvaluePathAt;
+ }
+ }
+ }
+
+ if ($flatvalue !== null) {
+ // TODO: The four lines below are needed because there is still no way to postpone filter column
+ // qualification. (i.e. Just like the expression, filter rules need to be handled the same
+ // so that their columns are qualified lazily when assembling the query)
+ $queryClone = clone $query;
+ $queryClone->getSelectBase()->resetWhere();
+ FilterProcessor::apply($obfuscationRules, $queryClone);
+ $where = $queryClone->getSelectBase()->getWhere();
+
+ $values = [];
+ $rendered = $query->getDb()->getQueryBuilder()->buildCondition($where, $values);
+ $columns[$flatvaluePath] = new Expression(
+ "CASE WHEN (" . $rendered . ") THEN (%s) ELSE '***' END",
+ [$flatvalue],
+ ...$values
+ );
+
+ $query->columns($columns);
+ }
+ }
+
+ $query->filter($queryFilter);
+ }
+ }
+
+ /**
+ * Parse the given restriction
+ *
+ * @param string $queryString
+ * @param string $restriction The name of the restriction
+ *
+ * @return Filter\Rule
+ */
+ protected function parseRestriction(string $queryString, string $restriction): Filter\Rule
+ {
+ $allowedColumns = [
+ 'host.name',
+ 'hostgroup.name',
+ 'host.user.name',
+ 'host.usergroup.name',
+ 'service.name',
+ 'servicegroup.name',
+ 'service.user.name',
+ 'service.usergroup.name',
+ '(host|service).vars.<customvar-name>' => function ($c) {
+ return preg_match('/^(?:host|service)\.vars\./i', $c);
+ }
+ ];
+
+ return QueryString::fromString($queryString)
+ ->on(
+ QueryString::ON_CONDITION,
+ function (Filter\Condition $condition) use (
+ $restriction,
+ $queryString,
+ $allowedColumns
+ ) {
+ foreach ($allowedColumns as $column) {
+ if (is_callable($column)) {
+ if ($column($condition->getColumn())) {
+ return;
+ }
+ } elseif ($column === $condition->getColumn()) {
+ return;
+ }
+ }
+
+ throw new ConfigurationError(
+ t(
+ 'Cannot apply restriction %s using the filter %s.'
+ . ' You can only use the following columns: %s'
+ ),
+ $restriction,
+ $queryString,
+ join(
+ ', ',
+ array_map(
+ function ($k, $v) {
+ return is_string($k) ? $k : $v;
+ },
+ array_keys($allowedColumns),
+ $allowedColumns
+ )
+ )
+ );
+ }
+ )->parse();
+ }
+
+ /**
+ * Parse the given denylist
+ *
+ * @param string $denylist Comma separated list of column names
+ * @param string $column The column which should not equal any of the denylisted names
+ *
+ * @return Filter\None
+ */
+ protected function parseDenylist(string $denylist, string $column): Filter\None
+ {
+ $filter = Filter::none();
+ foreach (explode(',', $denylist) as $value) {
+ $filter->add(Filter::like($column, trim($value)));
+ }
+
+ return $filter;
+ }
+
+ /**
+ * Force query optimization on the given service/host filter rule
+ *
+ * Applies forceOptimization, when the given filter rule contains the given filter column
+ *
+ * @param Filter\Rule $filterRule
+ * @param string $filterColumn
+ *
+ * @return void
+ */
+ protected function forceQueryOptimization(Filter\Rule $filterRule, string $filterColumn)
+ {
+ // TODO: This is really a very poor solution is therefore only a quick fix.
+ // We need to somehow manage to make this more enjoyable and creative!
+ if ($filterRule instanceof Filter\Chain) {
+ foreach ($filterRule as $rule) {
+ $this->forceQueryOptimization($rule, $filterColumn);
+ }
+ } elseif ($filterRule->getColumn() === $filterColumn) {
+ $filterRule->metaData()->set('forceOptimization', true);
+ }
+ }
+}
diff --git a/library/Icingadb/Common/BaseFilter.php b/library/Icingadb/Common/BaseFilter.php
new file mode 100644
index 0000000..5b1791f
--- /dev/null
+++ b/library/Icingadb/Common/BaseFilter.php
@@ -0,0 +1,13 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+/**
+ * @deprecated Use {@see \ipl\Stdlib\BaseFilter} instead. This will be removed with version 1.1
+ */
+trait BaseFilter
+{
+ use \ipl\Stdlib\BaseFilter;
+}
diff --git a/library/Icingadb/Common/BaseStatusBar.php b/library/Icingadb/Common/BaseStatusBar.php
new file mode 100644
index 0000000..add176d
--- /dev/null
+++ b/library/Icingadb/Common/BaseStatusBar.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Orm\Model;
+use ipl\Stdlib\BaseFilter;
+
+abstract class BaseStatusBar extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ /** @var ServicestateSummary|HoststateSummary */
+ protected $summary;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'status-bar'];
+
+ /**
+ * Create a host or service status bar
+ *
+ * @param ServicestateSummary|HoststateSummary $summary
+ */
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ abstract protected function assembleTotal(BaseHtmlElement $total): void;
+
+ abstract protected function createStateBadges(): BaseHtmlElement;
+
+ protected function createCount(): BaseHtmlElement
+ {
+ $total = Html::tag('span', ['class' => 'item-count']);
+
+ $this->assembleTotal($total);
+
+ return $total;
+ }
+
+ protected function assemble(): void
+ {
+ $this->add([
+ $this->createCount(),
+ $this->createStateBadges()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Common/CaptionDisabled.php b/library/Icingadb/Common/CaptionDisabled.php
new file mode 100644
index 0000000..2cee178
--- /dev/null
+++ b/library/Icingadb/Common/CaptionDisabled.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait CaptionDisabled
+{
+ /** @var bool */
+ protected $captionDisabled = false;
+
+ /**
+ * @return bool
+ */
+ public function isCaptionDisabled(): bool
+ {
+ return $this->captionDisabled;
+ }
+
+ /**
+ * @param bool $captionDisabled
+ *
+ * @return $this
+ */
+ public function setCaptionDisabled(bool $captionDisabled = true): self
+ {
+ $this->captionDisabled = $captionDisabled;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Common/CommandActions.php b/library/Icingadb/Common/CommandActions.php
new file mode 100644
index 0000000..2cd13fe
--- /dev/null
+++ b/library/Icingadb/Common/CommandActions.php
@@ -0,0 +1,308 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Forms\Command\CommandForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\AcknowledgeProblemForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\AddCommentForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ProcessCheckResultForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ScheduleCheckForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ScheduleHostDowntimeForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ScheduleServiceDowntimeForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\SendCustomNotificationForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
+use Icinga\Security\SecurityException;
+use Icinga\Web\Notification;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Web\Url;
+
+/**
+ * Trait CommandActions
+ */
+trait CommandActions
+{
+ /** @var Query $commandTargets */
+ protected $commandTargets;
+
+ /** @var Model $commandTargetModel */
+ protected $commandTargetModel;
+
+ /**
+ * Get url to view command targets, used as redirection target
+ *
+ * @return Url
+ */
+ abstract protected function getCommandTargetsUrl(): Url;
+
+ /**
+ * Get status of toggleable features
+ *
+ * @return object
+ */
+ protected function getFeatureStatus()
+ {
+ }
+
+ /**
+ * Fetch command targets
+ *
+ * @return Query|Model[]
+ */
+ abstract protected function fetchCommandTargets();
+
+ /**
+ * Get command targets
+ *
+ * @return Query|Model[]
+ */
+ protected function getCommandTargets()
+ {
+ if (! isset($this->commandTargets)) {
+ $this->commandTargets = $this->fetchCommandTargets();
+ }
+
+ return $this->commandTargets;
+ }
+
+ /**
+ * Get the model of the command targets
+ *
+ * @return Model
+ */
+ protected function getCommandTargetModel(): Model
+ {
+ if (! isset($this->commandTargetModel)) {
+ $commandTargets = $this->getCommandTargets();
+ if (is_array($commandTargets) && !empty($commandTargets)) {
+ $this->commandTargetModel = $commandTargets[0];
+ } else {
+ $this->commandTargetModel = $commandTargets->getModel();
+ }
+ }
+
+ return $this->commandTargetModel;
+ }
+
+ /**
+ * Check whether the permission is granted on any of the command targets
+ *
+ * @param string $permission
+ *
+ * @return bool
+ */
+ protected function isGrantedOnCommandTargets(string $permission): bool
+ {
+ $commandTargets = $this->getCommandTargets();
+ if (is_array($commandTargets)) {
+ foreach ($commandTargets as $commandTarget) {
+ if ($this->isGrantedOn($permission, $commandTarget)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return $this->isGrantedOnType(
+ $permission,
+ $this->getCommandTargetModel()->getTableName(),
+ $commandTargets->getFilter()
+ );
+ }
+
+ /**
+ * Assert that the permission is granted on any of the command targets
+ *
+ * @param string $permission
+ *
+ * @throws SecurityException
+ */
+ protected function assertIsGrantedOnCommandTargets(string $permission)
+ {
+ if (! $this->isGrantedOnCommandTargets($permission)) {
+ throw new SecurityException('No permission for %s', $permission);
+ }
+ }
+
+ /**
+ * Handle and register the given command form
+ *
+ * @param string|CommandForm $form
+ *
+ * @return void
+ */
+ protected function handleCommandForm($form)
+ {
+ $isXhr = $this->getRequest()->isXmlHttpRequest();
+ if ($isXhr && $this->getRequest()->isApiRequest()) {
+ // Prevents the framework already, this is just a fail-safe
+ $this->httpBadRequest('Responding with JSON during a Web request is not supported');
+ }
+
+ if (is_string($form)) {
+ /** @var CommandForm $form */
+ $form = new $form();
+ }
+
+ $form->setObjects($this->getCommandTargets());
+
+ if ($isXhr) {
+ $this->handleWebRequest($form);
+ } else {
+ $this->handleApiRequest($form);
+ }
+ }
+
+ /**
+ * Handle a Web request for the given form
+ *
+ * @param CommandForm $form
+ *
+ * @return void
+ */
+ protected function handleWebRequest(CommandForm $form): void
+ {
+ $actionUrl = $this->getRequest()->getUrl();
+ if ($this->view->compact) {
+ $actionUrl = clone $actionUrl;
+ // TODO: This solves https://github.com/Icinga/icingadb-web/issues/124 but I'd like to omit this
+ // entirely. I think it should be solved like https://github.com/Icinga/icingaweb2/pull/4300 so
+ // that a request's url object still has params like showCompact and _dev
+ $actionUrl->getParams()->add('showCompact', true);
+ }
+
+ $form->setAction($actionUrl->getAbsoluteUrl());
+ $form->on($form::ON_SUCCESS, function () {
+ // This forces the column to reload nearly instantly after the redirect
+ // and ensures the effect of the command is visible to the user asap
+ $this->getResponse()->setAutoRefreshInterval(1);
+
+ $this->redirectNow($this->getCommandTargetsUrl());
+ });
+
+ $form->handleRequest($this->getServerRequest());
+
+ $this->addContent($form);
+ }
+
+ /**
+ * Handle an API request for the given form
+ *
+ * @param CommandForm $form
+ *
+ * @return never
+ */
+ protected function handleApiRequest(CommandForm $form)
+ {
+ $form->setIsApiTarget();
+ $form->on($form::ON_SUCCESS, function () {
+ $this->getResponse()
+ ->json()
+ ->setSuccessData(Notification::getInstance()->popMessages())
+ ->sendResponse();
+ });
+
+ $form->handleRequest($this->getServerRequest());
+
+ $errors = [];
+ foreach ($form->getElements() as $element) {
+ $errors[$element->getName()] = $element->getMessages();
+ }
+
+ $response = $this->getResponse()->json();
+ $response->setHttpResponseCode(422);
+ $response->setFailData($errors)
+ ->sendResponse();
+ }
+
+ public function acknowledgeAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/acknowledge-problem');
+ $this->setTitle(t('Acknowledge Problem'));
+ $this->handleCommandForm(AcknowledgeProblemForm::class);
+ }
+
+ public function addCommentAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/comment/add');
+ $this->setTitle(t('Add Comment'));
+ $this->handleCommandForm(AddCommentForm::class);
+ }
+
+ public function checkNowAction()
+ {
+ if (! $this->isGrantedOnCommandTargets('icingadb/command/schedule-check/active-only')) {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/schedule-check');
+ }
+
+ $this->handleCommandForm(CheckNowForm::class);
+ }
+
+ public function processCheckresultAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/process-check-result');
+ $this->setTitle(t('Submit Passive Check Result'));
+ $this->handleCommandForm(ProcessCheckResultForm::class);
+ }
+
+ public function removeAcknowledgementAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/remove-acknowledgement');
+ $this->handleCommandForm(RemoveAcknowledgementForm::class);
+ }
+
+ public function scheduleCheckAction()
+ {
+ if (! $this->isGrantedOnCommandTargets('icingadb/command/schedule-check/active-only')) {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/schedule-check');
+ }
+
+ $this->setTitle(t('Reschedule Check'));
+ $this->handleCommandForm(ScheduleCheckForm::class);
+ }
+
+ public function scheduleDowntimeAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/downtime/schedule');
+
+ switch ($this->getCommandTargetModel()->getTableName()) {
+ case 'host':
+ $this->setTitle(t('Schedule Host Downtime'));
+ $this->handleCommandForm(ScheduleHostDowntimeForm::class);
+ break;
+ case 'service':
+ $this->setTitle(t('Schedule Service Downtime'));
+ $this->handleCommandForm(ScheduleServiceDowntimeForm::class);
+ break;
+ }
+ }
+
+ public function sendCustomNotificationAction()
+ {
+ $this->assertIsGrantedOnCommandTargets('icingadb/command/send-custom-notification');
+ $this->setTitle(t('Send Custom Notification'));
+ $this->handleCommandForm(SendCustomNotificationForm::class);
+ }
+
+ public function toggleFeaturesAction()
+ {
+ $commandObjects = $this->getCommandTargets();
+ $form = null;
+ if (count($commandObjects) > 1) {
+ $this->isGrantedOnCommandTargets('i/am-only-used/to-establish/the-object-auth-cache');
+ $form = new ToggleObjectFeaturesForm($this->getFeatureStatus());
+ } else {
+ foreach ($commandObjects as $object) {
+ // There's only a single result, a foreach is the most compatible way to retrieve the object
+ $form = new ToggleObjectFeaturesForm($object);
+ }
+ }
+
+ $this->handleCommandForm($form);
+ }
+}
diff --git a/library/Icingadb/Common/Database.php b/library/Icingadb/Common/Database.php
new file mode 100644
index 0000000..8fa87cc
--- /dev/null
+++ b/library/Icingadb/Common/Database.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Config as AppConfig;
+use Icinga\Data\ResourceFactory;
+use Icinga\Exception\ConfigurationError;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Config as SqlConfig;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\QueryBuilder;
+use ipl\Sql\Select;
+use PDO;
+
+trait Database
+{
+ /** @var Connection Connection to the Icinga database */
+ private $db;
+
+ /**
+ * Get the connection to the Icinga database
+ *
+ * @return Connection
+ *
+ * @throws ConfigurationError If the related resource configuration does not exist
+ */
+ public function getDb(): Connection
+ {
+ if ($this->db === null) {
+ $config = new SqlConfig(ResourceFactory::getResourceConfig(
+ AppConfig::module('icingadb')->get('icingadb', 'resource')
+ ));
+
+ $config->options = [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ];
+ if ($config->db === 'mysql') {
+ $config->options[PDO::MYSQL_ATTR_INIT_COMMAND] = "SET SESSION SQL_MODE='STRICT_TRANS_TABLES"
+ . ",NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'";
+ }
+
+ $this->db = new Connection($config);
+
+ $adapter = $this->db->getAdapter();
+ if ($adapter instanceof Pgsql) {
+ $quoted = $adapter->quoteIdentifier('user');
+ $this->db->getQueryBuilder()
+ ->on(QueryBuilder::ON_SELECT_ASSEMBLED, function (&$sql) use ($quoted) {
+ // user is a reserved key word in PostgreSQL, so we need to quote it.
+ // TODO(lippserd): This is pretty hacky,
+ // reconsider how to properly implement identifier quoting.
+ $sql = str_replace(' user ', sprintf(' %s ', $quoted), $sql);
+ $sql = str_replace(' user.', sprintf(' %s.', $quoted), $sql);
+ $sql = str_replace('(user.', sprintf('(%s.', $quoted), $sql);
+ })
+ ->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select) {
+ // For SELECT DISTINCT, all ORDER BY columns must appear in SELECT list.
+ if (! $select->getDistinct() || ! $select->hasOrderBy()) {
+ return;
+ }
+
+ $candidates = [];
+ foreach ($select->getOrderBy() as list($columnOrAlias, $_)) {
+ if ($columnOrAlias instanceof Expression) {
+ // Expressions can be and include anything,
+ // also columns that aren't already part of the SELECT list,
+ // so we're not trying to guess anything here.
+ // Such expressions must be in the SELECT list if necessary and
+ // referenced manually with an alias in ORDER BY.
+ continue;
+ }
+
+ $candidates[$columnOrAlias] = true;
+ }
+
+ foreach ($select->getColumns() as $alias => $column) {
+ if (is_int($alias)) {
+ if ($column instanceof Expression) {
+ // This is the complement to the above consideration.
+ // If it is an unaliased expression, ignore it.
+ continue;
+ }
+ } else {
+ unset($candidates[$alias]);
+ }
+
+ if (! $column instanceof Expression) {
+ unset($candidates[$column]);
+ }
+ }
+
+ if (! empty($candidates)) {
+ $select->columns(array_keys($candidates));
+ }
+ });
+ }
+ }
+
+ return $this->db;
+ }
+}
diff --git a/library/Icingadb/Common/DetailActions.php b/library/Icingadb/Common/DetailActions.php
new file mode 100644
index 0000000..b182b1f
--- /dev/null
+++ b/library/Icingadb/Common/DetailActions.php
@@ -0,0 +1,145 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+trait DetailActions
+{
+ /** @var bool */
+ protected $detailActionsDisabled = false;
+
+ /**
+ * Set whether this list should be an action-list
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setDetailActionsDisabled(bool $state = true): self
+ {
+ $this->detailActionsDisabled = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether this list should be an action-list
+ *
+ * @return bool
+ */
+ public function getDetailActionsDisabled(): bool
+ {
+ return $this->detailActionsDisabled;
+ }
+
+ /**
+ * Prepare this list as action-list
+ *
+ * @return $this
+ */
+ public function initializeDetailActions(): self
+ {
+ $this->getAttributes()
+ ->registerAttributeCallback('class', function () {
+ return $this->getDetailActionsDisabled() ? null : 'action-list';
+ })
+ ->registerAttributeCallback('data-icinga-multiselect-count-label', function () {
+ return $this->getDetailActionsDisabled() ? null : t('%d Item(s) selected');
+ })
+ ->registerAttributeCallback('data-icinga-multiselect-hint-label', function () {
+ return $this->getDetailActionsDisabled()
+ ? null
+ : t('Use shift/cmd + click/arrow keys to select multiple items');
+ });
+
+ return $this;
+ }
+
+ /**
+ * Set the url to use for multiple selected list items
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ protected function setMultiselectUrl(Url $url): self
+ {
+ $this->getAttributes()
+ ->registerAttributeCallback('data-icinga-multiselect-url', function () use ($url) {
+ return $this->getDetailActionsDisabled() ? null : (string) $url;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Set the url to use for a single selected list item
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ protected function setDetailUrl(Url $url): self
+ {
+ $this->getAttributes()
+ ->registerAttributeCallback('data-icinga-detail-url', function () use ($url) {
+ return $this->getDetailActionsDisabled() ? null : (string) $url;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Associate the given element with the given multi-selection filter
+ *
+ * @param BaseHtmlElement $element
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function addMultiselectFilterAttribute(BaseHtmlElement $element, Filter\Rule $filter): self
+ {
+ $element->getAttributes()
+ ->registerAttributeCallback('data-icinga-multiselect-filter', function () use ($filter) {
+ if ($this->getDetailActionsDisabled()) {
+ return null;
+ }
+
+ $queryString = QueryString::render($filter);
+ if ($filter instanceof Filter\Chain) {
+ $queryString = '(' . $queryString . ')';
+ }
+
+ return $queryString;
+ });
+
+ return $this;
+ }
+
+ /**
+ * Associate the given element with the given single-selection filter
+ *
+ * @param BaseHtmlElement $element
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function addDetailFilterAttribute(BaseHtmlElement $element, Filter\Rule $filter): self
+ {
+ $element->getAttributes()
+ ->registerAttributeCallback('data-action-item', function () {
+ return ! $this->getDetailActionsDisabled();
+ })
+ ->registerAttributeCallback('data-icinga-detail-filter', function () use ($filter) {
+ return $this->getDetailActionsDisabled() ? null : QueryString::render($filter);
+ });
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Common/HostLink.php b/library/Icingadb/Common/HostLink.php
new file mode 100644
index 0000000..3387220
--- /dev/null
+++ b/library/Icingadb/Common/HostLink.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\StateBall;
+
+trait HostLink
+{
+ protected function createHostLink(Host $host, bool $withStateBall = false): BaseHtmlElement
+ {
+ $content = [];
+
+ if ($withStateBall) {
+ $content[] = new StateBall($host->state->getStateText(), StateBall::SIZE_MEDIUM);
+ $content[] = ' ';
+ }
+
+ $content[] = $host->display_name;
+
+ return Html::tag('a', ['href' => Links::host($host), 'class' => 'subject'], $content);
+ }
+}
diff --git a/library/Icingadb/Common/HostLinks.php b/library/Icingadb/Common/HostLinks.php
new file mode 100644
index 0000000..e8f2880
--- /dev/null
+++ b/library/Icingadb/Common/HostLinks.php
@@ -0,0 +1,76 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Web\Url;
+
+abstract class HostLinks
+{
+ public static function acknowledge(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/acknowledge', ['name' => $host->name]);
+ }
+
+ public static function addComment(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/add-comment', ['name' => $host->name]);
+ }
+
+ public static function checkNow(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/check-now', ['name' => $host->name]);
+ }
+
+ public static function scheduleCheck(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/schedule-check', ['name' => $host->name]);
+ }
+
+ public static function comments(Host $host): Url
+ {
+ return Url::fromPath('icingadb/comments', ['host.name' => $host->name]);
+ }
+
+ public static function downtimes(Host $host): Url
+ {
+ return Url::fromPath('icingadb/downtimes', ['host.name' => $host->name]);
+ }
+
+ public static function history(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/history', ['name' => $host->name]);
+ }
+
+ public static function removeAcknowledgement(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/remove-acknowledgement', ['name' => $host->name]);
+ }
+
+ public static function scheduleDowntime(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/schedule-downtime', ['name' => $host->name]);
+ }
+
+ public static function sendCustomNotification(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/send-custom-notification', ['name' => $host->name]);
+ }
+
+ public static function processCheckresult(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/process-checkresult', ['name' => $host->name]);
+ }
+
+ public static function toggleFeatures(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/toggle-features', ['name' => $host->name]);
+ }
+
+ public static function services(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/services', ['name' => $host->name]);
+ }
+}
diff --git a/library/Icingadb/Common/HostStates.php b/library/Icingadb/Common/HostStates.php
new file mode 100644
index 0000000..06b9236
--- /dev/null
+++ b/library/Icingadb/Common/HostStates.php
@@ -0,0 +1,107 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+/**
+ * Collection of possible host states.
+ */
+class HostStates
+{
+ const UP = 0;
+
+ const DOWN = 1;
+
+ const PENDING = 99;
+
+ /**
+ * Get the integer value of the given textual host state
+ *
+ * @param string $state
+ *
+ * @return int
+ *
+ * @throws \InvalidArgumentException If the given host state is invalid, i.e. not known
+ */
+ public static function int(string $state): int
+ {
+ switch (strtolower($state)) {
+ case 'up':
+ $int = self::UP;
+ break;
+ case 'down':
+ $int = self::DOWN;
+ break;
+ case 'pending':
+ $int = self::PENDING;
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid host state %d', $state));
+ }
+
+ return $int;
+ }
+
+ /**
+ * Get the textual representation of the passed host state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given host state is invalid, i.e. not known
+ */
+ public static function text(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::UP:
+ $text = 'up';
+ break;
+ case $state === self::DOWN:
+ $text = 'down';
+ break;
+ case $state === self::PENDING:
+ $text = 'pending';
+ break;
+ case $state === null:
+ $text = 'not-available';
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid host state %d', $state));
+ }
+
+ return $text;
+ }
+
+ /**
+ * Get the translated textual representation of the passed host state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given host state is invalid, i.e. not known
+ */
+ public static function translated(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::UP:
+ $text = t('up');
+ break;
+ case $state === self::DOWN:
+ $text = t('down');
+ break;
+ case $state === self::PENDING:
+ $text = t('pending');
+ break;
+ case $state === null:
+ $text = t('not available');
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid host state %d', $state));
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icingadb/Common/IcingaRedis.php b/library/Icingadb/Common/IcingaRedis.php
new file mode 100644
index 0000000..a22a0f0
--- /dev/null
+++ b/library/Icingadb/Common/IcingaRedis.php
@@ -0,0 +1,323 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Exception;
+use Generator;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Predis\Client as Redis;
+
+class IcingaRedis
+{
+ /** @var static The singleton */
+ protected static $instance;
+
+ /** @var Redis Connection to the Icinga Redis */
+ private $redis;
+
+ /** @var bool true if no connection attempt was successful */
+ private $redisUnavailable = false;
+
+ /**
+ * Get the singleton
+ *
+ * @return static
+ */
+ public static function instance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new static();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Get whether Redis is unavailable
+ *
+ * @return bool
+ */
+ public static function isUnavailable(): bool
+ {
+ $self = self::instance();
+
+ if (! $self->redisUnavailable && $self->redis === null) {
+ try {
+ $self->getConnection();
+ } catch (Exception $_) {
+ // getConnection already logs the error
+ }
+ }
+
+ return $self->redisUnavailable;
+ }
+
+ /**
+ * Get the connection to the Icinga Redis
+ *
+ * @return Redis
+ *
+ * @throws Exception
+ */
+ public function getConnection(): Redis
+ {
+ if ($this->redisUnavailable) {
+ throw new Exception('Redis is still not available');
+ } elseif ($this->redis === null) {
+ try {
+ $primaryRedis = $this->getPrimaryRedis();
+ } catch (Exception $e) {
+ try {
+ $secondaryRedis = $this->getSecondaryRedis();
+ } catch (Exception $ee) {
+ $this->redisUnavailable = true;
+ Logger::error($ee);
+
+ throw $e;
+ }
+
+ if ($secondaryRedis === null) {
+ $this->redisUnavailable = true;
+
+ throw $e;
+ }
+
+ $this->redis = $secondaryRedis;
+
+ return $this->redis;
+ }
+
+ $primaryTimestamp = $this->getLastIcingaHeartbeat($primaryRedis);
+
+ if ($primaryTimestamp <= time() - 60) {
+ $secondaryRedis = $this->getSecondaryRedis();
+
+ if ($secondaryRedis === null) {
+ $this->redis = $primaryRedis;
+
+ return $this->redis;
+ }
+
+ $secondaryTimestamp = $this->getLastIcingaHeartbeat($secondaryRedis);
+
+ if ($secondaryTimestamp > $primaryTimestamp) {
+ $this->redis = $secondaryRedis;
+ } else {
+ $this->redis = $primaryRedis;
+ }
+ } else {
+ $this->redis = $primaryRedis;
+ }
+ }
+
+ return $this->redis;
+ }
+
+ /**
+ * Fetch host states
+ *
+ * @param array $ids The host ids to fetch results for
+ * @param array $columns The columns to include in the results
+ *
+ * @return Generator
+ */
+ public static function fetchHostState(array $ids, array $columns): Generator
+ {
+ return self::fetchState('icinga:host:state', $ids, $columns);
+ }
+
+ /**
+ * Fetch service states
+ *
+ * @param array $ids The service ids to fetch results for
+ * @param array $columns The columns to include in the results
+ *
+ * @return Generator
+ */
+ public static function fetchServiceState(array $ids, array $columns): Generator
+ {
+ return self::fetchState('icinga:service:state', $ids, $columns);
+ }
+
+ /**
+ * Fetch object states
+ *
+ * @param string $key The object key to access
+ * @param array $ids The object ids to fetch results for
+ * @param array $columns The columns to include in the results
+ *
+ * @return Generator
+ */
+ protected static function fetchState(string $key, array $ids, array $columns): Generator
+ {
+ try {
+ $results = self::instance()->getConnection()->hmget($key, $ids);
+ } catch (Exception $_) {
+ // The error has already been logged elsewhere
+ return;
+ }
+
+ foreach ($results as $i => $json) {
+ if ($json !== null) {
+ $data = json_decode($json, true);
+ $keyMap = array_fill_keys($columns, null);
+ unset($keyMap['is_overdue']); // Is calculated by Icinga DB, not Icinga 2, hence it's never in redis
+
+ // TODO: Remove once https://github.com/Icinga/icinga2/issues/9427 is fixed
+ $data['state_type'] = $data['state_type'] === 0 ? 'soft' : 'hard';
+
+ if (isset($data['in_downtime']) && is_bool($data['in_downtime'])) {
+ $data['in_downtime'] = $data['in_downtime'] ? 'y' : 'n';
+ }
+
+ if (isset($data['is_acknowledged']) && is_int($data['is_acknowledged'])) {
+ $data['is_acknowledged'] = $data['is_acknowledged'] ? 'y' : 'n';
+ }
+
+ yield $ids[$i] => array_intersect_key(array_merge($keyMap, $data), $keyMap);
+ }
+ }
+ }
+
+ /**
+ * Get the last icinga heartbeat from redis
+ *
+ * @param Redis|null $redis
+ *
+ * @return float|int|null
+ */
+ public static function getLastIcingaHeartbeat(Redis $redis = null)
+ {
+ if ($redis === null) {
+ $redis = self::instance()->getConnection();
+ }
+
+ // Predis doesn't support streams (yet).
+ // https://github.com/predis/predis/issues/607#event-3640855190
+ $rs = $redis->executeRaw(['XREAD', 'COUNT', '1', 'STREAMS', 'icinga:stats', '0']);
+
+ if (! is_array($rs)) {
+ return null;
+ }
+
+ $key = null;
+
+ foreach ($rs[0][1][0][1] as $kv) {
+ if ($key === null) {
+ $key = $kv;
+ } else {
+ if ($key === 'timestamp') {
+ return $kv / 1000;
+ }
+
+ $key = null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the primary redis instance
+ *
+ * @param Config|null $moduleConfig
+ * @param Config|null $redisConfig
+ *
+ * @return Redis
+ */
+ public static function getPrimaryRedis(Config $moduleConfig = null, Config $redisConfig = null): Redis
+ {
+ if ($moduleConfig === null) {
+ $moduleConfig = Config::module('icingadb');
+ }
+
+ if ($redisConfig === null) {
+ $redisConfig = Config::module('icingadb', 'redis');
+ }
+
+ $section = $redisConfig->getSection('redis1');
+
+ $redis = new Redis([
+ 'host' => $section->get('host', 'localhost'),
+ 'port' => $section->get('port', 6380),
+ 'password' => $section->get('password', ''),
+ 'timeout' => 0.5
+ ] + self::getTlsParams($moduleConfig));
+
+ $redis->ping();
+
+ return $redis;
+ }
+
+ /**
+ * Get the secondary redis instance if exists
+ *
+ * @param Config|null $moduleConfig
+ * @param Config|null $redisConfig
+ *
+ * @return ?Redis
+ */
+ public static function getSecondaryRedis(Config $moduleConfig = null, Config $redisConfig = null)
+ {
+ if ($moduleConfig === null) {
+ $moduleConfig = Config::module('icingadb');
+ }
+
+ if ($redisConfig === null) {
+ $redisConfig = Config::module('icingadb', 'redis');
+ }
+
+ $section = $redisConfig->getSection('redis2');
+ $host = $section->host;
+
+ if (empty($host)) {
+ return null;
+ }
+
+ $redis = new Redis([
+ 'host' => $host,
+ 'port' => $section->get('port', 6380),
+ 'password' => $section->get('password', ''),
+ 'timeout' => 0.5
+ ] + self::getTlsParams($moduleConfig));
+
+ $redis->ping();
+
+ return $redis;
+ }
+
+ private static function getTlsParams(Config $config): array
+ {
+ $config = $config->getSection('redis');
+
+ if (! $config->get('tls', false)) {
+ return [];
+ }
+
+ $ssl = [];
+
+ if ($config->get('insecure')) {
+ $ssl['verify_peer'] = false;
+ $ssl['verify_peer_name'] = false;
+ } else {
+ $ca = $config->get('ca');
+
+ if ($ca !== null) {
+ $ssl['cafile'] = $ca;
+ }
+ }
+
+ $cert = $config->get('cert');
+ $key = $config->get('key');
+
+ if ($cert !== null && $key !== null) {
+ $ssl['local_cert'] = $cert;
+ $ssl['local_pk'] = $key;
+ }
+
+ return ['scheme' => 'tls', 'ssl' => $ssl];
+ }
+}
diff --git a/library/Icingadb/Common/Icons.php b/library/Icingadb/Common/Icons.php
new file mode 100644
index 0000000..cac9f32
--- /dev/null
+++ b/library/Icingadb/Common/Icons.php
@@ -0,0 +1,30 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+class Icons
+{
+ const COMMENT = 'comment';
+
+ const HOST_DOWN = 'sitemap';
+
+ const IN_DOWNTIME = 'plug';
+
+ const IS_ACKNOWLEDGED = 'check';
+
+ const IS_FLAPPING = 'bolt';
+
+ const IS_PERSISTENT = 'thumbtack';
+
+ const NOTIFICATION = 'bell';
+
+ const REMOVE = 'trash';
+
+ const USER = 'user';
+
+ const USERGROUP = 'users';
+
+ const WARNING = 'exclamation-triangle';
+}
diff --git a/library/Icingadb/Common/Links.php b/library/Icingadb/Common/Links.php
new file mode 100644
index 0000000..5968e5f
--- /dev/null
+++ b/library/Icingadb/Common/Links.php
@@ -0,0 +1,143 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\NotificationHistory;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use ipl\Web\Url;
+
+abstract class Links
+{
+ public static function comment(Comment $comment): Url
+ {
+ return Url::fromPath('icingadb/comment', ['name' => $comment->name]);
+ }
+
+ public static function comments(): Url
+ {
+ return Url::fromPath('icingadb/comments');
+ }
+
+ public static function commentsDelete(): Url
+ {
+ return Url::fromPath('icingadb/comments/delete');
+ }
+
+ public static function commentsDetails(): Url
+ {
+ return Url::fromPath('icingadb/comments/details');
+ }
+
+ public static function downtime(Downtime $downtime): Url
+ {
+ return Url::fromPath('icingadb/downtime', ['name' => $downtime->name]);
+ }
+
+ public static function downtimes(): Url
+ {
+ return Url::fromPath('icingadb/downtimes');
+ }
+
+ public static function downtimesDelete(): Url
+ {
+ return Url::fromPath('icingadb/downtimes/delete');
+ }
+
+ public static function downtimesDetails(): Url
+ {
+ return Url::fromPath('icingadb/downtimes/details');
+ }
+
+ public static function host(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host', ['name' => $host->name]);
+ }
+
+ public static function hostSource(Host $host): Url
+ {
+ return Url::fromPath('icingadb/host/source', ['name' => $host->name]);
+ }
+
+ public static function hostsDetails(): Url
+ {
+ return Url::fromPath('icingadb/hosts/details');
+ }
+
+ public static function hostgroup($hostgroup): Url
+ {
+ return Url::fromPath('icingadb/hostgroup', ['name' => $hostgroup->name]);
+ }
+
+ public static function hosts(): Url
+ {
+ return Url::fromPath('icingadb/hosts');
+ }
+
+ public static function service(Service $service, Host $host): Url
+ {
+ return Url::fromPath('icingadb/service', ['name' => $service->name, 'host.name' => $host->name]);
+ }
+
+ public static function serviceSource(Service $service, Host $host): Url
+ {
+ return Url::fromPath('icingadb/service/source', ['name' => $service->name, 'host.name' => $host->name]);
+ }
+
+ public static function servicesDetails(): Url
+ {
+ return Url::fromPath('icingadb/services/details');
+ }
+
+ public static function servicegroup($servicegroup): Url
+ {
+ return Url::fromPath('icingadb/servicegroup', ['name' => $servicegroup->name]);
+ }
+
+ public static function services(): Url
+ {
+ return Url::fromPath('icingadb/services');
+ }
+
+ public static function toggleHostsFeatures(): Url
+ {
+ return Url::fromPath('icingadb/hosts/toggle-features');
+ }
+
+ public static function toggleServicesFeatures(): Url
+ {
+ return Url::fromPath('icingadb/services/toggle-features');
+ }
+
+ public static function user(User $user): Url
+ {
+ return Url::fromPath('icingadb/user', ['name' => $user->name]);
+ }
+
+ public static function usergroup(Usergroup $usergroup): Url
+ {
+ return Url::fromPath('icingadb/usergroup', ['name' => $usergroup->name]);
+ }
+
+ public static function users(): Url
+ {
+ return Url::fromPath('icingadb/users');
+ }
+
+ public static function usergroups(): Url
+ {
+ return Url::fromPath('icingadb/usergroups');
+ }
+
+ public static function event(History $event): Url
+ {
+ return Url::fromPath('icingadb/event', ['id' => bin2hex($event->id)]);
+ }
+}
diff --git a/library/Icingadb/Common/ListItemCommonLayout.php b/library/Icingadb/Common/ListItemCommonLayout.php
new file mode 100644
index 0000000..5a11be3
--- /dev/null
+++ b/library/Icingadb/Common/ListItemCommonLayout.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+
+trait ListItemCommonLayout
+{
+ use CaptionDisabled;
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ $header->addHtml($this->createTitle());
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->addHtml($this->createHeader());
+ if (!$this->isCaptionDisabled()) {
+ $main->addHtml($this->createCaption());
+ }
+ }
+}
diff --git a/library/Icingadb/Common/ListItemDetailedLayout.php b/library/Icingadb/Common/ListItemDetailedLayout.php
new file mode 100644
index 0000000..23aa017
--- /dev/null
+++ b/library/Icingadb/Common/ListItemDetailedLayout.php
@@ -0,0 +1,23 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+
+trait ListItemDetailedLayout
+{
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ $header->add($this->createTitle());
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->add($this->createHeader());
+ $main->add($this->createCaption());
+ $main->add($this->createFooter());
+ }
+}
diff --git a/library/Icingadb/Common/ListItemMinimalLayout.php b/library/Icingadb/Common/ListItemMinimalLayout.php
new file mode 100644
index 0000000..3cdf3a9
--- /dev/null
+++ b/library/Icingadb/Common/ListItemMinimalLayout.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+
+trait ListItemMinimalLayout
+{
+ use CaptionDisabled;
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ $header->add($this->createTitle());
+ if (! $this->isCaptionDisabled()) {
+ $header->add($this->createCaption());
+ }
+ $header->add($this->createTimestamp());
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->add($this->createHeader());
+ }
+}
diff --git a/library/Icingadb/Common/LoadMore.php b/library/Icingadb/Common/LoadMore.php
new file mode 100644
index 0000000..ad44e59
--- /dev/null
+++ b/library/Icingadb/Common/LoadMore.php
@@ -0,0 +1,111 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Generator;
+use Icinga\Module\Icingadb\Widget\ItemList\PageSeparatorItem;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Orm\ResultSet;
+use ipl\Web\Url;
+
+trait LoadMore
+{
+ /** @var int */
+ protected $pageSize;
+
+ /** @var int */
+ protected $pageNumber;
+
+ /** @var Url */
+ protected $loadMoreUrl;
+
+ /**
+ * Set the page size
+ *
+ * @param int $size
+ *
+ * @return $this
+ */
+ public function setPageSize(int $size): self
+ {
+ $this->pageSize = $size;
+
+ return $this;
+ }
+
+ /**
+ * Set the page number
+ *
+ * @param int $number
+ *
+ * @return $this
+ */
+ public function setPageNumber(int $number): self
+ {
+ $this->pageNumber = $number;
+
+ return $this;
+ }
+
+ /**
+ * Set the url to fetch more items
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setLoadMoreUrl(Url $url): self
+ {
+ $this->loadMoreUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Iterate over the given data
+ *
+ * Add the page separator and the "LoadMore" button at the desired position
+ *
+ * @param ResultSet $result
+ *
+ * @return Generator
+ */
+ protected function getIterator(ResultSet $result): Generator
+ {
+ $count = 0;
+ $pageNumber = $this->pageNumber ?: 1;
+
+ if ($pageNumber > 1) {
+ $this->add(new PageSeparatorItem($pageNumber));
+ }
+
+ foreach ($result as $data) {
+ $count++;
+
+ if ($count % $this->pageSize === 0) {
+ $pageNumber++;
+ } elseif ($count > $this->pageSize && $count % $this->pageSize === 1) {
+ $this->add(new PageSeparatorItem($pageNumber));
+ }
+
+ yield $data;
+ }
+
+ if ($count > 0 && $this->loadMoreUrl !== null) {
+ $showMore = (new ShowMore(
+ $result,
+ $this->loadMoreUrl->setParam('page', $pageNumber)
+ ->setAnchor('page-' . ($pageNumber))
+ ))
+ ->setLabel(t('Load More'))
+ ->setAttributes([
+ 'class' => 'load-more',
+ 'data-no-icinga-ajax' => true
+ ]);
+
+ $this->add($showMore->setTag('li')->addAttributes(['class' => 'list-item']));
+ }
+ }
+}
diff --git a/library/Icingadb/Common/Macros.php b/library/Icingadb/Common/Macros.php
new file mode 100644
index 0000000..4842c27
--- /dev/null
+++ b/library/Icingadb/Common/Macros.php
@@ -0,0 +1,120 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Compat\CompatHost;
+use Icinga\Module\Icingadb\Compat\CompatService;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\ResultSet;
+
+use function ipl\Stdlib\get_php_type;
+
+trait Macros
+{
+ /**
+ * Get the given string with macros being resolved
+ *
+ * @param string $input The string in which to look for macros
+ * @param Model|CompatService|CompatHost $object The host or service used to resolve the macros
+ *
+ * @return string
+ */
+ public function expandMacros(string $input, $object): string
+ {
+ if (preg_match_all('@\$([^\$\s]+)\$@', $input, $matches)) {
+ foreach ($matches[1] as $key => $value) {
+ $newValue = $this->resolveMacro($value, $object);
+ if ($newValue !== $value) {
+ $input = str_replace($matches[0][$key], $newValue, $input);
+ }
+ }
+ }
+
+ return $input;
+ }
+
+ /**
+ * Resolve a macro based on the given object
+ *
+ * @param string $macro The macro to resolve
+ * @param Model|CompatService|CompatHost $object The host or service used to resolve the macros
+ *
+ * @return string
+ */
+ public function resolveMacro(string $macro, $object): string
+ {
+ if ($object instanceof Host || (property_exists($object, 'type') && $object->type === 'host')) {
+ $objectType = 'host';
+ } else {
+ $objectType = 'service';
+ }
+
+ $path = null;
+ $macroType = $objectType;
+ $isCustomVar = false;
+ if (preg_match('/^((host|service)\.)?vars\.(.+)/', $macro, $matches)) {
+ if (! empty($matches[2])) {
+ $macroType = $matches[2];
+ }
+
+ $path = $matches[3];
+ $isCustomVar = true;
+ } elseif (preg_match('/^(\w+)\.(.+)/', $macro, $matches)) {
+ $macroType = $matches[1];
+ $path = $matches[2];
+ }
+
+ try {
+ if ($path !== null) {
+ if ($macroType !== $objectType) {
+ $value = $object->$macroType;
+ } else {
+ $value = $object;
+ }
+
+ $properties = explode('.', $path);
+
+ do {
+ $column = array_shift($properties);
+ if ($value instanceof Query || $value instanceof ResultSet || is_array($value)) {
+ Logger::debug(
+ 'Failed to resolve property "%s" on a "%s" type.',
+ $isCustomVar ? 'vars' : $column,
+ get_php_type($value)
+ );
+ $value = null;
+ break;
+ }
+
+ if ($isCustomVar) {
+ $value = $value->vars[$path];
+ break;
+ }
+
+ $value = $value->$column;
+ } while (! empty($properties) && $value !== null);
+ } else {
+ $value = $object->$macro;
+ }
+ } catch (\Exception $e) {
+ $value = null;
+ Logger::debug('Unable to resolve macro "%s". An error occurred: %s', $macro, $e);
+ }
+
+ if ($value instanceof Query || $value instanceof ResultSet || is_array($value)) {
+ Logger::debug(
+ 'It is not allowed to use "%s" as a macro which produces a "%s" type as a result.',
+ $macro,
+ get_php_type($value)
+ );
+ $value = null;
+ }
+
+ return $value !== null ? $value : $macro;
+ }
+}
diff --git a/library/Icingadb/Common/NoSubjectLink.php b/library/Icingadb/Common/NoSubjectLink.php
new file mode 100644
index 0000000..76c9a84
--- /dev/null
+++ b/library/Icingadb/Common/NoSubjectLink.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait NoSubjectLink
+{
+ /** @var bool */
+ protected $noSubjectLink = false;
+
+ /**
+ * Set whether a list item's subject should be a link
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setNoSubjectLink(bool $state = true): self
+ {
+ $this->noSubjectLink = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether a list item's subject should be a link
+ *
+ * @return bool
+ */
+ public function getNoSubjectLink(): bool
+ {
+ return $this->noSubjectLink;
+ }
+}
diff --git a/library/Icingadb/Common/ObjectInspectionDetail.php b/library/Icingadb/Common/ObjectInspectionDetail.php
new file mode 100644
index 0000000..b30797b
--- /dev/null
+++ b/library/Icingadb/Common/ObjectInspectionDetail.php
@@ -0,0 +1,348 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use DateTime;
+use DateTimeZone;
+use Exception;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Widget\Detail\CustomVarTable;
+use Icinga\Util\Format;
+use Icinga\Util\Json;
+use InvalidArgumentException;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+use ipl\Web\Widget\CopyToClipboard;
+use ipl\Web\Widget\EmptyState;
+
+abstract class ObjectInspectionDetail extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => ['object-detail', 'inspection-detail']];
+
+ /** @var Model */
+ protected $object;
+
+ /** @var array */
+ protected $attrs;
+
+ /** @var array */
+ protected $joins;
+
+ public function __construct(Model $object, array $apiResult)
+ {
+ $this->object = $object;
+ $this->attrs = $apiResult['attrs'];
+ $this->joins = $apiResult['joins'];
+ }
+
+ /**
+ * Render the object source location
+ *
+ * @return ?array
+ */
+ protected function createSourceLocation()
+ {
+ if (! isset($this->attrs['source_location'])) {
+ return;
+ }
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Source Location'))),
+ FormattedString::create(
+ t('You can find this object in %s on line %s.'),
+ new HtmlElement('strong', null, Text::create($this->attrs['source_location']['path'])),
+ new HtmlElement('strong', null, Text::create($this->attrs['source_location']['first_line']))
+ )
+ ];
+ }
+
+ /**
+ * Render object's last check result
+ *
+ * @return ?array
+ */
+ protected function createLastCheckResult()
+ {
+ if (! isset($this->attrs['last_check_result'])) {
+ return;
+ }
+
+ $command = $this->attrs['last_check_result']['command'];
+ if (is_array($command)) {
+ $command = join(' ', array_map('escapeshellarg', $command));
+ }
+
+ $denylist = [
+ 'command',
+ 'output',
+ 'type',
+ 'active'
+ ];
+
+ if ($command) {
+ $execCommand = new HtmlElement('pre', null, Text::create($command));
+ CopyToClipboard::attachTo($execCommand);
+ } else {
+ $execCommand = new EmptyState(t('n. a.'));
+ }
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Executed Command'))),
+ $execCommand,
+ new HtmlElement('h2', null, Text::create(t('Execution Details'))),
+ $this->createNameValueTable(
+ array_diff_key($this->attrs['last_check_result'], array_flip($denylist)),
+ [
+ 'execution_end' => [$this, 'formatTimestamp'],
+ 'execution_start' => [$this, 'formatTimestamp'],
+ 'schedule_end' => [$this, 'formatTimestamp'],
+ 'schedule_start' => [$this, 'formatTimestamp'],
+ 'ttl' => [$this, 'formatSeconds'],
+ 'state' => [$this, 'formatState']
+ ]
+ )
+ ];
+ }
+
+ protected function createRedisInfo(): array
+ {
+ $title = new HtmlElement('h2', null, Text::create(t('Volatile State Details')));
+
+ try {
+ $json = IcingaRedis::instance()->getConnection()
+ ->hGet("icinga:{$this->object->getTableName()}:state", bin2hex($this->object->id));
+ } catch (Exception $e) {
+ return [$title, sprintf('Failed to load redis data: %s', $e->getMessage())];
+ }
+
+ if (! $json) {
+ return [$title, new EmptyState(t('No data available in redis'))];
+ }
+
+ try {
+ $data = Json::decode($json, true);
+ } catch (JsonDecodeException $e) {
+ return [$title, sprintf('Failed to decode redis data: %s', $e->getMessage())];
+ }
+
+ $denylist = [
+ 'commandline',
+ 'environment_id',
+ 'id'
+ ];
+
+ return [$title, $this->createNameValueTable(
+ array_diff_key($data, array_flip($denylist)),
+ [
+ 'last_state_change' => [$this, 'formatMillisecondTimestamp'],
+ 'last_update' => [$this, 'formatMillisecondTimestamp'],
+ 'next_check' => [$this, 'formatMillisecondTimestamp'],
+ 'next_update' => [$this, 'formatMillisecondTimestamp'],
+ 'check_timeout' => [$this, 'formatMilliseconds'],
+ 'execution_time' => [$this, 'formatMilliseconds'],
+ 'latency' => [$this, 'formatMilliseconds'],
+ 'hard_state' => [$this, 'formatState'],
+ 'previous_soft_state' => [$this, 'formatState'],
+ 'previous_hard_state' => [$this, 'formatState'],
+ 'state' => [$this, 'formatState']
+ ]
+ )];
+ }
+
+ protected function createAttributes(): array
+ {
+ $denylist = [
+ 'name',
+ '__name',
+ 'host_name',
+ 'display_name',
+ 'last_check_result',
+ 'source_location',
+ 'templates',
+ 'package',
+ 'version',
+ 'type',
+ 'active',
+ 'paused',
+ 'ha_mode'
+ ];
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Object Attributes'))),
+ $this->createNameValueTable(
+ array_diff_key($this->attrs, array_flip($denylist)),
+ [
+ 'acknowledgement_expiry' => [$this, 'formatTimestamp'],
+ 'acknowledgement_last_change' => [$this, 'formatTimestamp'],
+ 'check_timeout' => [$this, 'formatSeconds'],
+ 'flapping_last_change' => [$this, 'formatTimestamp'],
+ 'last_check' => [$this, 'formatTimestamp'],
+ 'last_hard_state_change' => [$this, 'formatTimestamp'],
+ 'last_state_change' => [$this, 'formatTimestamp'],
+ 'last_state_ok' => [$this, 'formatTimestamp'],
+ 'last_state_up' => [$this, 'formatTimestamp'],
+ 'last_state_warning' => [$this, 'formatTimestamp'],
+ 'last_state_critical' => [$this, 'formatTimestamp'],
+ 'last_state_down' => [$this, 'formatTimestamp'],
+ 'last_state_unknown' => [$this, 'formatTimestamp'],
+ 'last_state_unreachable' => [$this, 'formatTimestamp'],
+ 'next_check' => [$this, 'formatTimestamp'],
+ 'next_update' => [$this, 'formatTimestamp'],
+ 'previous_state_change' => [$this, 'formatTimestamp'],
+ 'check_interval' => [$this, 'formatSeconds'],
+ 'retry_interval' => [$this, 'formatSeconds'],
+ 'last_hard_state' => [$this, 'formatState'],
+ 'last_state' => [$this, 'formatState'],
+ 'state' => [$this, 'formatState']
+ ]
+ )
+ ];
+ }
+
+ protected function createCustomVariables()
+ {
+ $query = $this->object->customvar
+ ->columns(['name', 'value']);
+
+ $result = [];
+ foreach ($query as $row) {
+ $result[$row->name] = json_decode($row->value, true) ?? $row->value;
+ }
+
+ if (! empty($result)) {
+ $vars = new CustomVarTable($result);
+ } else {
+ $vars = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Custom Variables'))),
+ $vars
+ ];
+ }
+
+ /**
+ * Format the given value as a json
+ *
+ * @param mixed $json
+ *
+ * @return BaseHtmlElement|string
+ */
+ private function formatJson($json)
+ {
+ if (is_scalar($json)) {
+ return Json::encode($json, JSON_UNESCAPED_SLASHES);
+ }
+
+ return new HtmlElement(
+ 'pre',
+ null,
+ Text::create(Json::encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
+ );
+ }
+
+ /**
+ * Format the given timestamp
+ *
+ * @param int|float|null $ts
+ *
+ * @return EmptyState|string
+ */
+ private function formatTimestamp($ts)
+ {
+ if (empty($ts)) {
+ return new EmptyState(t('n. a.'));
+ }
+
+ if (is_float($ts)) {
+ $dt = DateTime::createFromFormat('U.u', sprintf('%F', $ts));
+ } else {
+ $dt = (new DateTime())->setTimestamp($ts);
+ }
+
+ return $dt->setTimezone(new DateTimeZone('UTC'))
+ ->format('Y-m-d\TH:i:s.vP');
+ }
+
+ /**
+ * Format the given timestamp (in milliseconds)
+ *
+ * @param int|float|null $ms
+ *
+ * @return EmptyState|string
+ */
+ private function formatMillisecondTimestamp($ms)
+ {
+ return $this->formatTimestamp($ms / 1000.0);
+ }
+
+ private function formatSeconds($s): string
+ {
+ return Format::seconds($s);
+ }
+
+ private function formatMilliseconds($ms): string
+ {
+ return Format::seconds($ms / 1000.0);
+ }
+
+ private function formatState(int $state)
+ {
+ try {
+ switch (true) {
+ case $this->object instanceof Host:
+ return HostStates::text($state);
+ case $this->object instanceof Service:
+ return ServiceStates::text($state);
+ default:
+ return $state;
+ }
+ } catch (InvalidArgumentException $_) {
+ // The Icinga 2 API sometimes delivers strange details
+ return (string) $state;
+ }
+ }
+
+ private function createNameValueTable(array $data, array $formatters): Table
+ {
+ $table = new Table();
+ $table->addAttributes(['class' => 'name-value-table']);
+ foreach ($data as $name => $value) {
+ if (empty($value) && ($value === null || is_string($value) || is_array($value))) {
+ $value = new EmptyState(t('n. a.'));
+ } else {
+ try {
+ if (isset($formatters[$name])) {
+ $value = call_user_func($formatters[$name], $value);
+ } else {
+ $value = $this->formatJson($value);
+
+ if ($value instanceof BaseHtmlElement) {
+ CopyToClipboard::attachTo($value);
+ }
+ }
+ } catch (Exception $e) {
+ $value = new EmptyState(IcingaException::describe($e));
+ }
+ }
+
+ $table->addHtml(Table::tr([
+ Table::th($name),
+ Table::td($value)
+ ]));
+ }
+
+ return $table;
+ }
+}
diff --git a/library/Icingadb/Common/ObjectLinkDisabled.php b/library/Icingadb/Common/ObjectLinkDisabled.php
new file mode 100644
index 0000000..ca8283f
--- /dev/null
+++ b/library/Icingadb/Common/ObjectLinkDisabled.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait ObjectLinkDisabled
+{
+ /** @var bool */
+ protected $objectLinkDisabled = false;
+
+ /**
+ * Set whether list items should render host and service links
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setObjectLinkDisabled(bool $state = true): self
+ {
+ $this->objectLinkDisabled = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether list items should render host and service links
+ *
+ * @return bool
+ */
+ public function getObjectLinkDisabled(): bool
+ {
+ return $this->objectLinkDisabled;
+ }
+}
diff --git a/library/Icingadb/Common/SearchControls.php b/library/Icingadb/Common/SearchControls.php
new file mode 100644
index 0000000..7927da0
--- /dev/null
+++ b/library/Icingadb/Common/SearchControls.php
@@ -0,0 +1,69 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions;
+use ipl\Html\Html;
+use ipl\Orm\Query;
+use ipl\Web\Control\SearchBar;
+use ipl\Web\Url;
+use ipl\Web\Widget\ContinueWith;
+
+trait SearchControls
+{
+ use \ipl\Web\Compat\SearchControls {
+ \ipl\Web\Compat\SearchControls::createSearchBar as private webCreateSearchBar;
+ }
+
+ public function fetchFilterColumns(Query $query): array
+ {
+ return iterator_to_array(ObjectSuggestions::collectFilterColumns($query->getModel(), $query->getResolver()));
+ }
+
+ /**
+ * Create and return the SearchBar
+ *
+ * @param Query $query The query being filtered
+ * @param Url $redirectUrl Url to redirect to upon success
+ * @param array $preserveParams Query params to preserve when redirecting
+ *
+ * @return SearchBar
+ */
+ public function createSearchBar(Query $query, ...$params): SearchBar
+ {
+ $searchBar = $this->webCreateSearchBar($query, ...$params);
+
+ if (($wrapper = $searchBar->getWrapper()) && ! $wrapper->getWrapper()) {
+ // TODO: Remove this once ipl-web v0.7.0 is required
+ $searchBar->addWrapper(Html::tag('div', ['class' => 'search-controls']));
+ }
+
+ return $searchBar;
+ }
+
+ /**
+ * Create and return a ContinueWith
+ *
+ * This will automatically be appended to the SearchBar's wrapper. It's not necessary
+ * to add it separately as control or content!
+ *
+ * @param Url $detailsUrl
+ * @param SearchBar $searchBar
+ *
+ * @return ContinueWith
+ */
+ public function createContinueWith(Url $detailsUrl, SearchBar $searchBar): ContinueWith
+ {
+ $continueWith = new ContinueWith($detailsUrl, [$searchBar, 'getFilter']);
+ $continueWith->setTitle(t('Show bulk processing actions for all filtered results'));
+ $continueWith->setBaseTarget('_next');
+ $continueWith->getAttributes()
+ ->set('id', $this->getRequest()->protectId('continue-with'));
+
+ $searchBar->getWrapper()->add($continueWith);
+
+ return $continueWith;
+ }
+}
diff --git a/library/Icingadb/Common/ServiceLink.php b/library/Icingadb/Common/ServiceLink.php
new file mode 100644
index 0000000..75ac6c6
--- /dev/null
+++ b/library/Icingadb/Common/ServiceLink.php
@@ -0,0 +1,40 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\FormattedString;
+use ipl\Html\Html;
+use ipl\Web\Widget\StateBall;
+
+trait ServiceLink
+{
+ protected function createServiceLink(Service $service, Host $host, bool $withStateBall = false): FormattedString
+ {
+ $content = [];
+
+ if ($withStateBall) {
+ $content[] = new StateBall($service->state->getStateText(), StateBall::SIZE_MEDIUM);
+ $content[] = ' ';
+ }
+
+ $content[] = $service->display_name;
+
+ return Html::sprintf(
+ t('%s on %s', '<service> on <host>'),
+ Html::tag('a', ['href' => Links::service($service, $host), 'class' => 'subject'], $content),
+ Html::tag(
+ 'a',
+ ['href' => Links::host($host), 'class' => 'subject'],
+ [
+ new StateBall($host->state->getStateText(), StateBall::SIZE_MEDIUM),
+ ' ',
+ $host->display_name
+ ]
+ )
+ );
+ }
+}
diff --git a/library/Icingadb/Common/ServiceLinks.php b/library/Icingadb/Common/ServiceLinks.php
new file mode 100644
index 0000000..368be48
--- /dev/null
+++ b/library/Icingadb/Common/ServiceLinks.php
@@ -0,0 +1,108 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Web\Url;
+
+abstract class ServiceLinks
+{
+ public static function acknowledge(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/acknowledge',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function addComment(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/add-comment',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function checkNow(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/check-now',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function scheduleCheck(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/schedule-check',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function comments(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/comments',
+ ['service.name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function downtimes(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/downtimes',
+ ['service.name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function history(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/history',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function removeAcknowledgement(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/remove-acknowledgement',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function scheduleDowntime(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/schedule-downtime',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function sendCustomNotification(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/send-custom-notification',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function processCheckresult(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/process-checkresult',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+
+ public static function toggleFeatures(Service $service, Host $host): Url
+ {
+ return Url::fromPath(
+ 'icingadb/service/toggle-features',
+ ['name' => $service->name, 'host.name' => $host->name]
+ );
+ }
+}
diff --git a/library/Icingadb/Common/ServiceStates.php b/library/Icingadb/Common/ServiceStates.php
new file mode 100644
index 0000000..526f95e
--- /dev/null
+++ b/library/Icingadb/Common/ServiceStates.php
@@ -0,0 +1,129 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+/**
+ * Collection of possible service states.
+ */
+class ServiceStates
+{
+ const OK = 0;
+
+ const WARNING = 1;
+
+ const CRITICAL = 2;
+
+ const UNKNOWN = 3;
+
+ const PENDING = 99;
+
+ /**
+ * Get the integer value of the given textual service state
+ *
+ * @param string $state
+ *
+ * @return int
+ *
+ * @throws \InvalidArgumentException If the given service state is invalid, i.e. not known
+ */
+ public static function int(string $state): int
+ {
+ switch (strtolower($state)) {
+ case 'ok':
+ $int = self::OK;
+ break;
+ case 'warning':
+ $int = self::WARNING;
+ break;
+ case 'critical':
+ $int = self::CRITICAL;
+ break;
+ case 'unknown':
+ $int = self::UNKNOWN;
+ break;
+ case 'pending':
+ $int = self::PENDING;
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid service state %d', $state));
+ }
+
+ return $int;
+ }
+
+ /**
+ * Get the textual representation of the passed service state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given service state is invalid, i.e. not known
+ */
+ public static function text(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::OK:
+ $text = 'ok';
+ break;
+ case $state === self::WARNING:
+ $text = 'warning';
+ break;
+ case $state === self::CRITICAL:
+ $text = 'critical';
+ break;
+ case $state === self::UNKNOWN:
+ $text = 'unknown';
+ break;
+ case $state === self::PENDING:
+ $text = 'pending';
+ break;
+ case $state === null:
+ $text = 'not-available';
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid service state %d', $state));
+ }
+
+ return $text;
+ }
+
+ /**
+ * Get the translated textual representation of the passed service state
+ *
+ * @param int|null $state
+ *
+ * @return string
+ *
+ * @throws \InvalidArgumentException If the given service state is invalid, i.e. not known
+ */
+ public static function translated(int $state = null): string
+ {
+ switch (true) {
+ case $state === self::OK:
+ $text = t('ok');
+ break;
+ case $state === self::WARNING:
+ $text = t('warning');
+ break;
+ case $state === self::CRITICAL:
+ $text = t('critical');
+ break;
+ case $state === self::UNKNOWN:
+ $text = t('unknown');
+ break;
+ case $state === self::PENDING:
+ $text = t('pending');
+ break;
+ case $state === null:
+ $text = t('not available');
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf('Invalid service state %d', $state));
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icingadb/Common/StateBadges.php b/library/Icingadb/Common/StateBadges.php
new file mode 100644
index 0000000..c9c5c89
--- /dev/null
+++ b/library/Icingadb/Common/StateBadges.php
@@ -0,0 +1,195 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Stdlib\BaseFilter;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+abstract class StateBadges extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ /** @var object $item */
+ protected $item;
+
+ /** @var string */
+ protected $type;
+
+ /** @var string Prefix */
+ protected $prefix;
+
+ /** @var Url Badge link */
+ protected $url;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'state-badges'];
+
+ /**
+ * Create a new widget for state badges
+ *
+ * @param object $item
+ */
+ public function __construct($item)
+ {
+ $this->item = $item;
+ $this->type = $this->getType();
+ $this->prefix = $this->getPrefix();
+ $this->url = $this->getBaseUrl();
+ }
+
+ /**
+ * Get the badge base URL
+ *
+ * @return Url
+ */
+ abstract protected function getBaseUrl(): Url;
+
+ /**
+ * Get the type of the items
+ *
+ * @return string
+ */
+ abstract protected function getType(): string;
+
+ /**
+ * Get the prefix for accessing state information
+ *
+ * @return string
+ */
+ abstract protected function getPrefix(): string;
+
+ /**
+ * Get the integer of the given state text
+ *
+ * @param string $state
+ *
+ * @return int
+ */
+ abstract protected function getStateInt(string $state): int;
+
+ /**
+ * Get the badge URL
+ *
+ * @return Url
+ */
+ public function getUrl(): Url
+ {
+ return $this->url;
+ }
+
+ /**
+ * Set the badge URL
+ *
+ * @param Url $url
+ *
+ * @return $this
+ */
+ public function setUrl(Url $url): self
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Create a badge link
+ *
+ * @param mixed $content
+ * @param ?Filter\Rule $filter
+ *
+ * @return Link
+ */
+ public function createLink($content, Filter\Rule $filter = null): Link
+ {
+ $url = clone $this->getUrl();
+
+ $urlFilter = Filter::all();
+ if ($filter !== null) {
+ $urlFilter->add($filter);
+ }
+
+ if ($this->hasBaseFilter()) {
+ $urlFilter->add($this->getBaseFilter());
+ }
+
+ if (! $urlFilter->isEmpty()) {
+ $url->setFilter($urlFilter);
+ }
+
+ return new Link($content, $url);
+ }
+
+ /**
+ * Create a state bade
+ *
+ * @param string $state
+ *
+ * @return ?BaseHtmlElement
+ */
+ protected function createBadge(string $state)
+ {
+ $key = $this->prefix . "_{$state}";
+
+ if (isset($this->item->$key) && $this->item->$key) {
+ return Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$key, $state),
+ Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state))
+ ));
+ }
+
+ return null;
+ }
+
+ /**
+ * Create a state group
+ *
+ * @param string $state
+ *
+ * @return ?BaseHtmlElement
+ */
+ protected function createGroup(string $state)
+ {
+ $content = [];
+ $handledKey = $this->prefix . "_{$state}_handled";
+ $unhandledKey = $this->prefix . "_{$state}_unhandled";
+
+ if (isset($this->item->$unhandledKey) && $this->item->$unhandledKey) {
+ $content[] = Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$unhandledKey, $state),
+ Filter::all(
+ Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)),
+ Filter::equal($this->type . '.state.is_handled', 'n'),
+ Filter::equal($this->type . '.state.is_reachable', 'y')
+ )
+ ));
+ }
+
+ if (isset($this->item->$handledKey) && $this->item->$handledKey) {
+ $content[] = Html::tag('li', $this->createLink(
+ new StateBadge($this->item->$handledKey, $state, true),
+ Filter::all(
+ Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)),
+ Filter::any(
+ Filter::equal($this->type . '.state.is_handled', 'y'),
+ Filter::equal($this->type . '.state.is_reachable', 'n')
+ )
+ )
+ ));
+ }
+
+ if (empty($content)) {
+ return null;
+ }
+
+ return Html::tag('li', Html::tag('ul', $content));
+ }
+}
diff --git a/library/Icingadb/Common/TicketLinks.php b/library/Icingadb/Common/TicketLinks.php
new file mode 100644
index 0000000..6cf7e76
--- /dev/null
+++ b/library/Icingadb/Common/TicketLinks.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+use Icinga\Application\Hook;
+
+trait TicketLinks
+{
+ /** @var bool */
+ protected $ticketLinkEnabled = false;
+
+ /**
+ * Set whether list items should render host and service links
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setTicketLinkEnabled(bool $state = true): self
+ {
+ $this->ticketLinkEnabled = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get whether list items should render host and service links
+ *
+ * @return bool
+ */
+ public function getTicketLinkEnabled(): bool
+ {
+ return $this->ticketLinkEnabled;
+ }
+
+ /**
+ * Get whether list items should render host and service links
+ *
+ * @return string
+ */
+ public function createTicketLinks($text): string
+ {
+ if (Hook::has('ticket')) {
+ $tickets = Hook::first('ticket');
+ }
+
+ if ($this->getTicketLinkEnabled() && isset($tickets)) {
+ /** @var \Icinga\Application\Hook\TicketHook $tickets */
+ return $tickets->createLinks($text);
+ }
+
+ return $text;
+ }
+}
diff --git a/library/Icingadb/Common/ViewMode.php b/library/Icingadb/Common/ViewMode.php
new file mode 100644
index 0000000..841f28b
--- /dev/null
+++ b/library/Icingadb/Common/ViewMode.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Common;
+
+trait ViewMode
+{
+ /** @var string */
+ protected $viewMode;
+
+ /**
+ * Get the view mode
+ *
+ * @return ?string
+ */
+ public function getViewMode()
+ {
+ return $this->viewMode;
+ }
+
+ /**
+ * Set the view mode
+ *
+ * @param string $viewMode
+ *
+ * @return $this
+ */
+ public function setViewMode(string $viewMode): self
+ {
+ $this->viewMode = $viewMode;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Compat/CompatHost.php b/library/Icingadb/Compat/CompatHost.php
new file mode 100644
index 0000000..12be31f
--- /dev/null
+++ b/library/Icingadb/Compat/CompatHost.php
@@ -0,0 +1,103 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Compat;
+
+use Icinga\Module\Monitoring\Object\Host;
+
+class CompatHost extends Host
+{
+ use CompatObject;
+
+ private $legacyColumns = [
+ 'host_action_url' => ['path' => ['action_url', 'action_url']],
+ 'action_url' => ['path' => ['action_url', 'action_url']],
+ 'host_address' => ['path' => ['address']],
+ 'host_address6' => ['path' => ['address6']],
+ 'host_alias' => ['path' => ['display_name']],
+ 'host_check_interval' => ['path' => ['check_interval']],
+ 'host_display_name' => ['path' => ['display_name']],
+ 'host_icon_image' => ['path' => ['icon_image', 'icon_image']],
+ 'host_icon_image_alt' => ['path' => ['icon_image_alt']],
+ 'host_name' => ['path' => ['name']],
+ 'host_notes' => ['path' => ['notes']],
+ 'host_notes_url' => ['path' => ['notes_url', 'notes_url']],
+ 'host_acknowledged' => [
+ 'path' => ['state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'host_acknowledgement_type' => [
+ 'path' => ['state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'host_active_checks_enabled' => [
+ 'path' => ['active_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_active_checks_enabled_changed' => null,
+ 'host_attempt' => null,
+ 'host_check_command' => ['path' => ['checkcommand_name']],
+ 'host_check_execution_time' => ['path' => ['state', 'execution_time']],
+ 'host_check_latency' => ['path' => ['state', 'latency']],
+ 'host_check_source' => ['path' => ['state', 'check_source']],
+ 'host_check_timeperiod' => ['path' => ['check_timeperiod_name']],
+ 'host_current_check_attempt' => ['path' => ['state', 'check_attempt']],
+ 'host_current_notification_number' => null,
+ 'host_event_handler_enabled' => [
+ 'path' => ['event_handler_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_event_handler_enabled_changed' => null,
+ 'host_flap_detection_enabled' => [
+ 'path' => ['flapping_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_flap_detection_enabled_changed' => null,
+ 'host_handled' => [
+ 'path' => ['state', 'is_handled'],
+ 'type' => 'bool'
+ ],
+ 'host_in_downtime' => [
+ 'path' => ['state', 'in_downtime'],
+ 'type' => 'bool'
+ ],
+ 'host_is_flapping' => [
+ 'path' => ['state', 'is_flapping'],
+ 'type' => 'bool'
+ ],
+ 'host_is_reachable' => [
+ 'path' => ['state', 'is_reachable'],
+ 'type' => 'bool'
+ ],
+ 'host_last_check' => ['path' => ['state', 'last_update']],
+ 'host_last_notification' => null,
+ 'host_last_state_change' => ['path' => ['state', 'last_state_change']],
+ 'host_long_output' => ['path' => ['state', 'long_output']],
+ 'host_max_check_attempts' => ['path' => ['max_check_attempts']],
+ 'host_next_check' => ['path' => ['state', 'next_check']],
+ 'host_next_update' => ['path' => ['state', 'next_update']],
+ 'host_notifications_enabled' => [
+ 'path' => ['notifications_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_notifications_enabled_changed' => null,
+ 'host_obsessing' => null,
+ 'host_obsessing_changed' => null,
+ 'host_output' => ['path' => ['state', 'output']],
+ 'host_passive_checks_enabled' => [
+ 'path' => ['passive_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_passive_checks_enabled_changed' => null,
+ 'host_percent_state_change' => null,
+ 'host_perfdata' => ['path' => ['state', 'performance_data']],
+ 'host_process_perfdata' => [
+ 'path' => ['perfdata_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_state' => ['path' => ['state', 'soft_state']],
+ 'host_state_type' => ['path' => ['state', 'state_type']],
+ 'instance_name' => null
+ ];
+}
diff --git a/library/Icingadb/Compat/CompatObject.php b/library/Icingadb/Compat/CompatObject.php
new file mode 100644
index 0000000..6a30751
--- /dev/null
+++ b/library/Icingadb/Compat/CompatObject.php
@@ -0,0 +1,373 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Compat;
+
+use Icinga\Exception\NotImplementedError;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\Servicegroup;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use InvalidArgumentException;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+use LogicException;
+
+use function ipl\Stdlib\get_php_type;
+
+trait CompatObject
+{
+ use Auth;
+ use Database;
+
+ /** @var array Non-obscured custom variables */
+ protected $rawCustomvars;
+
+ /** @var array Non-obscured host custom variables */
+ protected $rawHostCustomvars;
+
+ /** @var Model $object */
+ private $object;
+
+ public function __construct(Model $object)
+ {
+ $this->object = $object;
+ }
+
+ public static function fromModel(Model $object)
+ {
+ switch (true) {
+ case $object instanceof Host:
+ return new CompatHost($object);
+ case $object instanceof Service:
+ return new CompatService($object);
+ default:
+ throw new InvalidArgumentException(sprintf(
+ 'Host or Service Model instance expected, got "%s" instead.',
+ get_php_type($object)
+ ));
+ }
+ }
+
+ /**
+ * Get this object's name
+ *
+ * @return string
+ */
+ public function getName(): string
+ {
+ return $this->object->name;
+ }
+
+ public function fetch(): bool
+ {
+ return true;
+ }
+
+ protected function fetchRawCustomvars(): self
+ {
+ if ($this->rawCustomvars !== null) {
+ return $this;
+ }
+
+ $vars = $this->object->customvar->execute();
+
+ $customVars = [];
+ foreach ($vars as $row) {
+ $customVars[$row->name] = $row->value;
+ }
+
+ $this->rawCustomvars = $customVars;
+
+ return $this;
+ }
+
+ protected function fetchRawHostCustomvars(): self
+ {
+ if ($this->rawHostCustomvars !== null) {
+ return $this;
+ }
+
+ $vars = $this->object->host->customvar->execute();
+
+ $customVars = [];
+ foreach ($vars as $row) {
+ $customVars[$row->name] = $row->value;
+ }
+
+ $this->rawHostCustomvars = $customVars;
+
+ return $this;
+ }
+
+ public function fetchComments()
+ {
+ $this->comments = [];
+
+ return $this;
+ }
+
+ public function fetchContactgroups()
+ {
+ $this->contactgroups = [];
+
+ return $this;
+ }
+
+ public function fetchContacts()
+ {
+ $this->contacts = [];
+
+ return $this;
+ }
+
+ public function fetchCustomvars(): self
+ {
+ if ($this->customvars !== null) {
+ return $this;
+ }
+
+ $this->customvars = (new CustomvarFlat())->unFlattenVars($this->object->customvar_flat);
+
+ return $this;
+ }
+
+ public function fetchHostVariables()
+ {
+ if (isset($this->hostVariables)) {
+ return $this;
+ }
+
+ $this->hostVariables = [];
+ foreach ($this->object->customvar as $customvar) {
+ $this->hostVariables[strtolower($customvar->name)] = json_decode($customvar->value);
+ }
+
+ return $this;
+ }
+
+ public function fetchServiceVariables()
+ {
+ if (isset($this->serviceVariables)) {
+ return $this;
+ }
+
+ $this->serviceVariables = [];
+ foreach ($this->object->customvar as $customvar) {
+ $this->serviceVariables[strtolower($customvar->name)] = json_decode($customvar->value);
+ }
+
+ return $this;
+ }
+
+ public function fetchDowntimes()
+ {
+ $this->downtimes = [];
+
+ return $this;
+ }
+
+ public function fetchEventhistory()
+ {
+ $this->eventhistory = [];
+
+ return $this;
+ }
+
+ public function fetchHostgroups()
+ {
+ if ($this->type === self::TYPE_HOST) {
+ $hostname = $this->object->name;
+ $hostgroupQuery = clone $this->object->hostgroup;
+ } else {
+ $hostname = $this->object->host->name;
+ $hostgroupQuery = clone $this->object->host->hostgroup;
+ }
+
+ $hostgroupQuery
+ ->columns(['name', 'display_name'])
+ ->filter(Filter::equal('host.name', $hostname));
+
+ /** @var Query $hostgroupQuery */
+ $this->hostgroups = [];
+ foreach ($hostgroupQuery as $hostgroup) {
+ $this->hostgroups[$hostgroup->name] = $hostgroup->display_name;
+ }
+
+ return $this;
+ }
+
+ public function fetchServicegroups()
+ {
+ if ($this->type === self::TYPE_HOST) {
+ $hostname = $this->object->name;
+ $query = Servicegroup::on($this->getDb());
+ } else {
+ $hostname = $this->object->host->name;
+ $query = (clone $this->object->servicegroup);
+ }
+
+ $query
+ ->columns(['name', 'display_name'])
+ ->filter(Filter::equal('host.name', $hostname));
+
+ if ($this->type === self::TYPE_SERVICE) {
+ $query->filter(Filter::equal('service.name', $this->object->name));
+ }
+
+ $this->servicegroups = [];
+ foreach ($query as $serviceGroup) {
+ $this->servicegroups[$serviceGroup->name] = $serviceGroup->display_name;
+ }
+
+ return $this;
+ }
+
+ public function fetchStats()
+ {
+ $query = ServicestateSummary::on($this->getDb());
+
+ if ($this->type === self::TYPE_HOST) {
+ $query->filter(Filter::equal('host.name', $this->object->name));
+ } else {
+ $query->filter(Filter::all(
+ Filter::equal('host.name', $this->object->host->name),
+ Filter::equal('service.name', $this->object->name)
+ ));
+ }
+
+ $result = $query->first();
+
+ $this->stats = (object) [
+ 'services_total' => $result->services_total,
+ 'services_ok' => $result->services_ok,
+ 'services_critical' => $result->services_critical_handled + $result->services_critical_unhandled,
+ 'services_critical_unhandled' => $result->services_critical_unhandled,
+ 'services_critical_handled' => $result->services_critical_handled,
+ 'services_warning' => $result->services_warning_handled + $result->services_warning_unhandled,
+ 'services_warning_unhandled' => $result->services_warning_unhandled,
+ 'services_warning_handled' => $result->services_warning_handled,
+ 'services_unknown' => $result->services_unknown_handled + $result->services_unknown_unhandled,
+ 'services_unknown_unhandled' => $result->services_unknown_unhandled,
+ 'services_unknown_handled' => $result->services_unknown_handled,
+ 'services_pending' => $result->services_pending
+ ];
+
+ return $this;
+ }
+
+ public function __get($name)
+ {
+ if (property_exists($this, $name)) {
+ if ($this->$name === null) {
+ $fetchMethod = 'fetch' . ucfirst($name);
+ $this->$fetchMethod();
+ }
+
+ return $this->$name;
+ }
+
+ if (preg_match('/^_(host|service)_(.+)/i', $name, $matches)) {
+ switch (strtolower($matches[1])) {
+ case $this->type:
+ $customvars = $this->fetchRawCustomvars()->rawCustomvars;
+ break;
+ case self::TYPE_HOST:
+ $customvars = $this->fetchRawHostCustomvars()->rawHostCustomvars;
+ break;
+ case self::TYPE_SERVICE:
+ throw new LogicException('Cannot fetch service custom variables for non-service objects');
+ }
+
+ $variableName = strtolower($matches[2]);
+ if (isset($customvars[$variableName])) {
+ return $customvars[$variableName];
+ }
+
+ return null; // Unknown custom variables MUST NOT throw an error
+ }
+
+ if (! array_key_exists($name, $this->legacyColumns) && ! $this->object->hasProperty($name)) {
+ if (isset($this->customvars[$name])) {
+ return $this->customvars[$name];
+ }
+
+ if (substr($name, 0, strlen($this->prefix)) !== $this->prefix) {
+ $name = $this->prefix . $name;
+ }
+ }
+
+ if (array_key_exists($name, $this->legacyColumns)) {
+ $opts = $this->legacyColumns[$name];
+ if ($opts === null) {
+ return null;
+ }
+
+ $path = $opts['path'];
+ $value = null;
+
+ if (! empty($path)) {
+ $value = $this->object;
+
+ do {
+ $col = array_shift($path);
+ $value = $value->$col;
+ } while (! empty($path) && $value !== null);
+ }
+
+ if (isset($opts['type'])) {
+ $method = 'get' . ucfirst($opts['type']) . 'Type';
+ $value = $this->$method($value);
+ }
+
+ return $value;
+ }
+
+ return $this->object->$name;
+ }
+
+ public function __isset($name)
+ {
+ if (property_exists($this, $name)) {
+ return isset($this->$name);
+ }
+
+ if (isset($this->legacyColumns[$name]) || isset($this->object->$name)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @throws NotImplementedError Don't use!
+ */
+ protected function getDataView()
+ {
+ throw new NotImplementedError('getDataView() is not supported');
+ }
+
+ /**
+ * Get the bool type of the given value as an int
+ *
+ * @param bool|string $value
+ *
+ * @return ?int
+ */
+ private function getBoolType($value)
+ {
+ switch ($value) {
+ case false:
+ return 0;
+ case true:
+ return 1;
+ case 'sticky':
+ return 2;
+ }
+ }
+}
diff --git a/library/Icingadb/Compat/CompatService.php b/library/Icingadb/Compat/CompatService.php
new file mode 100644
index 0000000..7fe599e
--- /dev/null
+++ b/library/Icingadb/Compat/CompatService.php
@@ -0,0 +1,156 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Compat;
+
+use Icinga\Module\Monitoring\Object\Service;
+
+class CompatService extends Service
+{
+ use CompatObject;
+
+ private $legacyColumns = [
+ 'instance_name' => null,
+ 'host_attempt' => null,
+ 'host_icon_image' => ['path' => ['host', 'icon_image', 'icon_image']],
+ 'host_icon_image_alt' => ['path' => ['host', 'icon_image_alt']],
+ 'host_acknowledged' => [
+ 'path' => ['host', 'state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'host_active_checks_enabled' => [
+ 'path' => ['host', 'active_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_address' => ['path' => ['host', 'address']],
+ 'host_address6' => ['path' => ['host', 'address6']],
+ 'host_alias' => ['path' => ['host', 'display_name']],
+ 'host_display_name' => ['path' => ['host', 'display_name']],
+ 'host_handled' => [
+ 'path' => ['host', 'state', 'is_handled'],
+ 'type' => 'bool'
+ ],
+ 'host_in_downtime' => [
+ 'path' => ['host', 'state', 'in_downtime'],
+ 'type' => 'bool'
+ ],
+ 'host_is_flapping' => [
+ 'path' => ['host', 'state', 'is_flapping'],
+ 'type' => 'bool'
+ ],
+ 'host_last_state_change' => ['path' => ['host', 'state', 'last_state_change']],
+ 'host_name' => ['path' => ['host', 'name']],
+ 'host_notifications_enabled' => [
+ 'path' => ['host', 'notifications_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_passive_checks_enabled' => [
+ 'path' => ['host', 'passive_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'host_state' => ['path' => ['host', 'state', 'soft_state']],
+ 'host_state_type' => ['path' => ['host', 'state', 'state_type']],
+ 'service_icon_image' => ['path' => ['icon_image', 'icon_image']],
+ 'service_icon_image_alt' => ['path' => ['icon_image_alt']],
+ 'service_acknowledged' => [
+ 'path' => ['state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'service_acknowledgement_type' => [
+ 'path' => ['state', 'is_acknowledged'],
+ 'type' => 'bool'
+ ],
+ 'service_action_url' => ['path' => ['action_url', 'action_url']],
+ 'action_url' => ['path' => ['action_url', 'action_url']],
+ 'service_active_checks_enabled' => [
+ 'path' => ['active_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_active_checks_enabled_changed' => null,
+ 'service_attempt' => null,
+ 'service_check_command' => ['path' => ['checkcommand_name']],
+ 'service_check_execution_time' => ['path' => ['state', 'execution_time']],
+ 'service_check_interval' => ['path' => ['check_interval']],
+ 'service_check_latency' => ['path' => ['state', 'latency']],
+ 'service_check_source' => ['path' => ['state', 'check_source']],
+ 'service_check_timeperiod' => ['path' => ['check_timeperiod_name']],
+ 'service_current_notification_number' => null,
+ 'service_description' => ['path' => ['name']],
+ 'service_display_name' => ['path' => ['display_name']],
+ 'service_event_handler_enabled' => [
+ 'path' => ['event_handler_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_event_handler_enabled_changed' => null,
+ 'service_flap_detection_enabled' => [
+ 'path' => ['flapping_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_flap_detection_enabled_changed' => null,
+ 'service_handled' => [
+ 'path' => ['state', 'is_handled'],
+ 'type' => 'bool'
+ ],
+ 'service_in_downtime' => [
+ 'path' => ['state', 'in_downtime'],
+ 'type' => 'bool'
+ ],
+ 'service_is_flapping' => [
+ 'path' => ['state', 'is_flapping'],
+ 'type' => 'bool'
+ ],
+ 'service_is_reachable' => [
+ 'path' => ['state', 'is_reachable'],
+ 'type' => 'bool'
+ ],
+ 'service_last_check' => ['path' => ['state', 'last_update']],
+ 'service_last_notification' => null,
+ 'service_last_state_change' => ['path' => ['state', 'last_state_change']],
+ 'service_long_output' => ['path' => ['state', 'long_output']],
+ 'service_next_check' => ['path' => ['state', 'next_check']],
+ 'service_next_update' => ['path' => ['state', 'next_update']],
+ 'service_notes' => ['path' => ['notes']],
+ 'service_notes_url' => ['path' => ['notes_url', 'notes_url']],
+ 'service_notifications_enabled' => [
+ 'path' => ['notifications_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_notifications_enabled_changed' => null,
+ 'service_obsessing' => null,
+ 'service_obsessing_changed' => null,
+ 'service_output' => ['path' => ['state', 'output']],
+ 'service_passive_checks_enabled' => [
+ 'path' => ['passive_checks_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_passive_checks_enabled_changed' => null,
+ 'service_percent_state_change' => null,
+ 'service_perfdata' => ['path' => ['state', 'performance_data']],
+ 'service_process_perfdata' => [
+ 'path' => ['perfdata_enabled'],
+ 'type' => 'bool'
+ ],
+ 'service_state' => ['path' => ['state', 'soft_state']],
+ 'service_state_type' => ['path' => ['state', 'state_type']]
+ ];
+
+ /**
+ * Get this service's host
+ *
+ * @return CompatHost
+ */
+ public function getHost(): CompatHost
+ {
+ if ($this->host === null) {
+ $this->host = new CompatHost($this->object->host);
+ }
+
+ return $this->host;
+ }
+
+ protected function fetchHost()
+ {
+ $this->getHost();
+ }
+}
diff --git a/library/Icingadb/Compat/UrlMigrator.php b/library/Icingadb/Compat/UrlMigrator.php
new file mode 100644
index 0000000..47780be
--- /dev/null
+++ b/library/Icingadb/Compat/UrlMigrator.php
@@ -0,0 +1,1353 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Compat;
+
+use Icinga\Web\UrlParams;
+use InvalidArgumentException;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class UrlMigrator
+{
+ const NO_YES = ['n', 'y'];
+ const USE_EXPR = 'use-expr';
+ const SORT_ONLY = 'sort-only';
+ const LOWER_EXPR = 'lower-expr';
+ const DROP = 'drop';
+
+ const SUPPORTED_PATHS = [
+ 'monitoring/list/hosts' => ['hosts', 'icingadb/hosts'],
+ 'monitoring/hosts/show' => ['multipleHosts', 'icingadb/hosts/details'],
+ 'monitoring/host/show' => ['host', 'icingadb/host'],
+ 'monitoring/host/services' => ['host', 'icingadb/host/services'],
+ 'monitoring/host/history' => ['host', 'icingadb/host/history'],
+ 'monitoring/list/services' => ['services', 'icingadb/services'],
+ 'monitoring/list/servicegrid' => ['servicegrid', 'icingadb/services/grid'],
+ 'monitoring/services/show' => ['multipleServices', 'icingadb/services/details'],
+ 'monitoring/service/show' => ['service', 'icingadb/service'],
+ 'monitoring/service/history' => ['service', 'icingadb/service/history'],
+ 'monitoring/list/hostgroups' => ['hostgroups', 'icingadb/hostgroups'],
+ 'monitoring/list/servicegroups' => ['servicegroups', 'icingadb/servicegroups'],
+ 'monitoring/list/contactgroups' => ['contactgroups', 'icingadb/usergroups'],
+ 'monitoring/list/contacts' => ['contacts', 'icingadb/users'],
+ 'monitoring/list/comments' => ['comments', 'icingadb/comments'],
+ 'monitoring/list/downtimes' => ['downtimes', 'icingadb/downtimes'],
+ 'monitoring/list/eventhistory' => ['history', 'icingadb/history'],
+ 'monitoring/list/notifications' => ['notificationHistory', 'icingadb/notifications'],
+ 'monitoring/health/info' => [null, 'icingadb/health'],
+ 'monitoring/health/stats' => [null, 'icingadb/health'],
+ 'monitoring/tactical' => ['services', 'icingadb/tactical']
+ ];
+
+ public static function isSupportedUrl(Url $url): bool
+ {
+ $supportedPaths = self::SUPPORTED_PATHS;
+ return isset($supportedPaths[ltrim($url->getPath(), '/')]);
+ }
+
+ public static function hasParamTransformer(string $name): bool
+ {
+ return method_exists(new self(), $name . 'Parameters');
+ }
+
+ public static function hasQueryTransformer(string $name): bool
+ {
+ return method_exists(new self(), $name . 'Columns');
+ }
+
+ public static function transformUrl(Url $url): Url
+ {
+ if (! self::isSupportedUrl($url)) {
+ throw new InvalidArgumentException(sprintf('Url path "%s" is not supported', $url->getPath()));
+ }
+
+ list($transformer, $dbRoute) = self::SUPPORTED_PATHS[ltrim($url->getPath(), '/')];
+
+ $url = clone $url;
+ $url->setPath($dbRoute);
+
+ if (! $url->getParams()->isEmpty()) {
+ [$params, $filter] = self::transformParams($url, $transformer);
+ $url->setParams($params);
+
+ if (! $filter->isEmpty()) {
+ $filter = QueryString::parse((string) $filter);
+ $filter = self::transformFilter($filter, $transformer);
+ $url->setFilter($filter ?: null);
+ }
+ }
+
+ return $url;
+ }
+
+ public static function transformParams(Url $url, string $transformerName = null): array
+ {
+ $transformer = new self();
+
+ $params = self::commonParameters();
+ $columns = self::commonColumns();
+
+ if ($transformerName !== null) {
+ if (! self::hasQueryTransformer($transformerName)) {
+ throw new InvalidArgumentException(sprintf('Transformer "%s" is not supported', $transformerName));
+ }
+
+ if (self::hasParamTransformer($transformerName)) {
+ $params = array_merge($params, $transformer->{$transformerName . 'Parameters'}());
+ }
+
+ $columns = array_merge($columns, $transformer->{$transformerName . 'Columns'}());
+ }
+
+ $columnRewriter = function ($column) use ($columns, $transformer) {
+ $rewritten = $transformer->rewrite(Filter::equal($column, 'bogus'), $columns);
+ if ($rewritten === false) {
+ return false;
+ } elseif ($rewritten instanceof Filter\Condition) {
+ return $rewritten->getColumn();
+ }
+
+ return $column;
+ };
+
+ $urlParams = $url->onlyWith(array_keys($params))->getParams();
+ $urlFilter = $url->without(array_keys($params))->getParams();
+
+ $newParams = new UrlParams();
+ foreach ($urlParams->toArray(false) as $name => $value) {
+ if (is_int($name)) {
+ $name = $value;
+ $value = true;
+ } else {
+ $value = rawurldecode($value);
+ }
+
+ $name = rawurldecode($name);
+
+ if (! isset($params[$name]) || $params[$name] === self::USE_EXPR) {
+ $newParams->add($name, $value);
+ } elseif ($params[$name] === self::DROP) {
+ // pass
+ } elseif (is_callable($params[$name])) {
+ $result = $params[$name]($value, $urlParams, $columnRewriter);
+ if ($result === false) {
+ continue;
+ } elseif (is_array($result)) {
+ [$name, $value] = $result;
+ } elseif ($result !== null) {
+ $value = $result;
+ }
+
+ $newParams->add($name, $value);
+ }
+ }
+
+ return [$newParams, $urlFilter];
+ }
+
+ /**
+ * Transform the given legacy filter
+ *
+ * @param Filter\Rule $filter
+ * @param string|null $queryTransformer
+ *
+ * @return Filter\Rule|false
+ */
+ public static function transformFilter(Filter\Rule $filter, string $queryTransformer = null)
+ {
+ $transformer = new self();
+
+ $columns = $transformer::commonColumns();
+ if ($queryTransformer !== null) {
+ if (! self::hasQueryTransformer($queryTransformer)) {
+ throw new InvalidArgumentException(sprintf('Transformer "%s" is not supported', $queryTransformer));
+ }
+
+ $columns = array_merge($columns, $transformer->{$queryTransformer . 'Columns'}());
+ }
+
+ $rewritten = $transformer->rewrite($filter, $columns);
+ return $rewritten === false ? false : ($rewritten instanceof Filter\Rule ? $rewritten : $filter);
+ }
+
+ /**
+ * Transform given legacy wildcard filters
+ *
+ * @param $filter Filter\Rule
+ *
+ * @return Filter\Chain|Filter\Condition
+ */
+ public static function transformLegacyWildcardFilter(Filter\Rule $filter)
+ {
+ if ($filter instanceof Filter\Chain) {
+ foreach ($filter as $child) {
+ $newChild = self::transformLegacyWildcardFilter($child);
+ if ($newChild !== $child) {
+ $filter->replace($child, $newChild);
+ }
+ }
+
+ return $filter;
+ } else {
+ /** @var Filter\Condition $filter */
+ return self::transformWildcardFilter($filter);
+ }
+ }
+
+ /**
+ * Rewrite the given filter and legacy columns
+ *
+ * @param Filter\Rule $filter
+ * @param array $legacyColumns
+ *
+ * @return ?mixed
+ */
+ protected function rewrite(Filter\Rule $filter, array $legacyColumns)
+ {
+ $rewritten = null;
+ if ($filter instanceof Filter\Condition) {
+ $column = $filter->getColumn();
+
+ $modelPath = null;
+ $exprRule = null;
+ if (isset($legacyColumns[$column])) {
+ if ($legacyColumns[$column] === self::DROP) {
+ return false;
+ } elseif (is_callable($legacyColumns[$column])) {
+ return $legacyColumns[$column]($filter);
+ } elseif (! is_array($legacyColumns[$column])) {
+ return null;
+ }
+
+ foreach ($legacyColumns[$column] as $modelPath => $exprRule) {
+ break;
+ }
+
+ $rewritten = $filter->setColumn($modelPath);
+
+ switch (true) {
+ case $exprRule === self::USE_EXPR:
+ break;
+ case $exprRule === self::LOWER_EXPR:
+ $filter->setValue(strtolower($filter->getValue()));
+ break;
+ case is_array($exprRule) && isset($exprRule[$filter->getValue()]):
+ $filter->setValue($exprRule[$filter->getValue()]);
+ break;
+ default:
+ $filter->setValue($exprRule);
+ }
+
+ $rewritten = self::transformWildcardFilter($rewritten);
+ } elseif (preg_match('/^_(host|service)_(.+)/i', $column, $groups)) {
+ $rewritten = $filter->setColumn($groups[1] . '.vars.' . $groups[2]);
+ $rewritten = self::transformWildcardFilter($rewritten);
+ }
+ } else {
+ /** @var Filter\Chain $filter */
+ foreach ($filter as $child) {
+ $retVal = $this->rewrite(
+ $child instanceof Filter\Condition ? clone $child : $child,
+ $legacyColumns
+ );
+ if ($retVal === false) {
+ $filter->remove($child);
+ } elseif ($retVal instanceof Filter\Rule) {
+ $filter->replace($child, $retVal);
+ }
+ }
+ }
+
+ return $rewritten;
+ }
+
+ private static function transformWildcardFilter(Filter\Condition $filter)
+ {
+ if (is_string($filter->getValue()) && strpos($filter->getValue(), '*') !== false) {
+ if ($filter instanceof Filter\Equal) {
+ return Filter::like($filter->getColumn(), $filter->getValue());
+ } elseif ($filter instanceof Filter\Unequal) {
+ return Filter::unlike($filter->getColumn(), $filter->getValue());
+ }
+ }
+
+ return $filter;
+ }
+
+ protected static function commonParameters(): array
+ {
+ return [
+ 'sort' => function ($value, $params, $rewriter) {
+ $value = $rewriter($value);
+ if ($params->has('dir')) {
+ return "{$value} {$params->get('dir')}";
+ }
+
+ return $value;
+ },
+ 'dir' => self::DROP,
+ 'limit' => self::USE_EXPR,
+ 'showCompact' => self::USE_EXPR,
+ 'showFullscreen' => self::USE_EXPR,
+ 'view' => function ($value) {
+ if ($value === 'compact') {
+ return ['showCompact', true];
+ }
+
+ return $value;
+ }
+ ];
+ }
+
+ protected static function commonColumns(): array
+ {
+ return [
+
+ // Filter columns
+ 'host' => [
+ 'host.name_ci' => self::USE_EXPR
+ ],
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'host_alias' => self::DROP,
+ 'hostgroup' => [
+ 'hostgroup.name_ci' => self::USE_EXPR
+ ],
+ 'hostgroup_alias' => [
+ 'hostgroup.display_name' => self::USE_EXPR
+ ],
+ 'service' => [
+ 'service.name_ci' => self::USE_EXPR
+ ],
+ 'service_display_name' => [
+ 'service.display_name' => self::USE_EXPR
+ ],
+ 'servicegroup' => [
+ 'servicegroup.name_ci' => self::USE_EXPR
+ ],
+ 'servicegroup_alias' => [
+ 'servicegroup.display_name' => self::USE_EXPR
+ ],
+
+ // Restriction columns
+ 'instance_name' => self::DROP,
+ 'host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'hostgroup_name' => [
+ 'hostgroup.name' => self::USE_EXPR
+ ],
+ 'service_description' => [
+ 'service.name' => self::USE_EXPR
+ ],
+ 'servicegroup_name' => [
+ 'servicegroup.name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function hostsParameters(): array
+ {
+ return [
+ 'addColumns' => function ($value, $params, $rewriter) {
+ $legacyColumns = array_filter(array_map('trim', explode(',', $value)));
+
+ $columns = [
+ 'host.state.soft_state',
+ 'host.state.last_state_change',
+ 'host.icon_image.icon_image',
+ 'host.display_name',
+ 'host.state.output',
+ 'host.state.performance_data',
+ 'host.state.is_problem'
+ ];
+ foreach ($legacyColumns as $column) {
+ $column = $rewriter($column);
+ if ($column !== false) {
+ $columns[] = $column;
+ }
+ }
+
+ return ['columns', implode(',', $columns)];
+ }
+ ];
+ }
+
+ protected static function hostsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'host_acknowledged' => [
+ 'host.state.is_acknowledged' => self::NO_YES
+ ],
+ 'host_acknowledgement_type' => [
+ 'host.state.is_acknowledged' => array_merge(self::NO_YES, ['sticky'])
+ ],
+ 'host_action_url' => [
+ 'host.action_url.action_url' => self::USE_EXPR
+ ],
+ 'host_active_checks_enabled' => [
+ 'host.active_checks_enabled' => self::NO_YES
+ ],
+ 'host_active_checks_enabled_changed' => self::DROP,
+ 'host_address' => [
+ 'host.address' => self::USE_EXPR
+ ],
+ 'host_address6' => [
+ 'host.address6' => self::USE_EXPR
+ ],
+ 'host_alias' => self::DROP,
+ 'host_check_command' => [
+ 'host.checkcommand_name' => self::USE_EXPR
+ ],
+ 'host_check_execution_time' => [
+ 'host.state.execution_time' => self::USE_EXPR
+ ],
+ 'host_check_latency' => [
+ 'host.state.latency' => self::USE_EXPR
+ ],
+ 'host_check_source' => [
+ 'host.state.check_source' => self::USE_EXPR
+ ],
+ 'host_check_timeperiod' => [
+ 'host.check_timeperiod_name' => self::USE_EXPR
+ ],
+ 'host_current_check_attempt' => [
+ 'host.state.check_attempt' => self::USE_EXPR
+ ],
+ 'host_current_notification_number' => self::DROP,
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'host_event_handler_enabled' => [
+ 'host.event_handler_enabled' => self::NO_YES
+ ],
+ 'host_event_handler_enabled_changed' => self::DROP,
+ 'host_flap_detection_enabled' => [
+ 'host.flapping_enabled' => self::NO_YES
+ ],
+ 'host_flap_detection_enabled_changed' => self::DROP,
+ 'host_handled' => [
+ 'host.state.is_handled' => self::NO_YES
+ ],
+ 'host_hard_state' => [
+ 'host.state.hard_state' => self::USE_EXPR
+ ],
+ 'host_in_downtime' => [
+ 'host.state.in_downtime' => self::NO_YES
+ ],
+ 'host_ipv4' => [
+ 'host.address_bin' => self::USE_EXPR
+ ],
+ 'host_is_flapping' => [
+ 'host.state.is_flapping' => self::NO_YES
+ ],
+ 'host_is_reachable' => [
+ 'host.state.is_reachable' => self::NO_YES
+ ],
+ 'host_last_check' => [
+ 'host.state.last_update' => self::USE_EXPR
+ ],
+ 'host_last_notification' => self::DROP,
+ 'host_last_state_change' => [
+ 'host.state.last_state_change' => self::USE_EXPR
+ ],
+ 'host_last_state_change_ts' => [
+ 'host.state.last_state_change' => self::USE_EXPR
+ ],
+ 'host_long_output' => [
+ 'host.state.long_output' => self::USE_EXPR
+ ],
+ 'host_max_check_attempts' => [
+ 'host.max_check_attempts' => self::USE_EXPR
+ ],
+ 'host_modified_host_attributes' => self::DROP,
+ 'host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'host_next_check' => [
+ 'host.state.next_check' => self::USE_EXPR
+ ],
+ 'host_notes_url' => [
+ 'host.notes_url.notes_url' => self::USE_EXPR
+ ],
+ 'host_notifications_enabled' => [
+ 'host.notifications_enabled' => self::NO_YES
+ ],
+ 'host_notifications_enabled_changed' => self::DROP,
+ 'host_obsessing' => self::DROP,
+ 'host_obsessing_changed' => self::DROP,
+ 'host_output' => [
+ 'host.state.output' => self::USE_EXPR
+ ],
+ 'host_passive_checks_enabled' => [
+ 'host.passive_checks_enabled' => self::NO_YES
+ ],
+ 'host_passive_checks_enabled_changed' => self::DROP,
+ 'host_percent_state_change' => self::DROP,
+ 'host_perfdata' => [
+ 'host.state.performance_data' => self::USE_EXPR
+ ],
+ 'host_problem' => [
+ 'host.state.is_problem' => self::NO_YES
+ ],
+ 'host_severity' => [
+ 'host.state.severity' => self::USE_EXPR
+ ],
+ 'host_state' => [
+ 'host.state.soft_state' => self::USE_EXPR
+ ],
+ 'host_state_type' => [
+ 'host.state.state_type' => ['soft', 'hard']
+ ],
+ 'host_unhandled' => [
+ 'host.state.is_handled' => array_reverse(self::NO_YES)
+ ],
+
+ // Filter columns
+ 'host_contact' => [
+ 'host.user.name' => self::USE_EXPR
+ ],
+ 'host_contactgroup' => [
+ 'host.usergroup.name' => self::USE_EXPR
+ ],
+
+ // Query columns the dataview doesn't include, added here because it's possible to filter for them anyway
+ 'host_check_interval' => self::DROP,
+ 'host_icon_image' => self::DROP,
+ 'host_icon_image_alt' => self::DROP,
+ 'host_notes' => self::DROP,
+ 'object_type' => self::DROP,
+ 'object_id' => self::DROP,
+ 'host_attempt' => self::DROP,
+ 'host_check_type' => self::DROP,
+ 'host_event_handler' => self::DROP,
+ 'host_failure_prediction_enabled' => self::DROP,
+ 'host_is_passive_checked' => self::DROP,
+ 'host_last_hard_state' => self::DROP,
+ 'host_last_hard_state_change' => self::DROP,
+ 'host_last_time_down' => self::DROP,
+ 'host_last_time_unreachable' => self::DROP,
+ 'host_last_time_up' => self::DROP,
+ 'host_next_notification' => self::DROP,
+ 'host_next_update' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ if ($filter->getValue() !== 'now') {
+ return false;
+ }
+
+ // Doesn't get dropped because there's a default dashlet using it..
+ // Though since this dashlet uses it to check for overdue hosts we'll
+ // replace it as next_update is volatile (only in redis up2date)
+ return Filter::equal('host.state.is_overdue', $filter instanceof Filter\LessThan ? 'y' : 'n');
+ },
+ 'host_no_more_notifications' => self::DROP,
+ 'host_normal_check_interval' => self::DROP,
+ 'host_problem_has_been_acknowledged' => self::DROP,
+ 'host_process_performance_data' => self::DROP,
+ 'host_retry_check_interval' => self::DROP,
+ 'host_scheduled_downtime_depth' => self::DROP,
+ 'host_status_update_time' => self::DROP,
+ 'problems' => self::DROP
+ ];
+ }
+
+ protected static function multipleHostsColumns(): array
+ {
+ return array_merge(
+ static::hostsColumns(),
+ [
+ 'host' => [
+ 'host.name' => self::USE_EXPR
+ ]
+ ]
+ );
+ }
+
+ protected static function hostColumns(): array
+ {
+ return [
+ 'host' => [
+ 'name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function servicesParameters(): array
+ {
+ return [
+ 'addColumns' => function ($value, $params, $rewriter) {
+ $legacyColumns = array_filter(array_map('trim', explode(',', $value)));
+
+ $columns = [
+ 'service.state.soft_state',
+ 'service.state.last_state_change',
+ 'service.icon_image.icon_image',
+ 'service.display_name',
+ 'service.host.display_name',
+ 'service.state.output',
+ 'service.state.performance_data',
+ 'service.state.is_problem'
+ ];
+ foreach ($legacyColumns as $column) {
+ $column = $rewriter($column);
+ if ($column !== false) {
+ $columns[] = $column;
+ }
+ }
+
+ return ['columns', implode(',', $columns)];
+ }
+ ];
+ }
+
+ protected static function servicesColumns(): array
+ {
+ return [
+ // Query columns
+ 'host_acknowledged' => [
+ 'host.state.is_acknowledged' => self::NO_YES
+ ],
+ 'host_action_url' => [
+ 'host.action_url.action_url' => self::USE_EXPR
+ ],
+ 'host_active_checks_enabled' => [
+ 'host.active_checks_enabled' => self::NO_YES
+ ],
+ 'host_address' => [
+ 'host.address' => self::USE_EXPR
+ ],
+ 'host_address6' => [
+ 'host.address6' => self::USE_EXPR
+ ],
+ 'host_alias' => self::DROP,
+ 'host_check_source' => [
+ 'host.state.check_source' => self::USE_EXPR
+ ],
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'host_handled' => [
+ 'host.state.is_handled' => self::NO_YES
+ ],
+ 'host_hard_state' => [
+ 'host.state.hard_state' => self::USE_EXPR
+ ],
+ 'host_in_downtime' => [
+ 'host.state.in_downtime' => self::NO_YES
+ ],
+ 'host_ipv4' => [
+ 'host.address_bin' => self::USE_EXPR
+ ],
+ 'host_is_flapping' => [
+ 'host.state.is_flapping' => self::NO_YES
+ ],
+ 'host_last_check' => [
+ 'host.state.last_update' => self::USE_EXPR
+ ],
+ 'host_last_hard_state' => [
+ 'host.state.previous_hard_state' => self::USE_EXPR
+ ],
+ 'host_last_hard_state_change' => self::DROP,
+ 'host_last_state_change' => [
+ 'host.state.last_state_change' => self::USE_EXPR
+ ],
+ 'host_last_time_down' => self::DROP,
+ 'host_last_time_unreachable' => self::DROP,
+ 'host_last_time_up' => self::DROP,
+ 'host_long_output' => [
+ 'host.state.long_output' => self::USE_EXPR
+ ],
+ 'host_modified_host_attributes' => self::DROP,
+ 'host_notes_url' => [
+ 'host.notes_url.notes_url' => self::USE_EXPR
+ ],
+ 'host_notifications_enabled' => [
+ 'host.notifications_enabled' => self::NO_YES
+ ],
+ 'host_output' => [
+ 'host.state.output' => self::USE_EXPR
+ ],
+ 'host_passive_checks_enabled' => [
+ 'host.passive_checks_enabled' => self::NO_YES
+ ],
+ 'host_perfdata' => [
+ 'host.state.performance_data' => self::USE_EXPR
+ ],
+ 'host_problem' => [
+ 'host.state.is_problem' => self::NO_YES
+ ],
+ 'host_severity' => [
+ 'host.state.severity' => self::USE_EXPR
+ ],
+ 'host_state' => [
+ 'host.state.soft_state' => self::USE_EXPR
+ ],
+ 'host_state_type' => [
+ 'host.state.state_type' => ['soft', 'hard']
+ ],
+ 'service_acknowledged' => [
+ 'service.state.is_acknowledged' => self::NO_YES
+ ],
+ 'service_acknowledgement_type' => [
+ 'service.state.is_acknowledged' => array_merge(self::NO_YES, ['sticky'])
+ ],
+ 'service_action_url' => [
+ 'service.action_url.action_url' => self::USE_EXPR
+ ],
+ 'service_active_checks_enabled' => [
+ 'service.active_checks_enabled' => self::NO_YES
+ ],
+ 'service_active_checks_enabled_changed' => self::DROP,
+ 'service_attempt' => [
+ 'service.state.check_attempt' => self::USE_EXPR
+ ],
+ 'service_check_command' => [
+ 'service.checkcommand_name' => self::USE_EXPR
+ ],
+ 'service_check_source' => [
+ 'service.state.check_source' => self::USE_EXPR
+ ],
+ 'service_check_timeperiod' => [
+ 'service.check_timeperiod_name' => self::USE_EXPR
+ ],
+ 'service_current_check_attempt' => [
+ 'service.state.check_attempt' => self::USE_EXPR
+ ],
+ 'service_current_notification_number' => self::DROP,
+ 'service_display_name' => [
+ 'service.display_name' => self::USE_EXPR
+ ],
+ 'service_event_handler_enabled' => [
+ 'service.event_handler_enabled' => self::NO_YES
+ ],
+ 'service_event_handler_enabled_changed' => self::DROP,
+ 'service_flap_detection_enabled' => [
+ 'service.flapping_enabled' => self::NO_YES
+ ],
+ 'service_flap_detection_enabled_changed' => self::DROP,
+ 'service_handled' => [
+ 'service.state.is_handled' => self::NO_YES
+ ],
+ 'service_hard_state' => [
+ 'service.state.hard_state' => self::USE_EXPR
+ ],
+ 'service_host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'service_in_downtime' => [
+ 'service.state.in_downtime' => self::NO_YES
+ ],
+ 'service_is_flapping' => [
+ 'service.state.is_flapping' => self::NO_YES
+ ],
+ 'service_is_reachable' => [
+ 'service.state.is_reachable' => self::NO_YES
+ ],
+ 'service_last_check' => [
+ 'service.state.last_update' => self::USE_EXPR
+ ],
+ 'service_last_hard_state' => [
+ 'service.state.previous_hard_state' => self::USE_EXPR
+ ],
+ 'service_last_hard_state_change' => self::DROP,
+ 'service_last_notification' => self::DROP,
+ 'service_last_state_change' => [
+ 'service.state.last_state_change' => self::USE_EXPR
+ ],
+ 'service_last_state_change_ts' => [
+ 'service.state.last_state_change' => self::USE_EXPR
+ ],
+ 'service_last_time_critical' => self::DROP,
+ 'service_last_time_ok' => self::DROP,
+ 'service_last_time_unknown' => self::DROP,
+ 'service_last_time_warning' => self::DROP,
+ 'service_long_output' => [
+ 'service.state.long_output' => self::USE_EXPR
+ ],
+ 'service_max_check_attempts' => [
+ 'service.max_check_attempts' => self::USE_EXPR
+ ],
+ 'service_modified_service_attributes' => self::DROP,
+ 'service_next_check' => [
+ 'service.state.next_check' => self::USE_EXPR
+ ],
+ 'service_notes' => [
+ 'service.notes' => self::USE_EXPR
+ ],
+ 'service_notes_url' => [
+ 'service.notes_url.notes_url' => self::USE_EXPR
+ ],
+ 'service_notifications_enabled' => [
+ 'service.notifications_enabled' => self::NO_YES
+ ],
+ 'service_notifications_enabled_changed' => self::DROP,
+ 'service_obsessing' => self::DROP,
+ 'service_obsessing_changed' => self::DROP,
+ 'service_output' => [
+ 'service.state.output' => self::USE_EXPR
+ ],
+ 'service_passive_checks_enabled' => [
+ 'service.passive_checks_enabled' => self::USE_EXPR
+ ],
+ 'service_passive_checks_enabled_changed' => self::DROP,
+ 'service_perfdata' => [
+ 'service.state.performance_data' => self::USE_EXPR
+ ],
+ 'service_problem' => [
+ 'service.state.is_problem' => self::NO_YES
+ ],
+ 'service_severity' => [
+ 'service.state.severity' => self::USE_EXPR
+ ],
+ 'service_state' => [
+ 'service.state.soft_state' => self::USE_EXPR
+ ],
+ 'service_state_type' => [
+ 'service.state.state_type' => ['soft', 'hard']
+ ],
+ 'service_unhandled' => [
+ 'service.state.is_handled' => array_reverse(self::NO_YES)
+ ],
+
+ // Filter columns
+ 'host_contact' => [
+ 'host.user.name' => self::USE_EXPR
+ ],
+ 'host_contactgroup' => [
+ 'host.usergroup.name' => self::USE_EXPR
+ ],
+ 'service_contact' => [
+ 'service.user.name' => self::USE_EXPR
+ ],
+ 'service_contactgroup' => [
+ 'service.usergroup.name' => self::USE_EXPR
+ ],
+ 'service_host' => [
+ 'host.name_ci' => self::USE_EXPR
+ ],
+
+ // Query columns the dataview doesn't include, added here because it's possible to filter for them anyway
+ 'host_icon_image' => self::DROP,
+ 'host_icon_image_alt' => self::DROP,
+ 'host_notes' => self::DROP,
+ 'host_acknowledgement_type' => self::DROP,
+ 'host_active_checks_enabled_changed' => self::DROP,
+ 'host_attempt' => self::DROP,
+ 'host_check_command' => self::DROP,
+ 'host_check_execution_time' => self::DROP,
+ 'host_check_latency' => self::DROP,
+ 'host_check_timeperiod_object_id' => self::DROP,
+ 'host_check_type' => self::DROP,
+ 'host_current_check_attempt' => self::DROP,
+ 'host_current_notification_number' => self::DROP,
+ 'host_event_handler' => self::DROP,
+ 'host_event_handler_enabled' => self::DROP,
+ 'host_event_handler_enabled_changed' => self::DROP,
+ 'host_failure_prediction_enabled' => self::DROP,
+ 'host_flap_detection_enabled' => self::DROP,
+ 'host_flap_detection_enabled_changed' => self::DROP,
+ 'host_is_reachable' => self::DROP,
+ 'host_last_notification' => self::DROP,
+ 'host_max_check_attempts' => self::DROP,
+ 'host_next_check' => self::DROP,
+ 'host_next_notification' => self::DROP,
+ 'host_no_more_notifications' => self::DROP,
+ 'host_normal_check_interval' => self::DROP,
+ 'host_notifications_enabled_changed' => self::DROP,
+ 'host_obsessing' => self::DROP,
+ 'host_obsessing_changed' => self::DROP,
+ 'host_passive_checks_enabled_changed' => self::DROP,
+ 'host_percent_state_change' => self::DROP,
+ 'host_problem_has_been_acknowledged' => self::DROP,
+ 'host_process_performance_data' => self::DROP,
+ 'host_retry_check_interval' => self::DROP,
+ 'host_scheduled_downtime_depth' => self::DROP,
+ 'host_status_update_time' => self::DROP,
+ 'host_unhandled' => self::DROP,
+ 'object_type' => self::DROP,
+ 'service_check_interval' => self::DROP,
+ 'service_icon_image' => self::DROP,
+ 'service_icon_image_alt' => self::DROP,
+ 'service_check_execution_time' => self::DROP,
+ 'service_check_latency' => self::DROP,
+ 'service_check_timeperiod_object_id' => self::DROP,
+ 'service_check_type' => self::DROP,
+ 'service_event_handler' => self::DROP,
+ 'service_failure_prediction_enabled' => self::DROP,
+ 'service_is_passive_checked' => self::DROP,
+ 'service_next_notification' => self::DROP,
+ 'service_next_update' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ if ($filter->getValue() !== 'now') {
+ return false;
+ }
+
+ // Doesn't get dropped because there's a default dashlet using it..
+ // Though since this dashlet uses it to check for overdue services we'll
+ // replace it as next_update is volatile (only in redis up2date)
+ return Filter::equal('service.state.is_overdue', $filter instanceof Filter\LessThan ? 'y' : 'n');
+ },
+ 'service_no_more_notifications' => self::DROP,
+ 'service_normal_check_interval' => self::DROP,
+ 'service_percent_state_change' => self::DROP,
+ 'service_problem_has_been_acknowledged' => self::DROP,
+ 'service_process_performance_data' => self::DROP,
+ 'service_retry_check_interval' => self::DROP,
+ 'service_scheduled_downtime_depth' => self::DROP,
+ 'service_status_update_time' => self::DROP,
+ 'problems' => self::DROP,
+ ];
+ }
+
+ protected static function servicegridColumns(): array
+ {
+ return array_merge(
+ static::servicesColumns(),
+ [
+ 'problems' => [
+ 'problems' => self::USE_EXPR
+ ]
+ ]
+ );
+ }
+
+ protected static function multipleServicesColumns(): array
+ {
+ return array_merge(
+ static::servicesColumns(),
+ [
+ 'host' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'service' => [
+ 'service.name' => self::USE_EXPR
+ ]
+ ]
+ );
+ }
+
+ protected static function serviceColumns(): array
+ {
+ return [
+ 'host' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'service' => [
+ 'name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function hostgroupsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'hostgroup_alias' => [
+ 'hostgroup.display_name' => self::USE_EXPR
+ ],
+ 'hosts_severity' => self::SORT_ONLY,
+ 'hosts_total' => self::SORT_ONLY,
+ 'services_total' => self::SORT_ONLY,
+
+ // Filter columns
+ 'host_contact' => [
+ 'host.user.name' => self::USE_EXPR
+ ],
+ 'host_contactgroup' => [
+ 'host.usergroup.name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function servicegroupsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'services_severity' => self::SORT_ONLY,
+ 'services_total' => self::SORT_ONLY,
+ 'servicegroup_alias' => [
+ 'servicegroup.display_name' => self::USE_EXPR
+ ],
+
+ // Filter columns
+ 'host_contact' => [
+ 'host.user.name' => self::USE_EXPR
+ ],
+ 'host_contactgroup' => [
+ 'host.usergroup.name' => self::USE_EXPR
+ ],
+ 'service_contact' => [
+ 'service.user.name' => self::USE_EXPR
+ ],
+ 'service_contactgroup' => [
+ 'service.usergroup.name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function contactgroupsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'contactgroup_name' => [
+ 'usergroup.name' => self::USE_EXPR
+ ],
+ 'contactgroup_alias' => [
+ 'usergroup.display_name' => self::USE_EXPR
+ ],
+ 'contact_count' => self::DROP,
+
+ // Filter columns
+ 'contactgroup' => [
+ 'usergroup.name_ci' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function contactsColumns(): array
+ {
+ $receivesStateNotifications = function ($state, $type = null) {
+ return function ($filter) use ($state, $type) {
+ /** @var Filter\Condition $filter */
+ $negate = $filter instanceof Filter\Unequal || $filter instanceof Filter\Unlike;
+ switch ($filter->getValue()) {
+ case '0':
+ $filter = Filter::any(
+ Filter::equal('user.notifications_enabled', 'n'),
+ Filter::unequal('user.states', $state)
+ );
+ if ($type !== null) {
+ $filter->add(Filter::unequal('user.types', $type));
+ }
+
+ break;
+ case '1':
+ $filter = Filter::all(
+ Filter::equal('user.notifications_enabled', 'y'),
+ Filter::equal('user.states', $state)
+ );
+ if ($type !== null) {
+ $filter->add(Filter::equal('user.types', $type));
+ }
+
+ break;
+ default:
+ return null;
+ }
+
+ if ($negate) {
+ $filter = Filter::none($filter);
+ }
+
+ return $filter;
+ };
+ };
+
+ return [
+
+ // Query columns
+ 'contact_object_id' => self::DROP,
+ 'contact_id' => [
+ 'user.id' => self::USE_EXPR
+ ],
+ 'contact_name' => [
+ 'user.name' => self::USE_EXPR
+ ],
+ 'contact_alias' => [
+ 'user.display_name' => self::USE_EXPR
+ ],
+ 'contact_email' => [
+ 'user.email' => self::USE_EXPR
+ ],
+ 'contact_pager' => [
+ 'user.pager' => self::USE_EXPR
+ ],
+ 'contact_has_host_notfications' => $receivesStateNotifications(['up', 'down']),
+ 'contact_has_service_notfications' => $receivesStateNotifications(['ok', 'warning', 'critical', 'unknown']),
+ 'contact_can_submit_commands' => self::DROP,
+ 'contact_notify_service_recovery' => $receivesStateNotifications(
+ ['ok', 'warning', 'critical', 'unknown'],
+ 'recovery'
+ ),
+ 'contact_notify_service_warning' => $receivesStateNotifications('warning'),
+ 'contact_notify_service_critical' => $receivesStateNotifications('critical'),
+ 'contact_notify_service_unknown' => $receivesStateNotifications('unknown'),
+ 'contact_notify_service_flapping' => $receivesStateNotifications(
+ ['ok', 'warning', 'critical', 'unknown'],
+ ['flapping_start', 'flapping_end']
+ ),
+ 'contact_notify_service_downtime' => $receivesStateNotifications(
+ ['ok', 'warning', 'critical', 'unknown'],
+ ['downtime_start', 'downtime_end', 'downtime_removed']
+ ),
+ 'contact_notify_host_recovery' => $receivesStateNotifications(['up', 'down'], 'recovery'),
+ 'contact_notify_host_down' => $receivesStateNotifications('down'),
+ 'contact_notify_host_unreachable' => self::DROP,
+ 'contact_notify_host_flapping' => $receivesStateNotifications(
+ ['up', 'down'],
+ ['flapping_start', 'flapping_end']
+ ),
+ 'contact_notify_host_downtime' => $receivesStateNotifications(
+ ['up', 'down'],
+ ['downtime_start', 'downtime_end', 'downtime_removed']
+ ),
+ 'contact_notify_host_timeperiod' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ $filter->setColumn('user.timeperiod.name_ci');
+ return Filter::all(
+ $filter,
+ Filter::equal('user.states', ['up', 'down'])
+ );
+ },
+ 'contact_notify_service_timeperiod' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ $filter->setColumn('user.timeperiod.name_ci');
+ return Filter::all(
+ $filter,
+ Filter::equal('user.states', ['ok', 'warning', 'critical', 'unknown'])
+ );
+ },
+
+ // Filter columns
+ 'contact' => [
+ 'user.name_ci' => self::USE_EXPR
+ ],
+ 'contactgroup' => [
+ 'usergroup.name_ci' => self::USE_EXPR
+ ],
+ 'contactgroup_name' => [
+ 'usergroup.name' => self::USE_EXPR
+ ],
+ 'contactgroup_alias' => [
+ 'usergroup.display_name' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function commentsColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'comment_author_name' => [
+ 'comment.author' => self::USE_EXPR
+ ],
+ 'comment_data' => [
+ 'comment.text' => self::USE_EXPR
+ ],
+ 'comment_expiration' => [
+ 'comment.expire_time' => self::USE_EXPR
+ ],
+ 'comment_internal_id' => self::DROP,
+ 'comment_is_persistent' => [
+ 'comment.is_persistent' => self::NO_YES
+ ],
+ 'comment_name' => [
+ 'comment.name' => self::USE_EXPR
+ ],
+ 'comment_timestamp' => [
+ 'comment.entry_time' => self::USE_EXPR
+ ],
+ 'comment_type' => [
+ 'comment.entry_type' => self::LOWER_EXPR
+ ],
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'object_type' => [
+ 'comment.object_type' => self::LOWER_EXPR
+ ],
+ 'service_display_name' => [
+ 'service.display_name' => self::USE_EXPR
+ ],
+ 'service_host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+
+ // Filter columns
+ 'comment_author' => [
+ 'comment.author' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function downtimesColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'downtime_author_name' => [
+ 'downtime.author' => self::USE_EXPR
+ ],
+ 'downtime_comment' => [
+ 'downtime.comment' => self::USE_EXPR
+ ],
+ 'downtime_duration' => [
+ 'downtime.flexible_duration' => self::USE_EXPR
+ ],
+ 'downtime_end' => [
+ 'downtime.end_time' => self::USE_EXPR
+ ],
+ 'downtime_entry_time' => [
+ 'downtime.entry_time' => self::USE_EXPR
+ ],
+ 'downtime_internal_id' => self::DROP,
+ 'downtime_is_fixed' => [
+ 'downtime.is_flexible' => array_reverse(self::NO_YES)
+ ],
+ 'downtime_is_flexible' => [
+ 'downtime.is_flexible' => self::NO_YES
+ ],
+ 'downtime_is_in_effect' => [
+ 'downtime.is_in_effect' => self::NO_YES
+ ],
+ 'downtime_name' => [
+ 'downtime.name' => self::USE_EXPR
+ ],
+ 'downtime_scheduled_end' => [
+ 'downtime.scheduled_end_time' => self::USE_EXPR
+ ],
+ 'downtime_scheduled_start' => [
+ 'downtime.scheduled_start_time' => self::USE_EXPR
+ ],
+ 'downtime_start' => [
+ 'downtime.start_time' => self::USE_EXPR
+ ],
+ 'host_display_name' => [
+ 'host.display_name' => self::USE_EXPR
+ ],
+ 'host_state' => [
+ 'host.state.soft_state' => self::USE_EXPR
+ ],
+ 'object_type' => [
+ 'downtime.object_type' => self::LOWER_EXPR
+ ],
+ 'service_display_name' => [
+ 'service.display_name' => self::USE_EXPR
+ ],
+ 'service_host_name' => [
+ 'host.name' => self::USE_EXPR
+ ],
+ 'service_state' => [
+ 'service.state.soft_state' => self::USE_EXPR
+ ],
+
+ // Filter columns
+ 'downtime_author' => [
+ 'downtime.author' => self::USE_EXPR
+ ]
+ ];
+ }
+
+ protected static function historyColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'id' => self::DROP,
+ 'object_type' => [
+ 'history.object_type' => self::LOWER_EXPR
+ ],
+ 'timestamp' => [
+ 'history.event_time' => self::USE_EXPR
+ ],
+ 'state' => [
+ 'history.state.soft_state' => self::USE_EXPR
+ ],
+ 'output' => [
+ 'history.state.output' => self::USE_EXPR
+ ],
+ 'type' => function ($filter) {
+ /** @var Filter\Condition $filter */
+ $expr = strtolower($filter->getValue());
+
+ switch (true) {
+ // NotificationhistoryQuery
+ case substr($expr, 0, 13) === 'notification_':
+ $filter->setColumn('history.notification.type');
+ $filter->setValue([
+ 'notification_ack' => 'acknowledgement',
+ 'notification_flapping' => 'flapping_start',
+ 'notification_flapping_end' => 'flapping_end',
+ 'notification_dt_start' => 'downtime_start',
+ 'notification_dt_end' => 'downtime_end',
+ 'notification_custom' => 'custom',
+ 'notification_state' => ['problem', 'recovery']
+ ][$expr]);
+ return Filter::all($filter, Filter::equal('history.event_type', 'notification'));
+ // StatehistoryQuery
+ case in_array($expr, ['soft_state', 'hard_state'], true):
+ $filter->setColumn('history.state.state_type');
+ $filter->setValue(substr($expr, 0, 4));
+ return Filter::all($filter, Filter::equal('history.event_type', 'state_change'));
+ // DowntimestarthistoryQuery and DowntimeendhistoryQuery
+ case in_array($expr, ['dt_start', 'dt_end'], true):
+ $filter->setColumn('history.event_type');
+ $filter->setValue('downtime_' . substr($expr, 3));
+ return $filter;
+ // CommenthistoryQuery
+ case in_array($expr, ['comment', 'ack'], true):
+ $filter->setColumn('history.comment.entry_type');
+ $filter->setValue($expr);
+ return Filter::all($filter, Filter::equal('history.event_type', 'comment_add'));
+ // CommentdeletionhistoryQuery
+ case in_array($expr, ['comment_deleted', 'ack_deleted'], true):
+ $filter->setColumn('history.comment.entry_type');
+ $filter->setValue($expr);
+ return Filter::all($filter, Filter::equal('history.event_type', 'comment_remove'));
+ // FlappingstarthistoryQuery and CommenthistoryQuery
+ case in_array($expr, ['flapping', 'flapping_deleted'], true):
+ $filter->setColumn('history.event_type');
+ return $filter->setValue($expr === 'flapping' ? 'flapping_start' : 'flapping_end');
+ }
+ }
+ ];
+ }
+
+ protected static function notificationHistoryColumns(): array
+ {
+ return [
+
+ // Query columns
+ 'notification_contact_name' => [
+ 'notification_history.user.name' => self::USE_EXPR
+ ],
+ 'notification_output' => [
+ 'notification_history.text' => self::USE_EXPR
+ ],
+ 'notification_reason' => [
+ 'notification_history.type' => [
+ 0 => ['problem', 'recovery'],
+ 1 => 'acknowledgement',
+ 2 => 'flapping_start',
+ 3 => 'flapping_end',
+ 5 => 'downtime_start',
+ 6 => 'downtime_end',
+ 7 => 'downtime_removed',
+ 8 => 'custom' // ido schema doc says it's `99`, icinga2 though uses `8`
+ ]
+ ],
+ 'notification_state' => [
+ 'notification_history.state' => self::USE_EXPR
+ ],
+ 'notification_timestamp' => [
+ 'notification_history.send_time' => self::USE_EXPR
+ ],
+ 'object_type' => [
+ 'notification_history.object_type' => self::LOWER_EXPR
+ ],
+ 'service_host_name' => [
+ 'host.name' => self::USE_EXPR
+ ]
+ ];
+ }
+}
diff --git a/library/Icingadb/Data/CsvResultSet.php b/library/Icingadb/Data/CsvResultSet.php
new file mode 100644
index 0000000..746a7e4
--- /dev/null
+++ b/library/Icingadb/Data/CsvResultSet.php
@@ -0,0 +1,85 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Data;
+
+use DateTime;
+use DateTimeZone;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+
+class CsvResultSet extends VolatileStateResults
+{
+ protected $isCacheDisabled = true;
+
+ /**
+ * @return array<string, ?string>
+ */
+ public function current(): array
+ {
+ return $this->extractKeysAndValues(parent::current());
+ }
+
+ protected function formatValue(string $key, $value): ?string
+ {
+ if (
+ $value
+ && (
+ $key === 'id'
+ || substr($key, -3) === '_id'
+ || substr($key, -3) === '.id'
+ || substr($key, -9) === '_checksum'
+ || substr($key, -4) === '_bin'
+ )
+ ) {
+ $value = bin2hex($value);
+ }
+
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ } elseif (is_string($value)) {
+ return '"' . str_replace('"', '""', $value) . '"';
+ } elseif (is_array($value)) {
+ return '"' . implode(',', $value) . '"';
+ } elseif ($value instanceof DateTime) {
+ return $value->setTimezone(new DateTimeZone('UTC'))
+ ->format('Y-m-d\TH:i:s.vP');
+ } else {
+ return $value;
+ }
+ }
+
+ protected function extractKeysAndValues(Model $model, string $path = ''): array
+ {
+ $keysAndValues = [];
+ foreach ($model as $key => $value) {
+ $keyPath = ($path ? $path . '.' : '') . $key;
+ if ($value instanceof Model) {
+ $keysAndValues += $this->extractKeysAndValues($value, $keyPath);
+ } else {
+ $keysAndValues[$keyPath] = $this->formatValue($key, $value);
+ }
+ }
+
+ return $keysAndValues;
+ }
+
+ public static function stream(Query $query): void
+ {
+ $query->setResultSetClass(__CLASS__);
+
+ foreach ($query as $i => $keysAndValues) {
+ if ($i === 0) {
+ echo implode(',', array_keys($keysAndValues));
+ }
+
+ echo "\r\n";
+
+ echo implode(',', array_values($keysAndValues));
+ }
+
+ exit;
+ }
+}
diff --git a/library/Icingadb/Data/JsonResultSet.php b/library/Icingadb/Data/JsonResultSet.php
new file mode 100644
index 0000000..73cd9ef
--- /dev/null
+++ b/library/Icingadb/Data/JsonResultSet.php
@@ -0,0 +1,80 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Data;
+
+use DateTime;
+use DateTimeZone;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Util\Json;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+
+class JsonResultSet extends VolatileStateResults
+{
+ protected $isCacheDisabled = true;
+
+ /**
+ * @return array<string, ?string>
+ */
+ public function current(): array
+ {
+ return $this->createObject(parent::current());
+ }
+
+ protected function formatValue(string $key, $value): ?string
+ {
+ if (
+ $value
+ && (
+ $key === 'id'
+ || substr($key, -3) === '_id'
+ || substr($key, -3) === '.id'
+ || substr($key, -9) === '_checksum'
+ || substr($key, -4) === '_bin'
+ )
+ ) {
+ $value = bin2hex($value);
+ }
+
+ if ($value instanceof DateTime) {
+ return $value->setTimezone(new DateTimeZone('UTC'))
+ ->format('Y-m-d\TH:i:s.vP');
+ }
+
+ return $value;
+ }
+
+ protected function createObject(Model $model): array
+ {
+ $keysAndValues = [];
+ foreach ($model as $key => $value) {
+ if ($value instanceof Model) {
+ $keysAndValues[$key] = $this->createObject($value);
+ } else {
+ $keysAndValues[$key] = $this->formatValue($key, $value);
+ }
+ }
+
+ return $keysAndValues;
+ }
+
+ public static function stream(Query $query): void
+ {
+ $query->setResultSetClass(__CLASS__);
+
+ echo '[';
+ foreach ($query as $i => $object) {
+ if ($i > 0) {
+ echo ",\n";
+ }
+
+ echo Json::sanitize($object);
+ }
+
+ echo ']';
+
+ exit;
+ }
+}
diff --git a/library/Icingadb/Data/PivotTable.php b/library/Icingadb/Data/PivotTable.php
new file mode 100644
index 0000000..1aee20c
--- /dev/null
+++ b/library/Icingadb/Data/PivotTable.php
@@ -0,0 +1,441 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Data;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Web;
+use ipl\Orm\Query;
+use ipl\Stdlib\Contract\Paginatable;
+use ipl\Stdlib\Filter;
+
+class PivotTable
+{
+ const SORT_ASC = 'asc';
+
+ /**
+ * The query to fetch as pivot table
+ *
+ * @var Query
+ */
+ protected $baseQuery;
+
+ /**
+ * X-axis pivot column
+ *
+ * @var string
+ */
+ protected $xAxisColumn;
+
+ /**
+ * Y-axis pivot column
+ *
+ * @var string
+ */
+ protected $yAxisColumn;
+
+ /**
+ * The filter being applied on the query for the x-axis
+ *
+ * @var Filter\Rule
+ */
+ protected $xAxisFilter;
+
+ /**
+ * The filter being applied on the query for the y-axis
+ *
+ * @var Filter\Rule
+ */
+ protected $yAxisFilter;
+
+ /**
+ * The query to fetch the leading x-axis rows and their headers
+ *
+ * @var Query
+ */
+ protected $xAxisQuery;
+
+ /**
+ * The query to fetch the leading y-axis rows and their headers
+ *
+ * @var Query
+ */
+ protected $yAxisQuery;
+
+ /**
+ * X-axis header column
+ *
+ * @var string|null
+ */
+ protected $xAxisHeader;
+
+ /**
+ * Y-axis header column
+ *
+ * @var string|null
+ */
+ protected $yAxisHeader;
+
+ /**
+ * Order by column and direction
+ *
+ * @var array
+ */
+ protected $order = [];
+
+ /**
+ * Grid columns as [Alias => Column name] pairs
+ *
+ * @var array
+ */
+ protected $gridcols = [];
+
+ /**
+ * Create a new pivot table
+ *
+ * @param Query $query The query to fetch as pivot table
+ * @param string $xAxisColumn X-axis pivot column
+ * @param string $yAxisColumn Y-axis pivot column
+ * @param array $gridcols Grid columns
+ */
+ public function __construct(Query $query, string $xAxisColumn, string $yAxisColumn, array $gridcols)
+ {
+ foreach ($query->getOrderBy() as $sort) {
+ $this->order[$sort[0]] = $sort[1];
+ }
+
+ $this->baseQuery = $query->columns($gridcols)->resetOrderBy();
+ $this->xAxisColumn = $xAxisColumn;
+ $this->yAxisColumn = $yAxisColumn;
+ $this->gridcols = $gridcols;
+ }
+
+ /**
+ * Set the filter to apply on the query for the x-axis
+ *
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function setXAxisFilter(Filter\Rule $filter = null): self
+ {
+ $this->xAxisFilter = $filter;
+ return $this;
+ }
+
+ /**
+ * Set the filter to apply on the query for the y-axis
+ *
+ * @param Filter\Rule $filter
+ *
+ * @return $this
+ */
+ public function setYAxisFilter(Filter\Rule $filter = null): self
+ {
+ $this->yAxisFilter = $filter;
+ return $this;
+ }
+
+ /**
+ * Get the x-axis header
+ *
+ * Defaults to {@link $xAxisColumn} in case no x-axis header has been set using {@link setXAxisHeader()}
+ *
+ * @return string
+ */
+ public function getXAxisHeader(): string
+ {
+ if ($this->xAxisHeader === null && $this->xAxisColumn === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->xAxisHeader !== null ? $this->xAxisHeader : $this->xAxisColumn;
+ }
+
+ /**
+ * Set the x-axis header
+ *
+ * @param string $xAxisHeader
+ *
+ * @return $this
+ */
+ public function setXAxisHeader(string $xAxisHeader): self
+ {
+ $this->xAxisHeader = $xAxisHeader;
+ return $this;
+ }
+
+ /**
+ * Get the y-axis header
+ *
+ * Defaults to {@link $yAxisColumn} in case no x-axis header has been set using {@link setYAxisHeader()}
+ *
+ * @return string
+ */
+ public function getYAxisHeader(): string
+ {
+ if ($this->yAxisHeader === null && $this->yAxisColumn === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->yAxisHeader !== null ? $this->yAxisHeader : $this->yAxisColumn;
+ }
+
+ /**
+ * Set the y-axis header
+ *
+ * @param string $yAxisHeader
+ *
+ * @return $this
+ */
+ public function setYAxisHeader(string $yAxisHeader): self
+ {
+ $this->yAxisHeader = $yAxisHeader;
+ return $this;
+ }
+
+ /**
+ * Return the value for the given request parameter
+ *
+ * @param string $axis The axis for which to return the parameter ('x' or 'y')
+ * @param string $param The parameter name to return
+ * @param int $default The default value to return
+ *
+ * @return int
+ */
+ protected function getPaginationParameter(string $axis, string $param, int $default = null): int
+ {
+ /** @var Web $app */
+ $app = Icinga::app();
+
+ $value = $app->getRequest()->getParam($param, '');
+ if (strpos($value, ',') > 0) {
+ $parts = explode(',', $value, 2);
+ return intval($parts[$axis === 'x' ? 0 : 1]);
+ }
+
+ return $default !== null ? $default : 0;
+ }
+
+ /**
+ * Query horizontal (x) axis
+ *
+ * @return Query
+ */
+ protected function queryXAxis(): Query
+ {
+ if ($this->xAxisQuery === null) {
+ $this->xAxisQuery = clone $this->baseQuery;
+ $xAxisHeader = $this->getXAxisHeader();
+ $table = $this->xAxisQuery->getModel()->getTableName();
+ $xCol = explode('.', $this->gridcols[$this->xAxisColumn]);
+ $columns = [
+ $this->xAxisColumn => $this->gridcols[$this->xAxisColumn],
+ $xAxisHeader => $this->gridcols[$xAxisHeader]
+ ];
+
+ // TODO: This shouldn't be required. Refactor this once ipl\Orm\Query has support for group by rules!
+ if ($xCol[0] !== $table) {
+ $groupCols = array_unique([
+ $this->xAxisColumn => $table . '_' . $this->gridcols[$this->xAxisColumn],
+ $xAxisHeader => $table . '_' . $this->gridcols[$xAxisHeader]
+ ]);
+ } else {
+ $groupCols = $columns;
+ }
+
+ $this->xAxisQuery->getSelectBase()->groupBy($groupCols);
+
+ if (count($columns) !== 2) {
+ $columns[] = $this->gridcols[$xAxisHeader];
+ }
+
+ $this->xAxisQuery->columns($columns);
+
+ if ($this->xAxisFilter !== null) {
+ $this->xAxisQuery->filter($this->xAxisFilter);
+ }
+
+ $this->xAxisQuery->orderBy(
+ $this->gridcols[$xAxisHeader],
+ isset($this->order[$this->gridcols[$xAxisHeader]]) ?
+ $this->order[$this->gridcols[$xAxisHeader]] : self::SORT_ASC
+ );
+ }
+
+ return $this->xAxisQuery;
+ }
+
+ /**
+ * Query vertical (y) axis
+ *
+ * @return Query
+ */
+ protected function queryYAxis(): Query
+ {
+ if ($this->yAxisQuery === null) {
+ $this->yAxisQuery = clone $this->baseQuery;
+ $yAxisHeader = $this->getYAxisHeader();
+ $table = $this->yAxisQuery->getModel()->getTableName();
+ $columns = [
+ $this->yAxisColumn => $this->gridcols[$this->yAxisColumn],
+ $yAxisHeader => $this->gridcols[$yAxisHeader]
+ ];
+ $yCol = explode('.', $this->gridcols[$this->yAxisColumn]);
+
+ // TODO: This shouldn't be required. Refactor this once ipl\Orm\Query has support for group by rules!
+ if ($yCol[0] !== $table) {
+ $groupCols = array_unique([
+ $this->yAxisColumn => $table . '_' . $this->gridcols[$this->yAxisColumn],
+ $yAxisHeader => $table . '_' . $this->gridcols[$yAxisHeader]
+ ]);
+ } else {
+ $groupCols = $columns;
+ }
+
+ $this->yAxisQuery->getSelectBase()->groupBy($groupCols);
+
+ if (count($columns) !== 2) {
+ $columns[] = $this->gridcols[$yAxisHeader];
+ }
+
+ $this->yAxisQuery->columns($columns);
+
+ if ($this->yAxisFilter !== null) {
+ $this->yAxisQuery->filter($this->yAxisFilter);
+ }
+
+ $this->yAxisQuery->orderBy(
+ $this->gridcols[$yAxisHeader],
+ isset($this->order[$this->gridcols[$yAxisHeader]]) ?
+ $this->order[$this->gridcols[$yAxisHeader]] : self::SORT_ASC
+ );
+ }
+
+ return $this->yAxisQuery;
+ }
+
+ /**
+ * Return a pagination adapter for the x-axis query
+ *
+ * $limit and $page are taken from the current request if not given.
+ *
+ * @param int $limit The maximum amount of entries to fetch
+ * @param int $page The page to set as current one
+ *
+ * @return Paginatable
+ */
+ public function paginateXAxis(int $limit = null, int $page = null): Paginatable
+ {
+ if ($limit === null || $page === null) {
+ if ($limit === null) {
+ $limit = $this->getPaginationParameter('x', 'limit', 20);
+ }
+
+ if ($page === null) {
+ $page = $this->getPaginationParameter('x', 'page', 1);
+ }
+ }
+
+ $query = $this->queryXAxis();
+
+ if ($limit !== 0) {
+ $query->limit($limit);
+ $query->offset($page > 0 ? ($page - 1) * $limit : 0);
+ }
+
+ return $query;
+ }
+
+ /**
+ * Return a Paginatable for the y-axis query
+ *
+ * $limit and $page are taken from the current request if not given.
+ *
+ * @param int $limit The maximum amount of entries to fetch
+ * @param int $page The page to set as current one
+ *
+ * @return Paginatable
+ */
+ public function paginateYAxis(int $limit = null, int $page = null): Paginatable
+ {
+ if ($limit === null || $page === null) {
+ if ($limit === null) {
+ $limit = $this->getPaginationParameter('y', 'limit', 20);
+ }
+
+ if ($page === null) {
+ $page = $this->getPaginationParameter('y', 'page', 1);
+ }
+ }
+ $query = $this->queryYAxis();
+
+ if ($limit !== 0) {
+ $query->limit($limit);
+ $query->offset($page > 0 ? ($page - 1) * $limit : 0);
+ }
+
+ return $query;
+ }
+
+ /**
+ * Return the pivot table as an array of pivot data and pivot header
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ if (
+ ($this->xAxisFilter === null && $this->yAxisFilter === null)
+ || ($this->xAxisFilter !== null && $this->yAxisFilter !== null)
+ ) {
+ $xAxis = $this->queryXAxis()->getDb()->fetchPairs($this->queryXAxis()->assembleSelect());
+ $xAxisKeys = array_keys($xAxis);
+ $yAxis = $this->queryYAxis()->getDb()->fetchPairs($this->queryYAxis()->assembleSelect());
+ $yAxisKeys = array_keys($yAxis);
+ } else {
+ if ($this->xAxisFilter !== null) {
+ $xAxis = $this->queryXAxis()->getDb()->fetchPairs($this->queryXAxis()->assembleSelect());
+ $xAxisKeys = array_keys($xAxis);
+ $yQuery = $this->queryYAxis();
+ $yQuery->filter(Filter::equal($this->gridcols[$this->xAxisColumn], $xAxisKeys));
+ $yAxis = $this->queryYAxis()->getDb()->fetchPairs($this->queryYAxis()->assembleSelect());
+ $yAxisKeys = array_keys($yAxis);
+ } else { // $this->yAxisFilter !== null
+ $yAxis = $this->queryYAxis()->getDb()->fetchPairs($this->queryYAxis()->assembleSelect());
+ $yAxisKeys = array_keys($yAxis);
+ $xQuery = $this->queryXAxis();
+ $xQuery->filter(Filter::equal($this->gridcols[$this->yAxisColumn], $yAxisKeys));
+ $xAxis = $this->queryXAxis()->getDb()->fetchPairs($this->queryXAxis()->assembleSelect());
+ $xAxisKeys = array_keys($yAxis);
+ }
+ }
+
+ $pivotData = [];
+ $pivotHeader = [
+ 'cols' => $xAxis,
+ 'rows' => $yAxis
+ ];
+
+ if (! empty($xAxis) && ! empty($yAxis)) {
+ $this->baseQuery->filter(Filter::equal($this->gridcols[$this->xAxisColumn], $xAxisKeys));
+ $this->baseQuery->filter(Filter::equal($this->gridcols[$this->yAxisColumn], $yAxisKeys));
+ foreach ($yAxisKeys as $yAxisKey) {
+ foreach ($xAxisKeys as $xAxisKey) {
+ $pivotData[$yAxisKey][$xAxisKey] = null;
+ }
+ }
+
+ foreach ($this->baseQuery as $row) {
+ $pivotData[$row->{$this->yAxisColumn}][$row->{$this->xAxisColumn}] = $row;
+ }
+ }
+
+ return [$pivotData, $pivotHeader];
+ }
+}
diff --git a/library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php b/library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php
new file mode 100644
index 0000000..9f869a4
--- /dev/null
+++ b/library/Icingadb/Hook/ActionsHook/ObjectActionsHook.php
@@ -0,0 +1,83 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\ActionsHook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+use Icinga\Module\Icingadb\Hook\HostActionsHook;
+use Icinga\Module\Icingadb\Hook\ServiceActionsHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use InvalidArgumentException;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+use ipl\Web\Widget\Link;
+
+use function ipl\Stdlib\get_php_type;
+
+abstract class ObjectActionsHook
+{
+ use HookUtils;
+
+ /**
+ * Load all actions for the given object
+ *
+ * @param Host|Service $object
+ *
+ * @return HtmlElement
+ *
+ * @throws InvalidArgumentException If the given model is not supported
+ */
+ final public static function loadActions(Model $object): HtmlElement
+ {
+ switch (true) {
+ case $object instanceof Host:
+ $hookName = 'Icingadb\\HostActions';
+ break;
+ case $object instanceof Service:
+ $hookName = 'Icingadb\\ServiceActions';
+ break;
+ default:
+ throw new InvalidArgumentException(
+ sprintf('%s is not a supported object type', get_php_type($object))
+ );
+ }
+
+ $list = new HtmlElement('ul', Attributes::create(['class' => 'object-detail-actions']));
+
+ /** @var HostActionsHook|ServiceActionsHook $hook */
+ foreach (Hook::all($hookName) as $hook) {
+ try {
+ foreach ($hook->getActionsForObject($object) as $link) {
+ if (! $link instanceof Link) {
+ continue;
+ }
+
+ // It may be ValidHtml, but modules shouldn't be able to break our views.
+ // That's why it needs to be rendered instantly, as any error will then
+ // be caught here.
+ $renderedLink = (string) $link;
+ $moduleName = $hook->getModule()->getName();
+
+ $list->addHtml(new HtmlElement('li', Attributes::create([
+ 'class' => 'icinga-module module-' . $moduleName,
+ 'data-icinga-module' => $moduleName
+ ]), HtmlString::create($renderedLink)));
+ }
+ } catch (Exception $e) {
+ Logger::error("Failed to load object actions: %s\n%s", $e, $e->getTraceAsString());
+ $list->addHtml(new HtmlElement('li', null, Text::create(IcingaException::describe($e))));
+ }
+ }
+
+ return $list;
+ }
+}
diff --git a/library/Icingadb/Hook/Common/HookUtils.php b/library/Icingadb/Hook/Common/HookUtils.php
new file mode 100644
index 0000000..8778849
--- /dev/null
+++ b/library/Icingadb/Hook/Common/HookUtils.php
@@ -0,0 +1,39 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\Common;
+
+use Icinga\Application\ClassLoader;
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+
+trait HookUtils
+{
+ final public function __construct()
+ {
+ $this->init();
+ }
+
+ /**
+ * Initialize this hook
+ *
+ * Override this in your concrete implementation for any initialization at construction time.
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Get the module this hook belongs to
+ *
+ * @return Module
+ */
+ final public function getModule(): Module
+ {
+ $moduleName = ClassLoader::extractModuleName(static::class);
+
+ return Icinga::app()->getModuleManager()
+ ->getModule($moduleName);
+ }
+}
diff --git a/library/Icingadb/Hook/Common/TotalSlaReportUtils.php b/library/Icingadb/Hook/Common/TotalSlaReportUtils.php
new file mode 100644
index 0000000..1006056
--- /dev/null
+++ b/library/Icingadb/Hook/Common/TotalSlaReportUtils.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\Common;
+
+use Icinga\Module\Icingadb\ProvidedHook\Reporting\HostSlaReport;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Html\Html;
+use ipl\Web\Widget\EmptyState;
+
+use function ipl\I18n\t;
+
+trait TotalSlaReportUtils
+{
+ public function getHtml(Timerange $timerange, array $config = null)
+ {
+ $data = $this->getData($timerange, $config);
+ $count = $data->count();
+
+ if (! $count) {
+ return new EmptyState(t('No data found.'));
+ }
+
+ $threshold = (float) ($config['threshold'] ?? static::DEFAULT_THRESHOLD);
+
+ $tableRows = [];
+ $precision = $config['sla_precision'] ?? static::DEFAULT_REPORT_PRECISION;
+
+ // We only have one average
+ $average = $data->getAverages()[0];
+
+ if ($average < $threshold) {
+ $slaClass = 'nok';
+ } else {
+ $slaClass = 'ok';
+ }
+
+ $total = $this instanceof HostSlaReport
+ ? sprintf(t('Total (%d Hosts)'), $count)
+ : sprintf(t('Total (%d Services)'), $count);
+
+ $tableRows[] = Html::tag('tr', null, [
+ Html::tag('td', ['colspan' => count($data->getDimensions())], $total),
+ Html::tag('td', ['class' => "sla-column $slaClass"], round($average, $precision))
+ ]);
+
+ $table = Html::tag(
+ 'table',
+ ['class' => 'common-table sla-table'],
+ [Html::tag('tbody', null, $tableRows)]
+ );
+
+ return $table;
+ }
+}
diff --git a/library/Icingadb/Hook/CustomVarRendererHook.php b/library/Icingadb/Hook/CustomVarRendererHook.php
new file mode 100644
index 0000000..796de11
--- /dev/null
+++ b/library/Icingadb/Hook/CustomVarRendererHook.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Closure;
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+use ipl\Orm\Model;
+
+abstract class CustomVarRendererHook
+{
+ use HookUtils;
+
+ /**
+ * Prefetch the data the hook needs to render custom variables
+ *
+ * @param Model $object The object for which they'll be rendered
+ *
+ * @return bool Return true if the hook can render variables for the given object, false otherwise
+ */
+ abstract public function prefetchForObject(Model $object): bool;
+
+ /**
+ * Render the given variable name
+ *
+ * @param string $key
+ *
+ * @return ?mixed
+ */
+ abstract public function renderCustomVarKey(string $key);
+
+ /**
+ * Render the given variable value
+ *
+ * @param string $key
+ * @param mixed $value
+ *
+ * @return ?mixed
+ */
+ abstract public function renderCustomVarValue(string $key, $value);
+
+ /**
+ * Return a group name for the given variable name
+ *
+ * @param string $key
+ *
+ * @return ?string
+ */
+ abstract public function identifyCustomVarGroup(string $key): ?string;
+
+ /**
+ * Prepare available hooks to render custom variables of the given object
+ *
+ * @param Model $object
+ *
+ * @return Closure A callback ($key, $value) which returns an array [$newKey, $newValue, $group]
+ */
+ final public static function prepareForObject(Model $object): Closure
+ {
+ $hooks = [];
+ foreach (Hook::all('Icingadb/CustomVarRenderer') as $hook) {
+ /** @var self $hook */
+ try {
+ if ($hook->prefetchForObject($object)) {
+ $hooks[] = $hook;
+ }
+ } catch (Exception $e) {
+ Logger::error('Failed to load hook %s:', get_class($hook), $e);
+ }
+ }
+
+ return function (string $key, $value) use ($hooks) {
+ $newKey = $key;
+ $newValue = $value;
+ $group = null;
+ foreach ($hooks as $hook) {
+ /** @var self $hook */
+
+ try {
+ $renderedKey = $hook->renderCustomVarKey($key);
+ $renderedValue = $hook->renderCustomVarValue($key, $value);
+ $group = $hook->identifyCustomVarGroup($key);
+ } catch (Exception $e) {
+ Logger::error('Failed to use hook %s:', get_class($hook), $e);
+ continue;
+ }
+
+ if ($renderedKey !== null || $renderedValue !== null) {
+ $newKey = $renderedKey ?? $key;
+ $newValue = $renderedValue ?? $value;
+ break;
+ }
+ }
+
+ return [$newKey, $newValue, $group];
+ };
+ }
+}
diff --git a/library/Icingadb/Hook/EventDetailExtensionHook.php b/library/Icingadb/Hook/EventDetailExtensionHook.php
new file mode 100644
index 0000000..c348a0c
--- /dev/null
+++ b/library/Icingadb/Hook/EventDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\History;
+use ipl\Html\ValidHtml;
+
+abstract class EventDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given event
+ *
+ * @param History $event
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(History $event): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php
new file mode 100644
index 0000000..dfefdcd
--- /dev/null
+++ b/library/Icingadb/Hook/ExtensionHook/BaseExtensionHook.php
@@ -0,0 +1,146 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\ExtensionHook;
+
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+
+abstract class BaseExtensionHook
+{
+ use HookUtils;
+
+ /** @var int Used as default return value for {@see BaseExtensionHook::getLocation()} */
+ const IDENTIFY_LOCATION_BY_SECTION = -1;
+
+ /** @var string Output section, right at the top */
+ const OUTPUT_SECTION = 'output';
+
+ /** @var string Graph section, below output */
+ const GRAPH_SECTION = 'graph';
+
+ /** @var string Detail section, below graphs */
+ const DETAIL_SECTION = 'detail';
+
+ /** @var string Action section, below action and note urls */
+ const ACTION_SECTION = 'action';
+
+ /** @var string Problem section, below comments and downtimes */
+ const PROBLEM_SECTION = 'problem';
+
+ /** @var string Related section, below groups and notification recipients */
+ const RELATED_SECTION = 'related';
+
+ /** @var string State section, below check statistics and performance data */
+ const STATE_SECTION = 'state';
+
+ /** @var string Config section, below custom variables and feature toggles */
+ const CONFIG_SECTION = 'config';
+
+ /**
+ * Base locations for all known sections
+ *
+ * @var array<string, int>
+ */
+ const BASE_LOCATIONS = [
+ self::OUTPUT_SECTION => 1000,
+ self::GRAPH_SECTION => 1100,
+ self::DETAIL_SECTION => 1200,
+ self::ACTION_SECTION => 1300,
+ self::PROBLEM_SECTION => 1400,
+ self::RELATED_SECTION => 1500,
+ self::STATE_SECTION => 1600,
+ self::CONFIG_SECTION => 1700
+ ];
+
+ /** @var int This hook's location */
+ private $location = self::IDENTIFY_LOCATION_BY_SECTION;
+
+ /** @var string This hook's section */
+ private $section = self::DETAIL_SECTION;
+
+ /**
+ * Set this hook's location
+ *
+ * Note that setting the location explicitly may override other widgets using the same location. But beware that
+ * this applies to this hook's widget as well.
+ *
+ * Also, while the sections are guaranteed to always refer to the same general location, this guarantee is lost
+ * when setting a location explicitly. The core and base locations may change at any time and any explicitly set
+ * location will **not** adjust accordingly.
+ *
+ * @param int $location
+ *
+ * @return void
+ */
+ final public function setLocation(int $location)
+ {
+ $this->location = $location;
+ }
+
+ /**
+ * Get this hook's location
+ *
+ * @return int
+ */
+ final public function getLocation(): int
+ {
+ return $this->location;
+ }
+
+ /**
+ * Set this hook's section
+ *
+ * Sections are used to place widgets loosely in a general location. Using e.g. the `state` section this hook's
+ * widget will always appear after the check statistics and performance data widgets.
+ *
+ * @param string $section
+ *
+ * @return void
+ */
+ final public function setSection(string $section)
+ {
+ $this->section = $section;
+ }
+
+ /**
+ * Get this hook's section
+ *
+ * @return string
+ */
+ final public function getSection(): string
+ {
+ return $this->section;
+ }
+
+ /**
+ * Union both arrays and sort the result by key
+ *
+ * @param array $coreElements
+ * @param array $extensions
+ *
+ * @return array
+ */
+ final public static function injectExtensions(array $coreElements, array $extensions): array
+ {
+ $extensions += $coreElements;
+
+ uksort($extensions, function ($a, $b) {
+ if ($a < 1000 && $b >= 1000) {
+ $b -= 1000;
+ if (abs($a - $b) < 10 && abs($a % 100 - $b % 100) < 10) {
+ return -1;
+ }
+ } elseif ($b < 1000 && $a >= 1000) {
+ $a -= 1000;
+ if (abs($a - $b) < 10 && abs($a % 100 - $b % 100) < 10) {
+ return 1;
+ }
+ }
+
+ return $a < $b ? -1 : ($a > $b ? 1 : 0);
+ });
+
+ return $extensions;
+ }
+}
diff --git a/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
new file mode 100644
index 0000000..7f880e8
--- /dev/null
+++ b/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php
@@ -0,0 +1,121 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\ExtensionHook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Hook\EventDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\HostDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\ServiceDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\UserDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\UsergroupDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use InvalidArgumentException;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Model;
+
+use function ipl\Stdlib\get_php_type;
+
+abstract class ObjectDetailExtensionHook extends BaseExtensionHook
+{
+ /**
+ * Load all extensions for the given object
+ *
+ * @param Host|Service|User|Usergroup|History $object
+ *
+ * @return array<int, ValidHtml>
+ *
+ * @throws InvalidArgumentException If the given model is not supported
+ */
+ final public static function loadExtensions(Model $object): array
+ {
+ switch (true) {
+ case $object instanceof Host:
+ $hookName = 'Icingadb\\HostDetailExtension';
+ break;
+ case $object instanceof Service:
+ $hookName = 'Icingadb\\ServiceDetailExtension';
+ break;
+ case $object instanceof User:
+ $hookName = 'Icingadb\\UserDetailExtension';
+ break;
+ case $object instanceof Usergroup:
+ $hookName = 'Icingadb\\UsergroupDetailExtension';
+ break;
+ case $object instanceof History:
+ $hookName = 'Icingadb\\EventDetailExtension';
+ break;
+ default:
+ throw new InvalidArgumentException(
+ sprintf('%s is not a supported object type', get_php_type($object))
+ );
+ }
+
+ $extensions = [];
+ $lastUsedLocations = [];
+
+ /**
+ * @var $hook HostDetailExtensionHook
+ * @var $hook ServiceDetailExtensionHook
+ * @var $hook UserDetailExtensionHook
+ * @var $hook UsergroupDetailExtensionHook
+ * @var $hook EventDetailExtensionHook
+ */
+ foreach (Hook::all($hookName) as $hook) {
+ $location = $hook->getLocation();
+ if ($location < 0) {
+ $location = null;
+ }
+
+ if ($location === null) {
+ $section = $hook->getSection();
+ if (! isset(self::BASE_LOCATIONS[$section])) {
+ Logger::error('Detail extension %s is using an invalid section: %s', get_class($hook), $section);
+ $section = self::DETAIL_SECTION;
+ }
+
+ if (isset($lastUsedLocations[$section])) {
+ $location = ++$lastUsedLocations[$section];
+ } else {
+ $location = self::BASE_LOCATIONS[$section];
+ $lastUsedLocations[$section] = $location;
+ }
+ }
+
+ try {
+ // It may be ValidHtml, but modules shouldn't be able to break our views.
+ // That's why it needs to be rendered instantly, as any error will then
+ // be caught here.
+ $extension = (string) $hook->getHtmlForObject(clone $object);
+
+ $moduleName = $hook->getModule()->getName();
+
+ $extensions[$location] = new HtmlElement(
+ 'div',
+ Attributes::create([
+ 'class' => 'icinga-module module-' . $moduleName,
+ 'data-icinga-module' => $moduleName
+ ]),
+ HtmlString::create($extension)
+ );
+ } catch (Exception $e) {
+ Logger::error("Failed to load detail extension: %s\n%s", $e, $e->getTraceAsString());
+ $extensions[$location] = Text::create(IcingaException::describe($e));
+ }
+ }
+
+ return $extensions;
+ }
+}
diff --git a/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php
new file mode 100644
index 0000000..cc49667
--- /dev/null
+++ b/library/Icingadb/Hook/ExtensionHook/ObjectsDetailExtensionHook.php
@@ -0,0 +1,101 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\ExtensionHook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Hook\HostsDetailExtensionHook;
+use Icinga\Module\Icingadb\Hook\ServicesDetailExtensionHook;
+use InvalidArgumentException;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+use ipl\Stdlib\BaseFilter;
+use ipl\Stdlib\Filter;
+
+abstract class ObjectsDetailExtensionHook extends BaseExtensionHook
+{
+ use BaseFilter;
+
+ /**
+ * Load all extensions for the given objects
+ *
+ * @param string $objectType
+ * @param Query $query
+ * @param Filter\Rule $baseFilter
+ *
+ * @return array<int, ValidHtml>
+ *
+ * @throws InvalidArgumentException If the given object type is not supported
+ */
+ final public static function loadExtensions(string $objectType, Query $query, Filter\Rule $baseFilter): array
+ {
+ switch ($objectType) {
+ case 'host':
+ $hookName = 'Icingadb\\HostsDetailExtension';
+ break;
+ case 'service':
+ $hookName = 'Icingadb\\ServicesDetailExtension';
+ break;
+ default:
+ throw new InvalidArgumentException(
+ sprintf('%s is not a supported object type', $objectType)
+ );
+ }
+
+ $extensions = [];
+ $lastUsedLocations = [];
+ /** @var HostsDetailExtensionHook|ServicesDetailExtensionHook $hook */
+ foreach (Hook::all($hookName) as $hook) {
+ $location = $hook->getLocation();
+ if ($location < 0) {
+ $location = null;
+ }
+
+ if ($location === null) {
+ $section = $hook->getSection();
+ if (! isset(self::BASE_LOCATIONS[$section])) {
+ Logger::error('Detail extension %s is using an invalid section: %s', get_class($hook), $section);
+ $section = self::DETAIL_SECTION;
+ }
+
+ if (isset($lastUsedLocations[$section])) {
+ $location = ++$lastUsedLocations[$section];
+ } else {
+ $location = self::BASE_LOCATIONS[$section];
+ $lastUsedLocations[$section] = $location;
+ }
+ }
+
+ try {
+ // It may be ValidHtml, but modules shouldn't be able to break our views.
+ // That's why it needs to be rendered instantly, as any error will then
+ // be caught here.
+ $extension = (string) $hook->setBaseFilter($baseFilter)->getHtmlForObjects(clone $query);
+
+ $moduleName = $hook->getModule()->getName();
+
+ $extensions[$location] = new HtmlElement(
+ 'div',
+ Attributes::create([
+ 'class' => 'icinga-module module-' . $moduleName,
+ 'data-icinga-module' => $moduleName
+ ]),
+ HtmlString::create($extension)
+ );
+ } catch (Exception $e) {
+ Logger::error("Failed to load details extension: %s\n%s", $e, $e->getTraceAsString());
+ $extensions[$location] = Text::create(IcingaException::describe($e));
+ }
+ }
+
+ return $extensions;
+ }
+}
diff --git a/library/Icingadb/Hook/HostActionsHook.php b/library/Icingadb/Hook/HostActionsHook.php
new file mode 100644
index 0000000..73c58f4
--- /dev/null
+++ b/library/Icingadb/Hook/HostActionsHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Web\Widget\Link;
+
+abstract class HostActionsHook extends ObjectActionsHook
+{
+ /**
+ * Assemble and return a list of HTML anchors for the given host
+ *
+ * @param Host $host
+ *
+ * @return Link[]
+ */
+ abstract public function getActionsForObject(Host $host): array;
+}
diff --git a/library/Icingadb/Hook/HostDetailExtensionHook.php b/library/Icingadb/Hook/HostDetailExtensionHook.php
new file mode 100644
index 0000000..a6e9ab0
--- /dev/null
+++ b/library/Icingadb/Hook/HostDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\ValidHtml;
+
+abstract class HostDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given host
+ *
+ * @param Host $host
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(Host $host): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/HostsDetailExtensionHook.php b/library/Icingadb/Hook/HostsDetailExtensionHook.php
new file mode 100644
index 0000000..79c091e
--- /dev/null
+++ b/library/Icingadb/Hook/HostsDetailExtensionHook.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+
+abstract class HostsDetailExtensionHook extends ObjectsDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given hosts
+ *
+ * The given query is already pre-filtered with the user's custom filter and restrictions. The base filter does
+ * only contain the user's custom filter, use this for e.g. subsidiary links.
+ *
+ * The query is also limited by default, use `$hosts->limit(null)` to clear that. But beware that this may yield
+ * a huge result set in case of a bulk selection.
+ *
+ * @param Query<Host> $hosts
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObjects(Query $hosts): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/IcingadbSupportHook.php b/library/Icingadb/Hook/IcingadbSupportHook.php
new file mode 100644
index 0000000..96cdd19
--- /dev/null
+++ b/library/Icingadb/Hook/IcingadbSupportHook.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+use Icinga\Web\Session;
+
+abstract class IcingadbSupportHook
+{
+ use HookUtils;
+
+ /** @var string key name of preference */
+ const PREFERENCE_NAME = 'icingadb.as_backend';
+
+ /**
+ * Return whether your module supports IcingaDB or not
+ *
+ * @return bool
+ */
+ public function supportsIcingaDb(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Whether icingadb is set as the preferred backend in preferences
+ *
+ * @return bool Return true if icingadb is set as backend, false otherwise
+ */
+ final public static function isIcingaDbSetAsPreferredBackend(): bool
+ {
+ return (bool) Session::getSession()
+ ->getNamespace('icingadb')
+ ->get(self::PREFERENCE_NAME, false);
+ }
+
+ /**
+ * Whether to use icingadb as the backend
+ *
+ * @return bool Returns true if monitoring module is accessible or icingadb is selected as backend, false otherwise.
+ */
+ final public static function useIcingaDbAsBackend(): bool
+ {
+ return ! Icinga::app()->getModuleManager()->hasEnabled('monitoring')
+ || ! Auth::getInstance()->hasPermission('module/monitoring')
+ || self::isIcingaDbSetAsPreferredBackend();
+ }
+}
diff --git a/library/Icingadb/Hook/PluginOutputHook.php b/library/Icingadb/Hook/PluginOutputHook.php
new file mode 100644
index 0000000..7c744ee
--- /dev/null
+++ b/library/Icingadb/Hook/PluginOutputHook.php
@@ -0,0 +1,63 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Exception;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+
+abstract class PluginOutputHook
+{
+ use HookUtils;
+
+ /**
+ * Return whether the given command is supported or not
+ *
+ * @param string $commandName
+ *
+ * @return bool
+ */
+ abstract public function isSupportedCommand(string $commandName): bool;
+
+ /**
+ * Process the given plugin output based on the specified check command
+ *
+ * Try to process the output as efficient and fast as possible.
+ * Especially list view performance may suffer otherwise.
+ *
+ * @param string $output A host's or service's output
+ * @param string $commandName The name of the checkcommand that produced the output
+ * @param bool $enrichOutput Whether macros or other markup should be processed
+ *
+ * @return string
+ */
+ abstract public function render(string $output, string $commandName, bool $enrichOutput): string;
+
+ /**
+ * Let all hooks process the given plugin output based on the specified check command
+ *
+ * @param string $output
+ * @param string $commandName
+ * @param bool $enrichOutput
+ *
+ * @return string
+ */
+ final public static function processOutput(string $output, string $commandName, bool $enrichOutput): string
+ {
+ foreach (Hook::all('Icingadb\\PluginOutput') as $hook) {
+ /** @var self $hook */
+ try {
+ if ($hook->isSupportedCommand($commandName)) {
+ $output = $hook->render($output, $commandName, $enrichOutput);
+ }
+ } catch (Exception $e) {
+ Logger::error("Unable to process plugin output: %s\n%s", $e, $e->getTraceAsString());
+ }
+ }
+
+ return $output;
+ }
+}
diff --git a/library/Icingadb/Hook/ServiceActionsHook.php b/library/Icingadb/Hook/ServiceActionsHook.php
new file mode 100644
index 0000000..988cdb6
--- /dev/null
+++ b/library/Icingadb/Hook/ServiceActionsHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Web\Widget\Link;
+
+abstract class ServiceActionsHook extends ObjectActionsHook
+{
+ /**
+ * Assemble and return a list of HTML anchors for the given service
+ *
+ * @param Service $service
+ *
+ * @return Link[]
+ */
+ abstract public function getActionsForObject(Service $service): array;
+}
diff --git a/library/Icingadb/Hook/ServiceDetailExtensionHook.php b/library/Icingadb/Hook/ServiceDetailExtensionHook.php
new file mode 100644
index 0000000..5344620
--- /dev/null
+++ b/library/Icingadb/Hook/ServiceDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\ValidHtml;
+
+abstract class ServiceDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given service
+ *
+ * @param Service $service
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(Service $service): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/ServicesDetailExtensionHook.php b/library/Icingadb/Hook/ServicesDetailExtensionHook.php
new file mode 100644
index 0000000..35ba8d3
--- /dev/null
+++ b/library/Icingadb/Hook/ServicesDetailExtensionHook.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+
+abstract class ServicesDetailExtensionHook extends ObjectsDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given services
+ *
+ * The given query is already pre-filtered with the user's custom filter and restrictions. The base filter does
+ * only contain the user's custom filter, use this for e.g. subsidiary links.
+ *
+ * The query is also limited by default, use `$hosts->limit(null)` to clear that. But beware that this may yield
+ * a huge result set in case of a bulk selection.
+ *
+ * @param Query<Service> $services
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObjects(Query $services): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/TabHook.php b/library/Icingadb/Hook/TabHook.php
new file mode 100644
index 0000000..0c5b676
--- /dev/null
+++ b/library/Icingadb/Hook/TabHook.php
@@ -0,0 +1,82 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Hook\Common\HookUtils;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Model;
+
+abstract class TabHook
+{
+ use Auth;
+ use Database;
+ use HookUtils;
+
+ /**
+ * Get the tab's name
+ *
+ * The name is used to identify this hook later on. It must be unique.
+ * Multiple words in the name should be separated by dashes. (-)
+ *
+ * @return string
+ */
+ abstract public function getName(): string;
+
+ /**
+ * Get the tab's label
+ *
+ * The label is shown on the tab and in the browser's title.
+ *
+ * @return string
+ */
+ abstract public function getLabel(): string;
+
+ /**
+ * Get tab content for the given object
+ *
+ * @param Model $object
+ *
+ * @return ValidHtml[]
+ */
+ abstract public function getContent(Model $object): array;
+
+ /**
+ * Get tab controls for the given object
+ *
+ * @param Model $object
+ *
+ * @return ValidHtml[]
+ */
+ public function getControls(Model $object): array
+ {
+ return [];
+ }
+
+ /**
+ * Get tab footer for the given object
+ *
+ * @param Model $object
+ *
+ * @return ValidHtml[]
+ */
+ public function getFooter(Model $object): array
+ {
+ return [];
+ }
+
+ /**
+ * Get whether this tab should be shown
+ *
+ * @param Model $object
+ *
+ * @return bool
+ */
+ public function shouldBeShown(Model $object): bool
+ {
+ return true;
+ }
+}
diff --git a/library/Icingadb/Hook/TabHook/HookActions.php b/library/Icingadb/Hook/TabHook/HookActions.php
new file mode 100644
index 0000000..d2801a5
--- /dev/null
+++ b/library/Icingadb/Hook/TabHook/HookActions.php
@@ -0,0 +1,148 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook\TabHook;
+
+use Exception;
+use Generator;
+use Icinga\Application\Hook;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Hook\TabHook;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Model;
+use ipl\Stdlib\Str;
+
+/**
+ * Trait HookActions
+ */
+trait HookActions
+{
+ /** @var Model The object to load tabs for */
+ protected $objectToLoadTabsFor;
+
+ /** @var TabHook[] Loaded tab hooks */
+ protected $tabHooks;
+
+ /**
+ * Get default control elements
+ *
+ * @return ValidHtml[]
+ */
+ abstract protected function getDefaultTabControls(): array;
+
+ public function __call($methodName, $args)
+ {
+ if (substr($methodName, -6) === 'Action') {
+ $hookName = substr($methodName, 0, -6);
+
+ $hooks = $this->loadTabHooks();
+ if (isset($hooks[$hookName])) {
+ $this->showTabHook($hooks[$hookName]);
+ return;
+ }
+ }
+
+ parent::__call($methodName, $args);
+ }
+
+ /**
+ * Register the object for which to load additional tabs
+ *
+ * @param Model $object
+ *
+ * @return void
+ */
+ protected function loadTabsForObject(Model $object)
+ {
+ $this->objectToLoadTabsFor = $object;
+ }
+
+ /**
+ * Load tab hooks
+ *
+ * @return array<string, TabHook>
+ */
+ protected function loadTabHooks(): array
+ {
+ if ($this->objectToLoadTabsFor === null) {
+ return [];
+ } elseif ($this->tabHooks !== null) {
+ return $this->tabHooks;
+ }
+
+ $this->tabHooks = [];
+ foreach (Hook::all('Icingadb\\Tab') as $hook) {
+ /** @var TabHook $hook */
+ try {
+ if ($hook->shouldBeShown($this->objectToLoadTabsFor)) {
+ $this->tabHooks[Str::camel($hook->getName())] = $hook;
+ }
+ } catch (Exception $e) {
+ Logger::error("Failed to load tab hook: %s\n%s", $e, $e->getTraceAsString());
+ }
+ }
+
+ return $this->tabHooks;
+ }
+
+ /**
+ * Load additional tabs
+ *
+ * @return Generator<string, array{label: string, url: string}>
+ */
+ protected function loadAdditionalTabs(): Generator
+ {
+ foreach ($this->loadTabHooks() as $hook) {
+ yield $hook->getName() => [
+ 'label' => $hook->getLabel(),
+ 'url' => 'icingadb/' . $this->getRequest()->getControllerName() . '/' . $hook->getName()
+ ];
+ }
+ }
+
+ /**
+ * Render the given tab hook
+ *
+ * @param TabHook $hook
+ *
+ * @return void
+ */
+ protected function showTabHook(TabHook $hook)
+ {
+ $moduleName = $hook->getModule()->getName();
+
+ foreach ($hook->getControls($this->objectToLoadTabsFor) as $control) {
+ $this->addControl($control);
+ }
+
+ if (! empty($this->controls->getContent())) {
+ $this->controls->addAttributes([
+ 'class' => ['icinga-module', 'module-' . $moduleName],
+ 'data-icinga-module' => $moduleName
+ ]);
+ } else {
+ foreach ($this->getDefaultTabControls() as $control) {
+ $this->addControl($control);
+ }
+ }
+
+ foreach ($hook->getContent($this->objectToLoadTabsFor) as $content) {
+ $this->addContent($content);
+ }
+
+ $this->content->addAttributes([
+ 'class' => ['icinga-module', 'module-' . $moduleName],
+ 'data-icinga-module' => $moduleName
+ ]);
+
+ foreach ($hook->getFooter($this->objectToLoadTabsFor) as $footer) {
+ $this->addFooter($footer);
+ }
+
+ $this->footer->addAttributes([
+ 'class' => ['icinga-module', 'module-' . $moduleName],
+ 'data-icinga-module' => $moduleName
+ ]);
+ }
+}
diff --git a/library/Icingadb/Hook/UserDetailExtensionHook.php b/library/Icingadb/Hook/UserDetailExtensionHook.php
new file mode 100644
index 0000000..bb1bf7e
--- /dev/null
+++ b/library/Icingadb/Hook/UserDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\User;
+use ipl\Html\ValidHtml;
+
+abstract class UserDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given user
+ *
+ * @param User $user
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(User $user): ValidHtml;
+}
diff --git a/library/Icingadb/Hook/UsergroupDetailExtensionHook.php b/library/Icingadb/Hook/UsergroupDetailExtensionHook.php
new file mode 100644
index 0000000..da2264d
--- /dev/null
+++ b/library/Icingadb/Hook/UsergroupDetailExtensionHook.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Hook;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use ipl\Html\ValidHtml;
+
+abstract class UsergroupDetailExtensionHook extends ObjectDetailExtensionHook
+{
+ /**
+ * Assemble and return an HTML representation of the given usergroup
+ *
+ * @param Usergroup $usergroup
+ *
+ * @return ValidHtml
+ */
+ abstract public function getHtmlForObject(Usergroup $usergroup): ValidHtml;
+}
diff --git a/library/Icingadb/Model/AcknowledgementHistory.php b/library/Icingadb/Model/AcknowledgementHistory.php
new file mode 100644
index 0000000..549d2ff
--- /dev/null
+++ b/library/Icingadb/Model/AcknowledgementHistory.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `acknowledgement_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this, the query
+ * needs a `acknowledgement_history.service_id IS NULL OR acknowledgement_history_service.id IS NOT NULL` where.
+ */
+class AcknowledgementHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'acknowledgement_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'set_time',
+ 'clear_time',
+ 'author',
+ 'cleared_by',
+ 'comment',
+ 'expire_time',
+ 'is_sticky',
+ 'is_persistent'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'set_time' => t('Acknowledgement Set Time'),
+ 'clear_time' => t('Acknowledgement Clear Time'),
+ 'author' => t('Acknowledgement Author'),
+ 'cleared_by' => t('Acknowledgement Cleared By'),
+ 'comment' => t('Acknowledgement Comment'),
+ 'expire_time' => t('Acknowledgement Expire Time'),
+ 'is_sticky' => t('Acknowledgement Is Sticky'),
+ 'is_persistent' => t('Acknowledgement Is Persistent')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_sticky',
+ 'is_persistent'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'set_time',
+ 'clear_time',
+ 'expire_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id',
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('acknowledgement_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/ActionUrl.php b/library/Icingadb/Model/ActionUrl.php
new file mode 100644
index 0000000..e0b092e
--- /dev/null
+++ b/library/Icingadb/Model/ActionUrl.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ActionAndNoteUrl;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class ActionUrl extends Model
+{
+ public function getTableName()
+ {
+ return 'action_url';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'action_url',
+ 'environment_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'action_url' => t('Action Url'),
+ 'environment_id' => t('Environment Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ActionAndNoteUrl(['action_url']));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->hasMany('host', Host::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('action_url_id');
+ $relations->hasMany('service', Service::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('action_url_id');
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/ActionAndNoteUrl.php b/library/Icingadb/Model/Behavior/ActionAndNoteUrl.php
new file mode 100644
index 0000000..e8f6799
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/ActionAndNoteUrl.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+
+class ActionAndNoteUrl extends PropertyBehavior
+{
+ public function fromDb($value, $key, $_)
+ {
+ if (empty($value)) {
+ return [];
+ }
+
+ $links = [];
+ if (strpos($value, "' ") === false) {
+ $links[] = $value;
+ } else {
+ foreach (explode("' ", $value) as $url) {
+ $url = strpos($url, "'") === 0 ? substr($url, 1) : $url;
+ $url = strpos($url, "'") === strlen($url) - 1 ? substr($url, 0, strlen($url) - 1) : $url;
+ $links[] = $url;
+ }
+ }
+
+ return $links;
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if (empty($value) || ! is_array($value)) {
+ return $value;
+ }
+
+ if (count($value) === 1) {
+ return $value[0];
+ }
+
+ $links = '';
+ foreach ($value as $url) {
+ if (! empty($links)) {
+ $links .= ' ';
+ }
+
+ $links .= "'$url'";
+ }
+
+ return $links;
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/Bitmask.php b/library/Icingadb/Model/Behavior/Bitmask.php
new file mode 100644
index 0000000..f8d91f6
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/Bitmask.php
@@ -0,0 +1,83 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+use ipl\Orm\Contract\RewriteFilterBehavior;
+use ipl\Stdlib\Filter\Condition;
+
+/**
+ * Class Bitmask
+ *
+ * @method void __construct(array $properties) Pass property names as keys and their bitmap ([value => bit]) as value
+ */
+class Bitmask extends PropertyBehavior implements RewriteFilterBehavior
+{
+ public function fromDb($bits, $key, $context)
+ {
+ $values = [];
+ foreach ($context as $value => $bit) {
+ if ($bits & $bit) {
+ $values[] = $value;
+ }
+ }
+
+ return $values;
+ }
+
+ public function toDb($value, $key, $context)
+ {
+ if (! is_array($value)) {
+ if (is_int($value) || ctype_digit($value)) {
+ return $value;
+ }
+
+ return isset($context[$value]) ? $context[$value] : -1;
+ }
+
+ $bits = [];
+ $allBits = 0;
+ foreach ($value as $v) {
+ if (isset($context[$v])) {
+ $bits[] = $context[$v];
+ $allBits |= $context[$v];
+ } elseif (is_int($v) || ctype_digit($v)) {
+ $bits[] = $v;
+ $allBits |= $v;
+ }
+ }
+
+ $bits[] = $allBits;
+ return $bits;
+ }
+
+ public function rewriteCondition(Condition $condition, $relation = null)
+ {
+ $column = $condition->metaData()->get('columnName');
+ if (! isset($this->properties[$column])) {
+ return;
+ }
+
+ $values = $condition->getValue();
+ if (! is_array($values)) {
+ if (is_int($values) || ctype_digit($values)) {
+ return;
+ }
+
+ $values = [$values];
+ }
+
+ $bits = 0;
+ foreach ($values as $value) {
+ if (isset($this->properties[$column][$value])) {
+ $bits |= $this->properties[$column][$value];
+ } elseif (is_int($value) || ctype_digit($value)) {
+ $bits |= $value;
+ }
+ }
+
+ $condition->setColumn(sprintf('%s & %s', $condition->getColumn(), $bits));
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/BoolCast.php b/library/Icingadb/Model/Behavior/BoolCast.php
new file mode 100644
index 0000000..8ab01ae
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/BoolCast.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use ipl\Orm\Contract\PropertyBehavior;
+
+class BoolCast extends PropertyBehavior
+{
+ public function fromDb($value, $key, $_)
+ {
+ switch ((string) $value) {
+ case 'y':
+ return true;
+ case 'n':
+ return false;
+ default:
+ return $value;
+ }
+ }
+
+ public function toDb($value, $key, $_)
+ {
+ if (is_string($value)) {
+ return $value;
+ }
+
+ return $value ? 'y' : 'n';
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/FlattenedObjectVars.php b/library/Icingadb/Model/Behavior/FlattenedObjectVars.php
new file mode 100644
index 0000000..b1c308a
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/FlattenedObjectVars.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use ipl\Orm\AliasedExpression;
+use ipl\Orm\ColumnDefinition;
+use ipl\Orm\Contract\QueryAwareBehavior;
+use ipl\Orm\Contract\RewriteColumnBehavior;
+use ipl\Orm\Query;
+use ipl\Stdlib\Filter;
+
+class FlattenedObjectVars implements RewriteColumnBehavior, QueryAwareBehavior
+{
+ use Auth;
+
+ /** @var Query */
+ protected $query;
+
+ public function setQuery(Query $query)
+ {
+ $this->query = $query;
+
+ return $this;
+ }
+
+ public function rewriteCondition(Filter\Condition $condition, $relation = null)
+ {
+ $column = $condition->metaData()->get('columnName');
+ if ($column !== null) {
+ $relation = substr($relation, 0, -5) . 'customvar_flat.';
+ $nameFilter = Filter::like($relation . 'flatname', $column);
+ $class = get_class($condition);
+ $valueFilter = new $class($relation . 'flatvalue', $condition->getValue());
+
+ return Filter::all($nameFilter, $valueFilter);
+ }
+ }
+
+ public function rewriteColumn($column, $relation = null)
+ {
+ $subQuery = $this->query->createSubQuery(new CustomvarFlat(), $relation)
+ ->limit(1)
+ ->columns('flatvalue')
+ ->filter(Filter::equal('flatname', $column));
+
+ $this->applyRestrictions($subQuery);
+
+ $alias = $this->query->getDb()->quoteIdentifier([str_replace('.', '_', $relation) . "_$column"]);
+
+ list($select, $values) = $this->query->getDb()->getQueryBuilder()->assembleSelect($subQuery->assembleSelect());
+ return new AliasedExpression($alias, "($select)", null, ...$values);
+ }
+
+ public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void
+ {
+ $parts = explode('.', substr($relation, 0, -5));
+ $objectType = array_pop($parts);
+
+ $name = $def->getName();
+ if (substr($name, -3) === '[*]') {
+ // The suggestions also hide this from the label, so should this
+ $name = substr($name, 0, -3);
+ }
+
+ // Programmatically translated since the full definition is available in class ObjectSuggestions
+ $def->setLabel(sprintf(t(ucfirst($objectType) . ' %s', '..<customvar-name>'), $name));
+ }
+
+ public function isSelectableColumn(string $name): bool
+ {
+ return true;
+ }
+}
diff --git a/library/Icingadb/Model/Behavior/ReRoute.php b/library/Icingadb/Model/Behavior/ReRoute.php
new file mode 100644
index 0000000..d054f00
--- /dev/null
+++ b/library/Icingadb/Model/Behavior/ReRoute.php
@@ -0,0 +1,83 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model\Behavior;
+
+use ipl\Orm\Contract\RewriteFilterBehavior;
+use ipl\Orm\Contract\RewritePathBehavior;
+use ipl\Stdlib\Filter;
+
+class ReRoute implements RewriteFilterBehavior, RewritePathBehavior
+{
+ protected $routes;
+
+ /**
+ * Tables with mixed object type entries for which servicegroup filters need to be resolved in multiple steps
+ *
+ * @var string[]
+ */
+ const MIXED_TYPE_RELATIONS = ['downtime', 'comment', 'history', 'notification_history'];
+
+ public function __construct(array $routes)
+ {
+ $this->routes = $routes;
+ }
+
+ public function getRoutes(): array
+ {
+ return $this->routes;
+ }
+
+ public function rewriteCondition(Filter\Condition $condition, $relation = null)
+ {
+ $remainingPath = $condition->metaData()->get('columnName', '');
+ if (strpos($remainingPath, '.') === false) {
+ return;
+ }
+
+ if (($path = $this->rewritePath($remainingPath, $relation)) !== null) {
+ $class = get_class($condition);
+ $filter = new $class($relation . $path, $condition->getValue());
+ if ($condition->metaData()->has('forceOptimization')) {
+ $filter->metaData()->set(
+ 'forceOptimization',
+ $condition->metaData()->get('forceOptimization')
+ );
+ }
+
+ if (
+ in_array(substr($relation, 0, -1), self::MIXED_TYPE_RELATIONS)
+ && substr($remainingPath, 0, 13) === 'servicegroup.'
+ ) {
+ $applyAll = Filter::all();
+ $applyAll->add(Filter::equal($relation . 'object_type', 'host'));
+
+ $orgFilter = clone $filter;
+ $orgFilter->setColumn($relation . 'host.' . $path);
+
+ $applyAll->add($orgFilter);
+
+ $filter = Filter::any($filter, $applyAll);
+ }
+
+ return $filter;
+ }
+ }
+
+ public function rewritePath(string $path, ?string $relation = null): ?string
+ {
+ $dot = strpos($path, '.');
+ if ($dot !== false) {
+ $routeName = substr($path, 0, $dot);
+ } else {
+ $routeName = $path;
+ }
+
+ if (isset($this->routes[$routeName])) {
+ return $this->routes[$routeName] . ($dot !== false ? substr($path, $dot) : '');
+ }
+
+ return null;
+ }
+}
diff --git a/library/Icingadb/Model/Checkcommand.php b/library/Icingadb/Model/Checkcommand.php
new file mode 100644
index 0000000..400a24b
--- /dev/null
+++ b/library/Icingadb/Model/Checkcommand.php
@@ -0,0 +1,86 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Checkcommand extends Model
+{
+ public function getTableName()
+ {
+ return 'checkcommand';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'command',
+ 'timeout'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'zone_id' => t('Zone Id'),
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Checkcommand Name Checksum'),
+ 'properties_checksum' => t('Checkcommand Properties Checksum'),
+ 'name' => t('Checkcommand Name'),
+ 'name_ci' => t('Checkcommand Name (CI)'),
+ 'command' => t('Checkcommand'),
+ 'timeout' => t('Checkcommand Timeout')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(CheckcommandCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(CheckcommandCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(CheckcommandCustomvar::class);
+
+ $relations->hasMany('argument', CheckcommandArgument::class);
+ $relations->hasMany('envvar', CheckcommandEnvvar::class);
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('service', Service::class);
+ }
+}
diff --git a/library/Icingadb/Model/CheckcommandArgument.php b/library/Icingadb/Model/CheckcommandArgument.php
new file mode 100644
index 0000000..3fb4ad5
--- /dev/null
+++ b/library/Icingadb/Model/CheckcommandArgument.php
@@ -0,0 +1,75 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class CheckcommandArgument extends Model
+{
+ public function getTableName()
+ {
+ return 'checkcommand_argument';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'checkcommand_id',
+ 'argument_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'argument_value',
+ 'argument_order',
+ 'description',
+ 'argument_key_override',
+ 'repeat_key',
+ 'required',
+ 'set_if',
+ 'skip_key'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'checkcommand_id' => t('Checkcommand Id'),
+ 'argument_key' => t('Checkcommand Argument Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Checkcommand Argument Properties Checksum'),
+ 'argument_value' => t('Checkcommand Argument Value'),
+ 'argument_order' => t('Checkcommand Argument Position'),
+ 'description' => t('Checkcommand Argument Description'),
+ 'argument_key_override' => t('Checkcommand Argument Actual Name'),
+ 'repeat_key' => t('Checkcommand Argument Repeated'),
+ 'required' => t('Checkcommand Argument Required'),
+ 'set_if' => t('Checkcommand Argument Condition'),
+ 'skip_key' => t('Checkcommand Argument Without Name')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'checkcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/CheckcommandCustomvar.php b/library/Icingadb/Model/CheckcommandCustomvar.php
new file mode 100644
index 0000000..0a834b5
--- /dev/null
+++ b/library/Icingadb/Model/CheckcommandCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class CheckcommandCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'checkcommand_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'checkcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'checkcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/CheckcommandEnvvar.php b/library/Icingadb/Model/CheckcommandEnvvar.php
new file mode 100644
index 0000000..517a45f
--- /dev/null
+++ b/library/Icingadb/Model/CheckcommandEnvvar.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class CheckcommandEnvvar extends Model
+{
+ public function getTableName()
+ {
+ return 'checkcommand_envvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'checkcommand_id',
+ 'envvar_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'envvar_value'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'checkcommand_id' => t('Checkcommand Id'),
+ 'envvar_key' => t('Checkcommand Envvar Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Checkcommand Properties Checksum'),
+ 'envvar_value' => t('Checkcommand Envvar Value')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'checkcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/Comment.php b/library/Icingadb/Model/Comment.php
new file mode 100644
index 0000000..bcdd5e0
--- /dev/null
+++ b/library/Icingadb/Model/Comment.php
@@ -0,0 +1,114 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Comment extends Model
+{
+ public function getTableName()
+ {
+ return 'comment';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'author',
+ 'text',
+ 'entry_type',
+ 'entry_time',
+ 'is_persistent',
+ 'is_sticky',
+ 'expire_time',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'name_checksum' => t('Comment Name Checksum'),
+ 'properties_checksum' => t('Comment Properties Checksum'),
+ 'name' => t('Comment Name'),
+ 'author' => t('Comment Author'),
+ 'text' => t('Comment Text'),
+ 'entry_type' => t('Comment Type'),
+ 'entry_time' => t('Comment Entry Time'),
+ 'is_persistent' => t('Comment Is Persistent'),
+ 'is_sticky' => t('Comment Is Sticky'),
+ 'expire_time' => t('Comment Expire Time'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['text'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'comment.entry_time desc';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_persistent',
+ 'is_sticky'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'entry_time',
+ 'expire_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'host_id',
+ 'service_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class)->setJoinType('LEFT');
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ $relations->belongsTo('zone', Zone::class);
+ }
+}
diff --git a/library/Icingadb/Model/CommentHistory.php b/library/Icingadb/Model/CommentHistory.php
new file mode 100644
index 0000000..5f03e68
--- /dev/null
+++ b/library/Icingadb/Model/CommentHistory.php
@@ -0,0 +1,107 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `comment_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this,
+ * the query needs a `comment_history.service_id IS NULL OR comment_history_service.id IS NOT NULL` where.
+ */
+class CommentHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'comment_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'comment_id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'entry_time',
+ 'author',
+ 'removed_by',
+ 'comment',
+ 'entry_type',
+ 'is_persistent',
+ 'is_sticky',
+ 'expire_time',
+ 'remove_time',
+ 'has_been_removed'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'entry_time' => t('Comment Entry Time'),
+ 'author' => t('Comment Author'),
+ 'removed_by' => t('Comment Removed By'),
+ 'comment' => t('Comment Text'),
+ 'entry_type' => t('Comment Entry Type'),
+ 'is_persistent' => t('Comment Is Persistent'),
+ 'is_sticky' => t('Comment Is Sticky'),
+ 'expire_time' => t('Comment Expire Time'),
+ 'remove_time' => t('Comment Remove Time'),
+ 'has_been_removed' => t('Comment Has Been Removed')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_persistent',
+ 'is_sticky',
+ 'has_been_removed'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'entry_time',
+ 'expire_time',
+ 'remove_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'comment_id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('comment_id')
+ ->setForeignKey('comment_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/Customvar.php b/library/Icingadb/Model/Customvar.php
new file mode 100644
index 0000000..e043229
--- /dev/null
+++ b/library/Icingadb/Model/Customvar.php
@@ -0,0 +1,72 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Customvar extends Model
+{
+ public function getTableName()
+ {
+ return 'customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'name',
+ 'value'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->belongsToMany('checkcommand', Checkcommand::class)
+ ->through(CheckcommandCustomvar::class);
+ $relations->belongsToMany('eventcommand', Eventcommand::class)
+ ->through(EventcommandCustomvar::class);
+ $relations->belongsToMany('host', Host::class)
+ ->through(HostCustomvar::class);
+ $relations->belongsToMany('hostgroup', Hostgroup::class)
+ ->through(HostgroupCustomvar::class);
+ $relations->belongsToMany('notification', Notification::class)
+ ->through(NotificationCustomvar::class);
+ $relations->belongsToMany('notificationcommand', Notificationcommand::class)
+ ->through(NotificationcommandCustomvar::class);
+ $relations->belongsToMany('service', Service::class)
+ ->through(ServiceCustomvar::class);
+ $relations->belongsToMany('servicegroup', Servicegroup::class)
+ ->through(ServicegroupCustomvar::class);
+ $relations->belongsToMany('timeperiod', Timeperiod::class)
+ ->through(TimeperiodCustomvar::class);
+ $relations->belongsToMany('user', User::class)
+ ->through(UserCustomvar::class);
+ $relations->belongsToMany('usergroup', Usergroup::class)
+ ->through(UsergroupCustomvar::class);
+
+ $relations->hasMany('customvar_flat', CustomvarFlat::class);
+ }
+}
diff --git a/library/Icingadb/Model/CustomvarFlat.php b/library/Icingadb/Model/CustomvarFlat.php
new file mode 100644
index 0000000..99d8aca
--- /dev/null
+++ b/library/Icingadb/Model/CustomvarFlat.php
@@ -0,0 +1,167 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Query;
+use ipl\Orm\Relations;
+use Traversable;
+
+class CustomvarFlat extends Model
+{
+ public function getTableName()
+ {
+ return 'customvar_flat';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'customvar_id',
+ 'flatname_checksum',
+ 'flatname',
+ 'flatvalue'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'customvar_id',
+ 'flatname_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('customvar', Customvar::class);
+
+ $relations->belongsToMany('checkcommand', Checkcommand::class)
+ ->through(CheckcommandCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('eventcommand', Eventcommand::class)
+ ->through(EventcommandCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('host', Host::class)
+ ->through(HostCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('hostgroup', Hostgroup::class)
+ ->through(HostgroupCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('notification', Notification::class)
+ ->through(NotificationCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('notificationcommand', Notificationcommand::class)
+ ->through(NotificationcommandCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('service', Service::class)
+ ->through(ServiceCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('servicegroup', Servicegroup::class)
+ ->through(ServicegroupCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('timeperiod', Timeperiod::class)
+ ->through(TimeperiodCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('user', User::class)
+ ->through(UserCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ $relations->belongsToMany('usergroup', Usergroup::class)
+ ->through(UsergroupCustomvar::class)
+ ->setCandidateKey('customvar_id');
+ }
+
+ /**
+ * Restore flattened custom variables to their previous structure
+ *
+ * @param Traversable $flattenedVars
+ *
+ * @return array
+ */
+ public function unFlattenVars(Traversable $flattenedVars): array
+ {
+ $registerValue = function (&$data, $source, $path, $value) use (&$registerValue) {
+ $step = array_shift($path);
+
+ $isIndex = (bool) preg_match('/^\[(\d+)]$/', $step, $m);
+ if ($isIndex) {
+ $step = $m[1];
+ }
+
+ if ($source !== null) {
+ while (! isset($source[$step])) {
+ if ($isIndex) {
+ $step = sprintf('[%d]', $step);
+ $isIndex = false;
+ } else {
+ if (empty($path)) {
+ break;
+ }
+
+ $step = implode('.', [$step, array_shift($path)]);
+ }
+ }
+ }
+
+ if (! empty($path)) {
+ if (! isset($data[$step])) {
+ $data[$step] = [];
+ }
+
+ $registerValue($data[$step], $source[$step] ?? null, $path, $value);
+ } else {
+ // Since empty custom vars of type dictionaries and arrays have null values in customvar_flat table,
+ // we won't be able to render them as such. Therefore, we have to use the value of the `customvar`
+ // table if it's not null, otherwise the current value, which is a "null" string.
+ $data[$step] = $value === null && ($source[$step] ?? null) === [] ? $source[$step] : $value;
+ }
+ };
+
+ if ($flattenedVars instanceof Query) {
+ $flattenedVars->withColumns(['customvar.name', 'customvar.value']);
+ }
+
+ $vars = [];
+ foreach ($flattenedVars as $var) {
+ if (isset($var->customvar->name)) {
+ $var->customvar->value = json_decode($var->customvar->value, true);
+
+ $realName = $var->customvar->name;
+ $source = [$realName => $var->customvar->value];
+
+ $sourcePath = ltrim(substr($var->flatname, strlen($realName)), '.');
+ $path = array_merge(
+ [$realName],
+ $sourcePath
+ ? preg_split('/(?<=\w|])\.|(?<!^|\.)(?=\[)/', $sourcePath)
+ : []
+ );
+ } else {
+ $path = explode('.', $var->flatname);
+ $source = null;
+ }
+
+ $registerValue($vars, $source, $path, $var->flatvalue);
+
+ if (isset($var->customvar->name)) {
+ $var->customvar->name = null;
+ $var->customvar->value = null;
+ }
+ }
+
+ return $vars;
+ }
+}
diff --git a/library/Icingadb/Model/Downtime.php b/library/Icingadb/Model/Downtime.php
new file mode 100644
index 0000000..7f600e9
--- /dev/null
+++ b/library/Icingadb/Model/Downtime.php
@@ -0,0 +1,144 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Downtime extends Model
+{
+ public function getTableName()
+ {
+ return 'downtime';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'triggered_by_id',
+ 'parent_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'author',
+ 'comment',
+ 'entry_time',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'scheduled_duration',
+ 'is_flexible',
+ 'flexible_duration',
+ 'is_in_effect',
+ 'start_time',
+ 'end_time',
+ 'duration',
+ 'scheduled_by',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'triggered_by_id' => t('Triggered By Downtime Id'),
+ 'parent_id' => t('Parent Downtime Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'name_checksum' => t('Downtime Name Checksum'),
+ 'properties_checksum' => t('Downtime Properties Checksum'),
+ 'name' => t('Downtime Name'),
+ 'author' => t('Downtime Author'),
+ 'comment' => t('Downtime Comment'),
+ 'entry_time' => t('Downtime Entry Time'),
+ 'scheduled_start_time' => t('Downtime Scheduled Start'),
+ 'scheduled_end_time' => t('Downtime Scheduled End'),
+ 'scheduled_duration' => t('Downtime Scheduled Duration'),
+ 'is_flexible' => t('Downtime Is Flexible'),
+ 'flexible_duration' => t('Downtime Flexible Duration'),
+ 'is_in_effect' => t('Downtime Is In Effect'),
+ 'start_time' => t('Downtime Actual Start'),
+ 'end_time' => t('Downtime Actual End'),
+ 'duration' => t('Downtime Duration'),
+ 'scheduled_by' => t('Scheduled By Downtime'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['comment'];
+ }
+
+ public function getDefaultSort()
+ {
+ return ['downtime.is_in_effect desc', 'downtime.start_time desc'];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_flexible',
+ 'is_in_effect'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'entry_time',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'start_time',
+ 'end_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'triggered_by_id',
+ 'parent_id',
+ 'host_id',
+ 'service_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+
+ // As long as the rewriteCondition() expects only Filter\Condition as a first argument
+ // We have to add this reroute behavior after the binary because the filter condition might
+ // be transformed into a filter chain!
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('triggered_by', self::class)
+ ->setCandidateKey('triggered_by_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('parent', self::class)
+ ->setCandidateKey('parent_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class)->setJoinType('LEFT');
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ $relations->belongsTo('zone', Zone::class);
+ }
+}
diff --git a/library/Icingadb/Model/DowntimeHistory.php b/library/Icingadb/Model/DowntimeHistory.php
new file mode 100644
index 0000000..7ba992f
--- /dev/null
+++ b/library/Icingadb/Model/DowntimeHistory.php
@@ -0,0 +1,128 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `downtime_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this,
+ * the query needs a `downtime_history.service_id IS NULL OR downtime_history_service.id IS NOT NULL` where.
+ */
+class DowntimeHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'downtime_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'downtime_id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'triggered_by_id',
+ 'parent_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'entry_time',
+ 'author',
+ 'cancelled_by',
+ 'comment',
+ 'is_flexible',
+ 'flexible_duration',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'start_time',
+ 'end_time',
+ 'has_been_cancelled',
+ 'trigger_time',
+ 'cancel_time'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'triggered_by_id' => t('Triggered By Downtime Id'),
+ 'parent_id' => t('Parent Downtime Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'entry_time' => t('Downtime Entry Time'),
+ 'author' => t('Downtime Author'),
+ 'cancelled_by' => t('Downtime Cancelled By'),
+ 'comment' => t('Downtime Comment'),
+ 'is_flexible' => t('Downtime Is Flexible'),
+ 'flexible_duration' => t('Downtime Flexible Duration'),
+ 'scheduled_start_time' => t('Downtime Scheduled Start'),
+ 'scheduled_end_time' => t('Downtime Scheduled End'),
+ 'start_time' => t('Downtime Actual Start'),
+ 'end_time' => t('Downtime Actual End'),
+ 'has_been_cancelled' => t('Downtime Has Been Cancelled'),
+ 'trigger_time' => t('Downtime Trigger Time'),
+ 'cancel_time' => t('Downtime Cancel Time')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_flexible',
+ 'has_been_cancelled'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'entry_time',
+ 'scheduled_start_time',
+ 'scheduled_end_time',
+ 'start_time',
+ 'end_time',
+ 'trigger_time',
+ 'cancel_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'downtime_id',
+ 'environment_id',
+ 'endpoint_id',
+ 'triggered_by_id',
+ 'parent_id',
+ 'host_id',
+ 'service_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('triggered_by', self::class)
+ ->setCandidateKey('triggered_by_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('parent', self::class)
+ ->setCandidateKey('parent_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('downtime_id')
+ ->setForeignKey('downtime_history_id');
+ $relations->belongsTo('host', Host::class)->setJoinType('LEFT');
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/Endpoint.php b/library/Icingadb/Model/Endpoint.php
new file mode 100644
index 0000000..257001b
--- /dev/null
+++ b/library/Icingadb/Model/Endpoint.php
@@ -0,0 +1,69 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Endpoint extends Model
+{
+ public function getTableName()
+ {
+ return 'endpoint';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Endpoint Name Checksum'),
+ 'properties_checksum' => t('Endpoint Properties Checksum'),
+ 'name' => t('Endpoint Name'),
+ 'name_ci' => t('Endpoint Name (CI)'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->hasMany('host', Host::class)
+ ->setForeignKey('command_endpoint_id');
+ $relations->hasMany('service', Service::class)
+ ->setForeignKey('command_endpoint_id');
+ }
+}
diff --git a/library/Icingadb/Model/Environment.php b/library/Icingadb/Model/Environment.php
new file mode 100644
index 0000000..919ca1c
--- /dev/null
+++ b/library/Icingadb/Model/Environment.php
@@ -0,0 +1,105 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Environment extends Model
+{
+ public function getTableName()
+ {
+ return 'environment';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'name'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'name' => t('Environment Name')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->hasMany('acknowledgement_history', AcknowledgementHistory::class);
+ $relations->hasMany('action_url', ActionUrl::class);
+ $relations->hasMany('checkcommand', Checkcommand::class);
+ $relations->hasMany('checkcommand_argument', CheckcommandArgument::class);
+ $relations->hasMany('checkcommand_customvar', CheckcommandCustomvar::class);
+ $relations->hasMany('checkcommand_envvar', CheckcommandEnvvar::class);
+ $relations->hasMany('comment', Comment::class);
+ $relations->hasMany('comment_history', CommentHistory::class);
+ $relations->hasMany('customvar', Customvar::class);
+ $relations->hasMany('customvar_flat', CustomvarFlat::class);
+ $relations->hasMany('downtime', Downtime::class);
+ $relations->hasMany('downtime_history', DowntimeHistory::class);
+ $relations->hasMany('endpoint', Endpoint::class);
+ $relations->hasMany('eventcommand', Eventcommand::class);
+ $relations->hasMany('eventcommand_argument', EventcommandArgument::class);
+ $relations->hasMany('eventcommand_customvar', EventcommandCustomvar::class);
+ $relations->hasMany('eventcommand_envvar', EventcommandEnvvar::class);
+ $relations->hasMany('flapping_history', FlappingHistory::class);
+ $relations->hasMany('history', History::class);
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('host_customvar', HostCustomvar::class);
+ $relations->hasMany('host_state', HostState::class);
+ $relations->hasMany('hostgroup', Hostgroup::class);
+ $relations->hasMany('hostgroup_customvar', HostgroupCustomvar::class);
+ $relations->hasMany('hostgroup_member', HostgroupMember::class);
+ $relations->hasMany('instance', Instance::class);
+ $relations->hasMany('icon_image', IconImage::class);
+ $relations->hasMany('notes_url', NotesUrl::class);
+ $relations->hasMany('notification', Notification::class);
+ $relations->hasMany('notification_customvar', NotificationCustomvar::class);
+ $relations->hasMany('notification_history', NotificationHistory::class);
+ //$relations->hasMany('notification_recipient', NotificationRecipient::class);
+ $relations->hasMany('notification_user', NotificationUser::class);
+ $relations->hasMany('notification_usergroup', NotificationUsergroup::class);
+ $relations->hasMany('notificationcommand', Notificationcommand::class);
+ $relations->hasMany('notificationcommand_argument', NotificationcommandArgument::class);
+ $relations->hasMany('notificationcommand_customvar', NotificationcommandCustomvar::class);
+ $relations->hasMany('notificationcommand_envvar', NotificationcommandEnvvar::class);
+ $relations->hasMany('service', Service::class);
+ $relations->hasMany('service_customvar', ServiceCustomvar::class);
+ $relations->hasMany('service_state', ServiceState::class);
+ $relations->hasMany('servicegroup', Servicegroup::class);
+ $relations->hasMany('servicegroup_customvar', ServicegroupCustomvar::class);
+ $relations->hasMany('servicegroup_member', ServicegroupMember::class);
+ $relations->hasMany('state_history', StateHistory::class);
+ $relations->hasMany('timeperiod', Timeperiod::class);
+ $relations->hasMany('timeperiod_customvar', TimeperiodCustomvar::class);
+ $relations->hasMany('timeperiod_override_exclude', TimeperiodOverrideExclude::class);
+ $relations->hasMany('timeperiod_override_include', TimeperiodOverrideInclude::class);
+ $relations->hasMany('timeperiod_range', TimeperiodRange::class);
+ $relations->hasMany('user', User::class);
+ $relations->hasMany('user_customvar', UserCustomvar::class);
+ //$relations->hasMany('user_notification_history', UserNotificationHistory::class);
+ $relations->hasMany('usergroup', Usergroup::class);
+ $relations->hasMany('usergroup_customvar', UsergroupCustomvar::class);
+ $relations->hasMany('usergroup_member', UsergroupMember::class);
+ $relations->hasMany('zone', Zone::class);
+ }
+}
diff --git a/library/Icingadb/Model/Eventcommand.php b/library/Icingadb/Model/Eventcommand.php
new file mode 100644
index 0000000..ad18e22
--- /dev/null
+++ b/library/Icingadb/Model/Eventcommand.php
@@ -0,0 +1,86 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Eventcommand extends Model
+{
+ public function getTableName()
+ {
+ return 'eventcommand';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'command',
+ 'timeout'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'zone_id' => t('Zone Id'),
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Eventcommand Name Checksum'),
+ 'properties_checksum' => t('Eventcommand Properties Checksum'),
+ 'name' => t('Eventcommand Name'),
+ 'name_ci' => t('Eventcommand Name (CI)'),
+ 'command' => t('Eventcommand'),
+ 'timeout' => t('Eventcommand Timeout')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(EventcommandCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(EventcommandCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(EventcommandCustomvar::class);
+
+ $relations->hasMany('argument', EventcommandArgument::class);
+ $relations->hasMany('envvar', EventcommandEnvvar::class);
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('service', Service::class);
+ }
+}
diff --git a/library/Icingadb/Model/EventcommandArgument.php b/library/Icingadb/Model/EventcommandArgument.php
new file mode 100644
index 0000000..485e5d3
--- /dev/null
+++ b/library/Icingadb/Model/EventcommandArgument.php
@@ -0,0 +1,75 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class EventcommandArgument extends Model
+{
+ public function getTableName()
+ {
+ return 'eventcommand_argument';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'eventcommand_id',
+ 'argument_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'argument_value',
+ 'argument_order',
+ 'description',
+ 'argument_key_override',
+ 'repeat_key',
+ 'required',
+ 'set_if',
+ 'skip_key'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'eventcommand_id' => t('Eventcommand Id'),
+ 'argument_key' => t('Eventcommand Argument Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Eventcommand Argument Properties Checksum'),
+ 'argument_value' => t('Eventcommand Argument Value'),
+ 'argument_order' => t('Eventcommand Argument Position'),
+ 'description' => t('Eventcommand Argument Description'),
+ 'argument_key_override' => t('Eventcommand Argument Actual Name'),
+ 'repeat_key' => t('Eventcommand Argument Repeated'),
+ 'required' => t('Eventcommand Argument Required'),
+ 'set_if' => t('Eventcommand Argument Condition'),
+ 'skip_key' => t('Eventcommand Argument Without Name')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'eventcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/EventcommandCustomvar.php b/library/Icingadb/Model/EventcommandCustomvar.php
new file mode 100644
index 0000000..3d1fa48
--- /dev/null
+++ b/library/Icingadb/Model/EventcommandCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class EventcommandCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'eventcommand_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'eventcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'eventcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/EventcommandEnvvar.php b/library/Icingadb/Model/EventcommandEnvvar.php
new file mode 100644
index 0000000..3883bef
--- /dev/null
+++ b/library/Icingadb/Model/EventcommandEnvvar.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class EventcommandEnvvar extends Model
+{
+ public function getTableName()
+ {
+ return 'eventcommand_envvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'eventcommand_id',
+ 'envvar_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'envvar_value'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'eventcommand_id' => t('Eventcommand Id'),
+ 'envvar_key' => t('Eventcommand Envvar Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Eventcommand Envvar Properties Checksum'),
+ 'envvar_value' => t('Eventcommand Envvar Value')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'eventcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/FlappingHistory.php b/library/Icingadb/Model/FlappingHistory.php
new file mode 100644
index 0000000..69711eb
--- /dev/null
+++ b/library/Icingadb/Model/FlappingHistory.php
@@ -0,0 +1,91 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `flapping_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this,
+ * the query needs a `flapping_history.service_id IS NULL OR flapping_history_service.id IS NOT NULL` where.
+ */
+class FlappingHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'flapping_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'start_time',
+ 'end_time',
+ 'percent_state_change_start',
+ 'percent_state_change_end',
+ 'flapping_threshold_low',
+ 'flapping_threshold_high'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'start_time' => t('Flapping Start Time'),
+ 'end_time' => t('Flapping End Time'),
+ 'percent_state_change_start' => t('Flapping Percent State Change Start'),
+ 'percent_state_change_end' => t('Flapping Percent State Change End'),
+ 'flapping_threshold_low' => t('Flapping Threshold Low'),
+ 'flapping_threshold_high' => t('Flapping Threshold High')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'start_time',
+ 'end_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('flapping_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/History.php b/library/Icingadb/Model/History.php
new file mode 100644
index 0000000..a34b3bd
--- /dev/null
+++ b/library/Icingadb/Model/History.php
@@ -0,0 +1,127 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid
+ * this, the query needs a `history.service_id IS NULL OR history_service.id IS NOT NULL` where.
+ */
+class History extends Model
+{
+ public function getTableName()
+ {
+ return 'history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'comment_history_id',
+ 'downtime_history_id',
+ 'flapping_history_id',
+ 'notification_history_id',
+ 'acknowledgement_history_id',
+ 'state_history_id',
+ 'event_type',
+ 'event_time'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'event_type' => t('Event Type'),
+ 'event_time' => t('Event Time')
+ ];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'history.event_time desc, history.event_type desc';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'event_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id',
+ 'comment_history_id',
+ 'downtime_history_id',
+ 'flapping_history_id',
+ 'notification_history_id',
+ 'acknowledgement_history_id',
+ 'state_history_id'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+
+ $relations->hasOne('comment', CommentHistory::class)
+ ->setCandidateKey('comment_history_id')
+ ->setForeignKey('comment_id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('downtime', DowntimeHistory::class)
+ ->setCandidateKey('downtime_history_id')
+ ->setForeignKey('downtime_id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('flapping', FlappingHistory::class)
+ ->setCandidateKey('flapping_history_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('notification', NotificationHistory::class)
+ ->setCandidateKey('notification_history_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('acknowledgement', AcknowledgementHistory::class)
+ ->setCandidateKey('acknowledgement_history_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ $relations->hasOne('state', StateHistory::class)
+ ->setCandidateKey('state_history_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/Host.php b/library/Icingadb/Model/Host.php
new file mode 100644
index 0000000..e1296c8
--- /dev/null
+++ b/library/Icingadb/Model/Host.php
@@ -0,0 +1,234 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Defaults;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+use ipl\Orm\ResultSet;
+
+/**
+ * Host model.
+ */
+class Host extends Model
+{
+ use Auth;
+
+ public function getTableName()
+ {
+ return 'host';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'address',
+ 'address6',
+ 'address_bin',
+ 'address6_bin',
+ 'checkcommand_name',
+ 'checkcommand_id',
+ 'max_check_attempts',
+ 'check_timeperiod_name',
+ 'check_timeperiod_id',
+ 'check_timeout',
+ 'check_interval',
+ 'check_retry_interval',
+ 'active_checks_enabled',
+ 'passive_checks_enabled',
+ 'event_handler_enabled',
+ 'notifications_enabled',
+ 'flapping_enabled',
+ 'flapping_threshold_low',
+ 'flapping_threshold_high',
+ 'perfdata_enabled',
+ 'eventcommand_name',
+ 'eventcommand_id',
+ 'is_volatile',
+ 'action_url_id',
+ 'notes_url_id',
+ 'notes',
+ 'icon_image_id',
+ 'icon_image_alt',
+ 'zone_name',
+ 'zone_id',
+ 'command_endpoint_name',
+ 'command_endpoint_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Host Name Checksum'),
+ 'properties_checksum' => t('Host Properties Checksum'),
+ 'name' => t('Host Name'),
+ 'name_ci' => t('Host Name (CI)'),
+ 'display_name' => t('Host Display Name'),
+ 'address' => t('Host Address (IPv4)'),
+ 'address6' => t('Host Address (IPv6)'),
+ 'address_bin' => t('Host Address (IPv4, Binary)'),
+ 'address6_bin' => t('Host Address (IPv6, Binary)'),
+ 'checkcommand_name' => t('Checkcommand Name'),
+ 'checkcommand_id' => t('Checkcommand Id'),
+ 'max_check_attempts' => t('Host Max Check Attempts'),
+ 'check_timeperiod_name' => t('Check Timeperiod Name'),
+ 'check_timeperiod_id' => t('Check Timeperiod Id'),
+ 'check_timeout' => t('Host Check Timeout'),
+ 'check_interval' => t('Host Check Interval'),
+ 'check_retry_interval' => t('Host Check Retry Inverval'),
+ 'active_checks_enabled' => t('Host Active Checks Enabled'),
+ 'passive_checks_enabled' => t('Host Passive Checks Enabled'),
+ 'event_handler_enabled' => t('Host Event Handler Enabled'),
+ 'notifications_enabled' => t('Host Notifications Enabled'),
+ 'flapping_enabled' => t('Host Flapping Enabled'),
+ 'flapping_threshold_low' => t('Host Flapping Threshold Low'),
+ 'flapping_threshold_high' => t('Host Flapping Threshold High'),
+ 'perfdata_enabled' => t('Host Performance Data Enabled'),
+ 'eventcommand_name' => t('Eventcommand Name'),
+ 'eventcommand_id' => t('Eventcommand Id'),
+ 'is_volatile' => t('Host Is Volatile'),
+ 'action_url_id' => t('Action Url Id'),
+ 'notes_url_id' => t('Notes Url Id'),
+ 'notes' => t('Host Notes'),
+ 'icon_image_id' => t('Icon Image Id'),
+ 'icon_image_alt' => t('Icon Image Alt'),
+ 'zone_name' => t('Zone Name'),
+ 'zone_id' => t('Zone Id'),
+ 'command_endpoint_name' => t('Endpoint Name'),
+ 'command_endpoint_id' => t('Endpoint Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'host.display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'active_checks_enabled',
+ 'passive_checks_enabled',
+ 'event_handler_enabled',
+ 'notifications_enabled',
+ 'flapping_enabled',
+ 'is_volatile'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'servicegroup' => 'service.servicegroup',
+ 'user' => 'notification.user',
+ 'usergroup' => 'notification.usergroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'address_bin',
+ 'address6_bin',
+ 'checkcommand_id',
+ 'check_timeperiod_id',
+ 'eventcommand_id',
+ 'action_url_id',
+ 'notes_url_id',
+ 'icon_image_id',
+ 'zone_id',
+ 'command_endpoint_id'
+ ]));
+ }
+
+ public function createDefaults(Defaults $defaults)
+ {
+ $defaults->add('vars', function (self $subject) {
+ if (! $subject->customvar_flat instanceof ResultSet) {
+ $this->applyRestrictions($subject->customvar_flat);
+ }
+
+ $vars = [];
+ foreach ($subject->customvar_flat as $customVar) {
+ $vars[$customVar->flatname] = $customVar->flatvalue;
+ }
+
+ return $vars;
+ });
+
+ $defaults->add('customvars', function (self $subject) {
+ if (! $subject->customvar instanceof ResultSet) {
+ $this->applyRestrictions($subject->customvar);
+ }
+
+ $vars = [];
+ foreach ($subject->customvar as $customVar) {
+ $vars[$customVar->name] = json_decode($customVar->value, true);
+ }
+
+ return $vars;
+ });
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class)
+ ->setCandidateKey('check_timeperiod_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('action_url', ActionUrl::class)
+ ->setCandidateKey('action_url_id')
+ ->setForeignKey('id');
+ $relations->belongsTo('notes_url', NotesUrl::class)
+ ->setCandidateKey('notes_url_id')
+ ->setForeignKey('id');
+ $relations->belongsTo('icon_image', IconImage::class)
+ ->setCandidateKey('icon_image_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('zone', Zone::class);
+ $relations->belongsTo('endpoint', Endpoint::class)
+ ->setCandidateKey('command_endpoint_id');
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(HostCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(HostCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(HostCustomvar::class);
+ $relations->belongsToMany('hostgroup', Hostgroup::class)
+ ->through(HostgroupMember::class);
+
+ $relations->hasOne('state', HostState::class)->setJoinType('LEFT');
+ $relations->hasMany('comment', Comment::class)->setJoinType('LEFT');
+ $relations->hasMany('downtime', Downtime::class)->setJoinType('LEFT');
+ $relations->hasMany('history', History::class);
+ $relations->hasMany('notification', Notification::class)->setJoinType('LEFT');
+ $relations->hasMany('notification_history', NotificationHistory::class);
+ $relations->hasMany('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/HostCustomvar.php b/library/Icingadb/Model/HostCustomvar.php
new file mode 100644
index 0000000..9f7df26
--- /dev/null
+++ b/library/Icingadb/Model/HostCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class HostCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'host_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'host_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'host_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setForeignKey('customvar_id')
+ ->setCandidateKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/HostState.php b/library/Icingadb/Model/HostState.php
new file mode 100644
index 0000000..efa2752
--- /dev/null
+++ b/library/Icingadb/Model/HostState.php
@@ -0,0 +1,81 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Common\HostStates;
+use ipl\Orm\Relations;
+
+/**
+ * Host state model.
+ */
+class HostState extends State
+{
+ public function getTableName()
+ {
+ return 'host_state';
+ }
+
+ public function getKeyName()
+ {
+ return 'host_id';
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'state_type' => t('Host State Type'),
+ 'soft_state' => t('Host Soft State'),
+ 'hard_state' => t('Host Hard State'),
+ 'previous_soft_state' => t('Host Previous Soft State'),
+ 'previous_hard_state' => t('Host Previous Hard State'),
+ 'check_attempt' => t('Host Check Attempt No.'),
+ 'severity' => t('Host State Severity'),
+ 'output' => t('Host Output'),
+ 'long_output' => t('Host Long Output'),
+ 'performance_data' => t('Host Performance Data'),
+ 'normalized_performance_data' => t('Host Normalized Performance Data'),
+ 'check_commandline' => t('Host Check Commandline'),
+ 'is_problem' => t('Host Has Problem'),
+ 'is_handled' => t('Host Is Handled'),
+ 'is_reachable' => t('Host Is Reachable'),
+ 'is_flapping' => t('Host Is Flapping'),
+ 'is_overdue' => t('Host Check Is Overdue'),
+ 'is_acknowledged' => t('Host Is Acknowledged'),
+ 'acknowledgement_comment_id' => t('Acknowledgement Comment Id'),
+ 'in_downtime' => t('Host In Downtime'),
+ 'execution_time' => t('Host Check Execution Time'),
+ 'latency' => t('Host Check Latency'),
+ 'check_timeout' => t('Host Check Timeout'),
+ 'check_source' => t('Host Check Source'),
+ 'last_update' => t('Host Last Update'),
+ 'last_state_change' => t('Host Last State Change'),
+ 'next_check' => t('Host Next Check'),
+ 'next_update' => t('Host Next Update')
+ ];
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class);
+ $relations->hasOne('last_comment', LastHostComment::class)
+ ->setCandidateKey('last_comment_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ }
+
+
+ public function getStateText(): string
+ {
+ return HostStates::text($this->soft_state);
+ }
+
+
+ public function getStateTextTranslated(): string
+ {
+ return HostStates::text($this->soft_state);
+ }
+}
diff --git a/library/Icingadb/Model/Hostgroup.php b/library/Icingadb/Model/Hostgroup.php
new file mode 100644
index 0000000..97930fa
--- /dev/null
+++ b/library/Icingadb/Model/Hostgroup.php
@@ -0,0 +1,92 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Hostgroup extends Model
+{
+ public function getTableName()
+ {
+ return 'hostgroup';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Hostgroup Name Checksum'),
+ 'properties_checksum' => t('Hostgroup Properties Checksum'),
+ 'name' => t('Hostgroup Name'),
+ 'name_ci' => t('Hostgroup Name (CI)'),
+ 'display_name' => t('Hostgroup Display Name'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(HostgroupCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(HostgroupCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(HostgroupCustomvar::class);
+ $relations->belongsToMany('host', Host::class)
+ ->through(HostgroupMember::class);
+ $relations->belongsToMany('service', Service::class)
+ ->through(HostgroupMember::class);
+ }
+}
diff --git a/library/Icingadb/Model/HostgroupCustomvar.php b/library/Icingadb/Model/HostgroupCustomvar.php
new file mode 100644
index 0000000..41272d1
--- /dev/null
+++ b/library/Icingadb/Model/HostgroupCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class HostgroupCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'hostgroup_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'hostgroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'hostgroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('hostgroup', Hostgroup::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/HostgroupMember.php b/library/Icingadb/Model/HostgroupMember.php
new file mode 100644
index 0000000..3660e71
--- /dev/null
+++ b/library/Icingadb/Model/HostgroupMember.php
@@ -0,0 +1,53 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class HostgroupMember extends Model
+{
+ public function getTableName()
+ {
+ return 'hostgroup_member';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'host_id',
+ 'hostgroup_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'host_id',
+ 'hostgroup_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('hostgroup', Hostgroup::class);
+ $relations->belongsTo('host', Host::class);
+
+ $relations->hasMany('service', Service::class)
+ ->setForeignKey('host_id')
+ ->setCandidateKey('host_id');
+ }
+}
diff --git a/library/Icingadb/Model/Hostgroupsummary.php b/library/Icingadb/Model/Hostgroupsummary.php
new file mode 100644
index 0000000..a9295bb
--- /dev/null
+++ b/library/Icingadb/Model/Hostgroupsummary.php
@@ -0,0 +1,213 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Relations;
+use ipl\Orm\UnionModel;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+
+class Hostgroupsummary extends UnionModel
+{
+ public static function on(Connection $db)
+ {
+ $q = parent::on($db);
+
+ $q->on($q::ON_SELECT_ASSEMBLED, function (Select $select) use ($q) {
+ $model = $q->getModel();
+
+ $groupBy = $q->getResolver()->qualifyColumnsAndAliases((array) $model->getKeyName(), $model, false);
+
+ // For PostgreSQL, ALL non-aggregate SELECT columns must appear in the GROUP BY clause:
+ if ($q->getDb()->getAdapter() instanceof Pgsql) {
+ /**
+ * Ignore Expressions, i.e. aggregate functions {@see getColumns()},
+ * which do not need to be added to the GROUP BY.
+ */
+ $candidates = array_filter($select->getColumns(), 'is_string');
+ // Remove already considered columns for the GROUP BY, i.e. the primary key.
+ $candidates = array_diff_assoc($candidates, $groupBy);
+ $groupBy = array_merge($groupBy, $candidates);
+ }
+
+ $select->groupBy($groupBy);
+ });
+
+ return $q;
+ }
+
+ public function getTableName()
+ {
+ return 'hostgroup';
+ }
+
+ public function getKeyName()
+ {
+ return ['id' => 'hostgroup_id'];
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'display_name' => 'hostgroup_display_name',
+ 'hosts_down_handled' => new Expression(
+ 'SUM(CASE WHEN host_state = 1'
+ . ' AND (host_handled = \'y\' OR host_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_down_unhandled' => new Expression(
+ 'SUM(CASE WHEN host_state = 1'
+ . ' AND host_handled = \'n\' AND host_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_pending' => new Expression(
+ 'SUM(CASE WHEN host_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_total' => new Expression(
+ 'SUM(CASE WHEN host_id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_up' => new Expression(
+ 'SUM(CASE WHEN host_state = 0 THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_severity' => new Expression('MAX(host_severity)'),
+ 'name' => 'hostgroup_name',
+ 'services_critical_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_ok' => new Expression(
+ 'SUM(CASE WHEN service_state = 0 THEN 1 ELSE 0 END)'
+ ),
+ 'services_pending' => new Expression(
+ 'SUM(CASE WHEN service_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'services_total' => new Expression(
+ 'SUM(CASE WHEN service_id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ )
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'display_name';
+ }
+
+ public function getUnions()
+ {
+ $unions = [
+ [
+ Host::class,
+ [
+ 'hostgroup',
+ 'state'
+ ],
+ [
+ 'hostgroup_id' => 'hostgroup.id',
+ 'hostgroup_name' => 'hostgroup.name',
+ 'hostgroup_display_name' => 'hostgroup.display_name',
+ 'host_id' => 'host.id',
+ 'host_state' => 'state.soft_state',
+ 'host_handled' => 'state.is_handled',
+ 'host_reachable' => 'state.is_reachable',
+ 'host_severity' => 'state.severity',
+ 'service_id' => new Expression('NULL'),
+ 'service_state' => new Expression('NULL'),
+ 'service_handled' => new Expression('NULL'),
+ 'service_reachable' => new Expression('NULL')
+ ]
+ ],
+ [
+ Service::class,
+ [
+ 'hostgroup',
+ 'state'
+ ],
+ [
+ 'hostgroup_id' => 'hostgroup.id',
+ 'hostgroup_name' => 'hostgroup.name',
+ 'hostgroup_display_name' => 'hostgroup.display_name',
+ 'host_id' => new Expression('NULL'),
+ 'host_state' => new Expression('NULL'),
+ 'host_handled' => new Expression('NULL'),
+ 'host_reachable' => new Expression('NULL'),
+ 'host_severity' => new Expression('0'),
+ 'service_id' => 'service.id',
+ 'service_state' => 'state.soft_state',
+ 'service_handled' => 'state.is_handled',
+ 'service_reachable' => 'state.is_reachable'
+ ]
+ ],
+ [
+ Hostgroup::class,
+ [],
+ [
+ 'hostgroup_id' => 'hostgroup.id',
+ 'hostgroup_name' => 'hostgroup.name',
+ 'hostgroup_display_name' => 'hostgroup.display_name',
+ 'host_id' => new Expression('NULL'),
+ 'host_state' => new Expression('NULL'),
+ 'host_handled' => new Expression('NULL'),
+ 'host_reachable' => new Expression('NULL'),
+ 'host_severity' => new Expression('0'),
+ 'service_id' => new Expression('NULL'),
+ 'service_state' => new Expression('NULL'),
+ 'service_handled' => new Expression('NULL'),
+ 'service_reachable' => new Expression('NULL')
+ ]
+ ]
+ ];
+
+ return $unions;
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id'
+ ]));
+
+ // This is because there is no better way
+ (new Hostgroup())->createBehaviors($behaviors);
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ // This is because there is no better way
+ (new Hostgroup())->createRelations($relations);
+ }
+
+ public function getColumnDefinitions()
+ {
+ // This is because there is no better way
+ return (new Hostgroup())->getColumnDefinitions();
+ }
+}
diff --git a/library/Icingadb/Model/HoststateSummary.php b/library/Icingadb/Model/HoststateSummary.php
new file mode 100644
index 0000000..93268f3
--- /dev/null
+++ b/library/Icingadb/Model/HoststateSummary.php
@@ -0,0 +1,78 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+
+class HoststateSummary extends Host
+{
+ public function getSummaryColumns()
+ {
+ return [
+ 'hosts_acknowledged' => new Expression(
+ 'SUM(CASE WHEN host_state.is_acknowledged = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_active_checks_enabled' => new Expression(
+ 'SUM(CASE WHEN host.active_checks_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_passive_checks_enabled' => new Expression(
+ 'SUM(CASE WHEN host.passive_checks_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_down_handled' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 1'
+ . ' AND (host_state.is_handled = \'y\' OR host_state.is_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_down_unhandled' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 1'
+ . ' AND host_state.is_handled = \'n\' AND host_state.is_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_event_handler_enabled' => new Expression(
+ 'SUM(CASE WHEN host.event_handler_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_flapping_enabled' => new Expression(
+ 'SUM(CASE WHEN host.flapping_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_notifications_enabled' => new Expression(
+ 'SUM(CASE WHEN host.notifications_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_pending' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_problems_unacknowledged' => new Expression(
+ 'SUM(CASE WHEN host_state.is_problem = \'y\''
+ . ' AND host_state.is_acknowledged = \'n\' THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_total' => new Expression(
+ 'SUM(CASE WHEN host.id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'hosts_up' => new Expression(
+ 'SUM(CASE WHEN host_state.soft_state = 0 THEN 1 ELSE 0 END)'
+ )
+ ];
+ }
+
+ public static function on(Connection $db)
+ {
+ $q = parent::on($db);
+ $q->utilize('state');
+
+ /** @var static $m */
+ $m = $q->getModel();
+ $q->columns($m->getSummaryColumns());
+
+ return $q;
+ }
+
+ public function getColumns()
+ {
+ return array_merge(parent::getColumns(), $this->getSummaryColumns());
+ }
+
+ public function getDefaultSort()
+ {
+ return null;
+ }
+}
diff --git a/library/Icingadb/Model/IconImage.php b/library/Icingadb/Model/IconImage.php
new file mode 100644
index 0000000..212234a
--- /dev/null
+++ b/library/Icingadb/Model/IconImage.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class IconImage extends Model
+{
+ public function getTableName()
+ {
+ return 'icon_image';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'icon_image',
+ 'environment_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'icon_image' => t('Icon Image'),
+ 'environment_id' => t('Environment Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('service', Service::class);
+ }
+}
diff --git a/library/Icingadb/Model/Instance.php b/library/Icingadb/Model/Instance.php
new file mode 100644
index 0000000..73db565
--- /dev/null
+++ b/library/Icingadb/Model/Instance.php
@@ -0,0 +1,78 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Instance extends Model
+{
+ public function getTableName()
+ {
+ return 'icingadb_instance';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'heartbeat',
+ 'responsible',
+ 'icinga2_active_host_checks_enabled',
+ 'icinga2_active_service_checks_enabled',
+ 'icinga2_event_handlers_enabled',
+ 'icinga2_flap_detection_enabled',
+ 'icinga2_notifications_enabled',
+ 'icinga2_performance_data_enabled',
+ 'icinga2_start_time',
+ 'icinga2_version'
+ ];
+ }
+
+ public function getDefaultSort()
+ {
+ return ['responsible desc', 'heartbeat desc'];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'heartbeat',
+ 'icinga2_start_time'
+ ]));
+
+ $behaviors->add(new BoolCast([
+ 'responsible',
+ 'icinga2_active_host_checks_enabled',
+ 'icinga2_active_service_checks_enabled',
+ 'icinga2_event_handlers_enabled',
+ 'icinga2_flap_detection_enabled',
+ 'icinga2_notifications_enabled',
+ 'icinga2_performance_data_enabled'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('endpoint', Endpoint::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/LastHostComment.php b/library/Icingadb/Model/LastHostComment.php
new file mode 100644
index 0000000..621b204
--- /dev/null
+++ b/library/Icingadb/Model/LastHostComment.php
@@ -0,0 +1,19 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Relations;
+
+class LastHostComment extends Comment
+{
+ public function createRelations(Relations $relations): void
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+ $relations->belongsTo('state', HostState::class)
+ ->setForeignKey('last_comment_id')
+ ->setCandidateKey('id');
+ }
+}
diff --git a/library/Icingadb/Model/LastServiceComment.php b/library/Icingadb/Model/LastServiceComment.php
new file mode 100644
index 0000000..4d44f11
--- /dev/null
+++ b/library/Icingadb/Model/LastServiceComment.php
@@ -0,0 +1,19 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Relations;
+
+class LastServiceComment extends Comment
+{
+ public function createRelations(Relations $relations): void
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+ $relations->belongsTo('state', ServiceState::class)
+ ->setForeignKey('last_comment_id')
+ ->setCandidateKey('id');
+ }
+}
diff --git a/library/Icingadb/Model/NotesUrl.php b/library/Icingadb/Model/NotesUrl.php
new file mode 100644
index 0000000..5865c52
--- /dev/null
+++ b/library/Icingadb/Model/NotesUrl.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ActionAndNoteUrl;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotesUrl extends Model
+{
+ public function getTableName()
+ {
+ return 'notes_url';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notes_url',
+ 'environment_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'notes_url' => t('Notes Url'),
+ 'environment_id' => t('Environment Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ActionAndNoteUrl(['notes_url']));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->hasMany('host', Host::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('notes_url_id');
+ $relations->hasMany('service', Service::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('notes_url_id');
+ }
+}
diff --git a/library/Icingadb/Model/Notification.php b/library/Icingadb/Model/Notification.php
new file mode 100644
index 0000000..8d42301
--- /dev/null
+++ b/library/Icingadb/Model/Notification.php
@@ -0,0 +1,130 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\Bitmask;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Notification extends Model
+{
+ public function getTableName()
+ {
+ return 'notification';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'host_id',
+ 'service_id',
+ 'notificationcommand_id',
+ 'times_begin',
+ 'times_end',
+ 'notification_interval',
+ 'timeperiod_id',
+ 'states',
+ 'types',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Notification Name Checksum'),
+ 'properties_checksum' => t('Notification Properties Checksum'),
+ 'name' => t('Notification Name'),
+ 'name_ci' => t('Notification Name (CI)'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'notificationcommand_id' => t('Notificationcommand Id'),
+ 'times_begin' => t('Notification Escalate After'),
+ 'times_end' => t('Notification Escalate Until'),
+ 'notification_interval' => t('Notification Interval'),
+ 'timeperiod_id' => t('Timeperiod Id'),
+ 'states' => t('Notification State Filter'),
+ 'types' => t('Notification Type Filter'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Bitmask([
+ 'states' => [
+ 'ok' => 1,
+ 'warning' => 2,
+ 'critical' => 4,
+ 'unknown' => 8,
+ 'up' => 16,
+ 'down' => 32
+ ],
+ 'types' => [
+ 'downtime_start' => 1,
+ 'downtime_end' => 2,
+ 'downtime_removed' => 4,
+ 'custom' => 8,
+ 'ack' => 16,
+ 'problem' => 32,
+ 'recovery' => 64,
+ 'flapping_start' => 128,
+ 'flapping_end' => 256
+ ]
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'host_id',
+ 'service_id',
+ 'notificationcommand_id',
+ 'timeperiod_id',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class);
+ $relations->belongsTo('notificationcommand', Notificationcommand::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(NotificationCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(NotificationCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(NotificationCustomvar::class);
+ $relations->belongsToMany('user', User::class)
+ ->through('notification_recipient');
+ $relations->belongsToMany('usergroup', Usergroup::class)
+ ->through('notification_recipient');
+ }
+}
diff --git a/library/Icingadb/Model/NotificationCustomvar.php b/library/Icingadb/Model/NotificationCustomvar.php
new file mode 100644
index 0000000..620ae5c
--- /dev/null
+++ b/library/Icingadb/Model/NotificationCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'notification_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notification_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notification_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notification', Notification::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/NotificationHistory.php b/library/Icingadb/Model/NotificationHistory.php
new file mode 100644
index 0000000..c635dbb
--- /dev/null
+++ b/library/Icingadb/Model/NotificationHistory.php
@@ -0,0 +1,115 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `notification_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this, the
+ * query needs a `notification_history.service_id IS NULL OR notification_history_service.id IS NOT NULL` where.
+ */
+class NotificationHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'notification_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'notification_id',
+ 'type',
+ 'send_time',
+ 'state',
+ 'previous_hard_state',
+ 'author',
+ 'text',
+ 'users_notified'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'id' => t('History Id'),
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'notification_id' => t('Notification Id'),
+ 'type' => t('Notification Type'),
+ 'send_time' => t('Notification Sent On'),
+ 'state' => t('Hard State'),
+ 'previous_hard_state' => t('Previous Hard State'),
+ 'author' => t('Notification Author'),
+ 'text' => t('Notification Text'),
+ 'users_notified' => t('Users Notified')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['text'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'notification_history.send_time desc';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'send_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id',
+ 'notification_id'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('notification_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ $relations->belongsTo('notification', Notification::class)->setJoinType('LEFT');
+
+ $relations->belongsToMany('user', User::class)
+ ->through('user_notification_history');
+ }
+}
diff --git a/library/Icingadb/Model/NotificationUser.php b/library/Icingadb/Model/NotificationUser.php
new file mode 100644
index 0000000..ab23ad4
--- /dev/null
+++ b/library/Icingadb/Model/NotificationUser.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationUser extends Model
+{
+ public function getTableName()
+ {
+ return 'notification_user';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notification_id',
+ 'user_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notification_id',
+ 'user_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notification', Notification::class);
+ $relations->belongsTo('user', User::class);
+ }
+}
diff --git a/library/Icingadb/Model/NotificationUsergroup.php b/library/Icingadb/Model/NotificationUsergroup.php
new file mode 100644
index 0000000..bd60fae
--- /dev/null
+++ b/library/Icingadb/Model/NotificationUsergroup.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationUsergroup extends Model
+{
+ public function getTableName()
+ {
+ return 'notification_usergroup';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notification_id',
+ 'usergroup_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notification_id',
+ 'usergroup_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notification', Notification::class);
+ $relations->belongsTo('usergroup', Usergroup::class);
+ }
+}
diff --git a/library/Icingadb/Model/Notificationcommand.php b/library/Icingadb/Model/Notificationcommand.php
new file mode 100644
index 0000000..6ee2a21
--- /dev/null
+++ b/library/Icingadb/Model/Notificationcommand.php
@@ -0,0 +1,87 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Notificationcommand extends Model
+{
+ public function getTableName()
+ {
+ return 'notificationcommand';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'command',
+ 'timeout'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'zone_id' => t('Zone Id'),
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Notificationcommand Name Checksum'),
+ 'properties_checksum' => t('Notificationcommand Properties Checksum'),
+ 'name' => t('Notificationcommand Name'),
+ 'name_ci' => t('Notificationcommand Name (CI)'),
+ 'command' => t('Notificationcommand'),
+ 'timeout' => t('Notificationcommand Timeout')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'host' => 'notification.host',
+ 'hostgroup' => 'notification.host.hostgroup',
+ 'service' => 'notification.service',
+ 'servicegroup' => 'notification.service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'zone_id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(NotificationcommandCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(NotificationcommandCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(NotificationcommandCustomvar::class);
+
+ $relations->hasMany('notification', Notification::class);
+ $relations->hasMany('argument', NotificationcommandArgument::class);
+ $relations->hasMany('envvar', NotificationcommandEnvvar::class);
+ }
+}
diff --git a/library/Icingadb/Model/NotificationcommandArgument.php b/library/Icingadb/Model/NotificationcommandArgument.php
new file mode 100644
index 0000000..e855022
--- /dev/null
+++ b/library/Icingadb/Model/NotificationcommandArgument.php
@@ -0,0 +1,75 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationcommandArgument extends Model
+{
+ public function getTableName()
+ {
+ return 'notificationcommand_argument';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notificationcommand_id',
+ 'argument_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'argument_value',
+ 'argument_order',
+ 'description',
+ 'argument_key_override',
+ 'repeat_key',
+ 'required',
+ 'set_if',
+ 'skip_key'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'notificationcommand_id' => t('Notificationcommand Id'),
+ 'argument_key' => t('Notificationcommand Argument Name'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Notificationcommand Argument Properties Checksum'),
+ 'argument_value' => t('Notificationcommand Argument Value'),
+ 'argument_order' => t('Notificationcommand Argument Position'),
+ 'description' => t('Notificationcommand Argument Description'),
+ 'argument_key_override' => t('Notificationcommand Argument Actual Name'),
+ 'repeat_key' => t('Notificationcommand Argument Repeated'),
+ 'required' => t('Notificationcommand Argument Required'),
+ 'set_if' => t('Notificationcommand Argument Condition'),
+ 'skip_key' => t('Notificationcommand Argument Without Name')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notificationcommand_id',
+ 'environment_id',
+ 'properties_checksum'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notificationcommand', Notificationcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/NotificationcommandCustomvar.php b/library/Icingadb/Model/NotificationcommandCustomvar.php
new file mode 100644
index 0000000..bd103f7
--- /dev/null
+++ b/library/Icingadb/Model/NotificationcommandCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationcommandCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'notificationcommand_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notificationcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notificationcommand_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notificationcommand', Notificationcommand::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/NotificationcommandEnvvar.php b/library/Icingadb/Model/NotificationcommandEnvvar.php
new file mode 100644
index 0000000..09f77b0
--- /dev/null
+++ b/library/Icingadb/Model/NotificationcommandEnvvar.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class NotificationcommandEnvvar extends Model
+{
+ public function getTableName()
+ {
+ return 'notificationcommand_envvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'notificationcommand_id',
+ 'envvar_key',
+ 'environment_id',
+ 'properties_checksum',
+ 'envvar_value'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'notificationcommand_id' => t('Notificationcommand Id'),
+ 'envvar_key' => t('Notificationcommand Envvar Key'),
+ 'environment_id' => t('Environment Id'),
+ 'properties_checksum' => t('Notificationcommand Envvar Properties Checksum'),
+ 'envvar_value' => t('Notificationcommand Envvar Value')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'notificationcommand_id',
+ 'environment_id',
+ 'properties_checksum',
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('notificationcommand', Notificationcommand::class);
+ }
+}
diff --git a/library/Icingadb/Model/Service.php b/library/Icingadb/Model/Service.php
new file mode 100644
index 0000000..c57b6ba
--- /dev/null
+++ b/library/Icingadb/Model/Service.php
@@ -0,0 +1,225 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Defaults;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+use ipl\Orm\ResultSet;
+
+class Service extends Model
+{
+ use Auth;
+
+ public function getTableName()
+ {
+ return 'service';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'host_id',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'checkcommand_name',
+ 'checkcommand_id',
+ 'max_check_attempts',
+ 'check_timeperiod_name',
+ 'check_timeperiod_id',
+ 'check_timeout',
+ 'check_interval',
+ 'check_retry_interval',
+ 'active_checks_enabled',
+ 'passive_checks_enabled',
+ 'event_handler_enabled',
+ 'notifications_enabled',
+ 'flapping_enabled',
+ 'flapping_threshold_low',
+ 'flapping_threshold_high',
+ 'perfdata_enabled',
+ 'eventcommand_name',
+ 'eventcommand_id',
+ 'is_volatile',
+ 'action_url_id',
+ 'notes_url_id',
+ 'notes',
+ 'icon_image_id',
+ 'icon_image_alt',
+ 'zone_name',
+ 'zone_id',
+ 'command_endpoint_name',
+ 'command_endpoint_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Service Name Checksum'),
+ 'properties_checksum' => t('Service Properties Checksum'),
+ 'host_id' => t('Host Id'),
+ 'name' => t('Service Name'),
+ 'name_ci' => t('Service Name (CI)'),
+ 'display_name' => t('Service Display Name'),
+ 'checkcommand_name' => t('Checkcommand Name'),
+ 'checkcommand_id' => t('Checkcommand Id'),
+ 'max_check_attempts' => t('Service Max Check Attempts'),
+ 'check_timeperiod_name' => t('Check Timeperiod Name'),
+ 'check_timeperiod_id' => t('Check Timeperiod Id'),
+ 'check_timeout' => t('Service Check Timeout'),
+ 'check_interval' => t('Service Check Interval'),
+ 'check_retry_interval' => t('Service Check Retry Inverval'),
+ 'active_checks_enabled' => t('Service Active Checks Enabled'),
+ 'passive_checks_enabled' => t('Service Passive Checks Enabled'),
+ 'event_handler_enabled' => t('Service Event Handler Enabled'),
+ 'notifications_enabled' => t('Service Notifications Enabled'),
+ 'flapping_enabled' => t('Service Flapping Enabled'),
+ 'flapping_threshold_low' => t('Service Flapping Threshold Low'),
+ 'flapping_threshold_high' => t('Service Flapping Threshold High'),
+ 'perfdata_enabled' => t('Service Performance Data Enabled'),
+ 'eventcommand_name' => t('Eventcommand Name'),
+ 'eventcommand_id' => t('Eventcommand Id'),
+ 'is_volatile' => t('Service Is Volatile'),
+ 'action_url_id' => t('Action Url Id'),
+ 'notes_url_id' => t('Notes Url Id'),
+ 'notes' => t('Service Notes'),
+ 'icon_image_id' => t('Icon Image Id'),
+ 'icon_image_alt' => t('Icon Image Alt'),
+ 'zone_name' => t('Zone Name'),
+ 'zone_id' => t('Zone Id'),
+ 'command_endpoint_name' => t('Endpoint Name'),
+ 'command_endpoint_id' => t('Endpoint Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'service.display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'active_checks_enabled',
+ 'passive_checks_enabled',
+ 'event_handler_enabled',
+ 'notifications_enabled',
+ 'flapping_enabled',
+ 'is_volatile'
+ ]));
+
+ $behaviors->add(new ReRoute([
+ 'user' => 'notification.user',
+ 'usergroup' => 'notification.usergroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'host_id',
+ 'checkcommand_id',
+ 'check_timeperiod_id',
+ 'eventcommand_id',
+ 'action_url_id',
+ 'notes_url_id',
+ 'icon_image_id',
+ 'zone_id',
+ 'command_endpoint_id'
+ ]));
+ }
+
+ public function createDefaults(Defaults $defaults)
+ {
+ $defaults->add('vars', function (self $subject) {
+ if (! $subject->customvar_flat instanceof ResultSet) {
+ $this->applyRestrictions($subject->customvar_flat);
+ }
+
+ $vars = [];
+ foreach ($subject->customvar_flat as $customVar) {
+ $vars[$customVar->flatname] = $customVar->flatvalue;
+ }
+
+ return $vars;
+ });
+
+ $defaults->add('customvars', function (self $subject) {
+ if (! $subject->customvar instanceof ResultSet) {
+ $this->applyRestrictions($subject->customvar);
+ }
+
+ $vars = [];
+ foreach ($subject->customvar as $customVar) {
+ $vars[$customVar->name] = json_decode($customVar->value, true);
+ }
+
+ return $vars;
+ });
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('host', Host::class)->setJoinType('LEFT');
+ $relations->belongsTo('checkcommand', Checkcommand::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class)
+ ->setCandidateKey('check_timeperiod_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('eventcommand', Eventcommand::class);
+ $relations->belongsTo('action_url', ActionUrl::class)
+ ->setCandidateKey('action_url_id')
+ ->setForeignKey('id');
+ $relations->belongsTo('notes_url', NotesUrl::class)
+ ->setCandidateKey('notes_url_id')
+ ->setForeignKey('id');
+ $relations->belongsTo('icon_image', IconImage::class)
+ ->setCandidateKey('icon_image_id')
+ ->setJoinType('LEFT');
+ $relations->belongsTo('zone', Zone::class);
+ $relations->belongsTo('endpoint', Endpoint::class)
+ ->setCandidateKey('command_endpoint_id');
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(ServiceCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(ServiceCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(ServiceCustomvar::class);
+ $relations->belongsToMany('servicegroup', Servicegroup::class)
+ ->through(ServicegroupMember::class);
+ $relations->belongsToMany('hostgroup', Hostgroup::class)
+ ->through(HostgroupMember::class);
+
+ $relations->hasOne('state', ServiceState::class)->setJoinType('LEFT');
+ $relations->hasMany('comment', Comment::class)->setJoinType('LEFT');
+ $relations->hasMany('downtime', Downtime::class)->setJoinType('LEFT');
+ $relations->hasMany('history', History::class);
+ $relations->hasMany('notification', Notification::class)->setJoinType('LEFT');
+ $relations->hasMany('notification_history', NotificationHistory::class);
+ }
+}
diff --git a/library/Icingadb/Model/ServiceCustomvar.php b/library/Icingadb/Model/ServiceCustomvar.php
new file mode 100644
index 0000000..07ee84c
--- /dev/null
+++ b/library/Icingadb/Model/ServiceCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class ServiceCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'service_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'service_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'service_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('service', Service::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setForeignKey('customvar_id')
+ ->setCandidateKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/ServiceState.php b/library/Icingadb/Model/ServiceState.php
new file mode 100644
index 0000000..c5daa08
--- /dev/null
+++ b/library/Icingadb/Model/ServiceState.php
@@ -0,0 +1,76 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use ipl\Orm\Relations;
+
+class ServiceState extends State
+{
+ public function getTableName()
+ {
+ return 'service_state';
+ }
+
+ public function getKeyName()
+ {
+ return 'service_id';
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'state_type' => t('Service State Type'),
+ 'soft_state' => t('Service Soft State'),
+ 'hard_state' => t('Service Hard State'),
+ 'previous_soft_state' => t('Service Previous Soft State'),
+ 'previous_hard_state' => t('Service Previous Hard State'),
+ 'check_attempt' => t('Service Check Attempt No.'),
+ 'severity' => t('Service State Severity'),
+ 'output' => t('Service Output'),
+ 'long_output' => t('Service Long Output'),
+ 'performance_data' => t('Service Performance Data'),
+ 'normalized_performance_data' => t('Service Normalized Performance Data'),
+ 'check_commandline' => t('Service Check Commandline'),
+ 'is_problem' => t('Service Has Problem'),
+ 'is_handled' => t('Service Is Handled'),
+ 'is_reachable' => t('Service Is Reachable'),
+ 'is_flapping' => t('Service Is Flapping'),
+ 'is_overdue' => t('Service Check Is Overdue'),
+ 'is_acknowledged' => t('Service Is Acknowledged'),
+ 'acknowledgement_comment_id' => t('Acknowledgement Comment Id'),
+ 'in_downtime' => t('Service In Downtime'),
+ 'execution_time' => t('Service Check Execution Time'),
+ 'latency' => t('Service Check Latency'),
+ 'check_timeout' => t('Service Check Timeout'),
+ 'check_source' => t('Service Check Source'),
+ 'last_update' => t('Service Last Update'),
+ 'last_state_change' => t('Service Last State Change'),
+ 'next_check' => t('Service Next Check'),
+ 'next_update' => t('Service Next Update')
+ ];
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('service', Service::class);
+ $relations->hasOne('last_comment', LastServiceComment::class)
+ ->setCandidateKey('last_comment_id')
+ ->setForeignKey('id')
+ ->setJoinType('LEFT');
+ }
+
+ public function getStateText(): string
+ {
+ return ServiceStates::text($this->soft_state);
+ }
+
+ public function getStateTextTranslated(): string
+ {
+ return ServiceStates::text($this->soft_state);
+ }
+}
diff --git a/library/Icingadb/Model/Servicegroup.php b/library/Icingadb/Model/Servicegroup.php
new file mode 100644
index 0000000..0da92fb
--- /dev/null
+++ b/library/Icingadb/Model/Servicegroup.php
@@ -0,0 +1,91 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Servicegroup extends Model
+{
+ public function getTableName()
+ {
+ return 'servicegroup';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Servicegroup Name Checksum'),
+ 'properties_checksum' => t('Servicegroup Properties Checksum'),
+ 'name' => t('Servicegroup Name'),
+ 'name_ci' => t('Servicegroup Name (CI)'),
+ 'display_name' => t('Servicegroup Display Name'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'host' => 'service.host',
+ 'hostgroup' => 'service.hostgroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(ServicegroupCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(ServicegroupCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(ServicegroupCustomvar::class);
+ $relations->belongsToMany('service', Service::class)
+ ->through(ServicegroupMember::class);
+ }
+}
diff --git a/library/Icingadb/Model/ServicegroupCustomvar.php b/library/Icingadb/Model/ServicegroupCustomvar.php
new file mode 100644
index 0000000..23e536b
--- /dev/null
+++ b/library/Icingadb/Model/ServicegroupCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class ServicegroupCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'servicegroup_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'servicegroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'servicegroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('servicegroup', Servicegroup::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/ServicegroupMember.php b/library/Icingadb/Model/ServicegroupMember.php
new file mode 100644
index 0000000..5da537a
--- /dev/null
+++ b/library/Icingadb/Model/ServicegroupMember.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class ServicegroupMember extends Model
+{
+ public function getTableName()
+ {
+ return 'servicegroup_member';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'service_id',
+ 'servicegroup_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'service_id',
+ 'servicegroup_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('servicegroup', Servicegroup::class);
+ $relations->belongsTo('service', Service::class);
+ }
+}
diff --git a/library/Icingadb/Model/ServicegroupSummary.php b/library/Icingadb/Model/ServicegroupSummary.php
new file mode 100644
index 0000000..89a0953
--- /dev/null
+++ b/library/Icingadb/Model/ServicegroupSummary.php
@@ -0,0 +1,167 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Relations;
+use ipl\Orm\UnionModel;
+use ipl\Sql\Adapter\Pgsql;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+
+class ServicegroupSummary extends UnionModel
+{
+ public static function on(Connection $db)
+ {
+ $q = parent::on($db);
+
+ $q->on($q::ON_SELECT_ASSEMBLED, function (Select $select) use ($q) {
+ $model = $q->getModel();
+
+ $groupBy = $q->getResolver()->qualifyColumnsAndAliases((array) $model->getKeyName(), $model, false);
+
+ // For PostgreSQL, ALL non-aggregate SELECT columns must appear in the GROUP BY clause:
+ if ($q->getDb()->getAdapter() instanceof Pgsql) {
+ /**
+ * Ignore Expressions, i.e. aggregate functions {@see getColumns()},
+ * which do not need to be added to the GROUP BY.
+ */
+ $candidates = array_filter($select->getColumns(), 'is_string');
+ // Remove already considered columns for the GROUP BY, i.e. the primary key.
+ $candidates = array_diff_assoc($candidates, $groupBy);
+ $groupBy = array_merge($groupBy, $candidates);
+ }
+
+ $select->groupBy($groupBy);
+ });
+
+ return $q;
+ }
+
+ public function getTableName()
+ {
+ return 'servicegroup';
+ }
+
+ public function getKeyName()
+ {
+ return ['id' => 'servicegroup_id'];
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'display_name' => 'servicegroup_display_name',
+ 'name' => 'servicegroup_name',
+ 'services_critical_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 2'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_ok' => new Expression(
+ 'SUM(CASE WHEN service_state = 0 THEN 1 ELSE 0 END)'
+ ),
+ 'services_pending' => new Expression(
+ 'SUM(CASE WHEN service_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'services_total' => new Expression(
+ 'SUM(CASE WHEN service_id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 3'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_handled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1'
+ . ' AND (service_handled = \'y\' OR service_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state = 1'
+ . ' AND service_handled = \'n\' AND service_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_severity' => new Expression('MAX(service_severity)')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'display_name';
+ }
+
+ public function getUnions()
+ {
+ $unions = [
+ [
+ Service::class,
+ [
+ 'servicegroup',
+ 'state'
+ ],
+ [
+ 'servicegroup_id' => 'servicegroup.id',
+ 'servicegroup_name' => 'servicegroup.name',
+ 'servicegroup_display_name' => 'servicegroup.display_name',
+ 'service_id' => 'service.id',
+ 'service_state' => 'state.soft_state',
+ 'service_handled' => 'state.is_handled',
+ 'service_reachable' => 'state.is_reachable',
+ 'service_severity' => 'state.severity'
+ ]
+ ],
+ [
+ Servicegroup::class,
+ [],
+ [
+ 'servicegroup_id' => 'servicegroup.id',
+ 'servicegroup_name' => 'servicegroup.name',
+ 'servicegroup_display_name' => 'servicegroup.display_name',
+ 'service_id' => new Expression('NULL'),
+ 'service_state' => new Expression('NULL'),
+ 'service_handled' => new Expression('NULL'),
+ 'service_reachable' => new Expression('NULL'),
+ 'service_severity' => new Expression('0')
+ ]
+ ]
+ ];
+
+ return $unions;
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id'
+ ]));
+
+ // This is because there is no better way
+ (new Servicegroup())->createBehaviors($behaviors);
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ // This is because there is no better way
+ (new Servicegroup())->createRelations($relations);
+ }
+
+ public function getColumnDefinitions()
+ {
+ // This is because there is no better way
+ return (new Servicegroup())->getColumnDefinitions();
+ }
+}
diff --git a/library/Icingadb/Model/ServicestateSummary.php b/library/Icingadb/Model/ServicestateSummary.php
new file mode 100644
index 0000000..b1364f7
--- /dev/null
+++ b/library/Icingadb/Model/ServicestateSummary.php
@@ -0,0 +1,99 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+
+class ServicestateSummary extends Service
+{
+ public function getSummaryColumns()
+ {
+ return [
+ 'services_acknowledged' => new Expression(
+ 'SUM(CASE WHEN service_state.is_acknowledged = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_active_checks_enabled' => new Expression(
+ 'SUM(CASE WHEN service.active_checks_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_passive_checks_enabled' => new Expression(
+ 'SUM(CASE WHEN service.passive_checks_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_handled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 2'
+ . ' AND (service_state.is_handled = \'y\' OR service_state.is_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_critical_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 2'
+ . ' AND service_state.is_handled = \'n\' AND service_state.is_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_event_handler_enabled' => new Expression(
+ 'SUM(CASE WHEN service.event_handler_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_flapping_enabled' => new Expression(
+ 'SUM(CASE WHEN service.flapping_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_notifications_enabled' => new Expression(
+ 'SUM(CASE WHEN service.notifications_enabled = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_ok' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 0 THEN 1 ELSE 0 END)'
+ ),
+ 'services_pending' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 99 THEN 1 ELSE 0 END)'
+ ),
+ 'services_problems_unacknowledged' => new Expression(
+ 'SUM(CASE WHEN service_state.is_problem = \'y\''
+ . ' AND service_state.is_acknowledged = \'n\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_total' => new Expression(
+ 'SUM(CASE WHEN service.id IS NOT NULL THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_handled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 3'
+ . ' AND (service_state.is_handled = \'y\' OR service_state.is_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_unknown_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 3'
+ . ' AND service_state.is_handled = \'n\' AND service_state.is_reachable = \'y\' THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_handled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 1'
+ . ' AND (service_state.is_handled = \'y\' OR service_state.is_reachable = \'n\') THEN 1 ELSE 0 END)'
+ ),
+ 'services_warning_unhandled' => new Expression(
+ 'SUM(CASE WHEN service_state.soft_state = 1'
+ . ' AND service_state.is_handled = \'n\' AND service_state.is_reachable = \'y\' THEN 1 ELSE 0 END)'
+ )
+ ];
+ }
+
+ public static function on(Connection $db)
+ {
+ $q = parent::on($db);
+ $q->utilize('state');
+
+ /** @var static $m */
+ $m = $q->getModel();
+ $q->columns($m->getSummaryColumns());
+
+ return $q;
+ }
+
+ public function getColumns()
+ {
+ return array_merge(parent::getColumns(), $this->getSummaryColumns());
+ }
+
+ public function getDefaultSort()
+ {
+ return null;
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'host.name_ci'];
+ }
+}
diff --git a/library/Icingadb/Model/State.php b/library/Icingadb/Model/State.php
new file mode 100644
index 0000000..2d242a8
--- /dev/null
+++ b/library/Icingadb/Model/State.php
@@ -0,0 +1,177 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use DateTime;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Model\Behavior\BoolCast;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Web\Widget\Icon;
+
+/**
+ * Base class for the {@link HostState} and {@link ServiceState} models providing common columns.
+ *
+ * @property string $environment_id The environment id
+ * @property string $state_type The state type (hard or soft)
+ * @property int $soft_state The current soft state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN)
+ * @property int $hard_state The current hard state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN)
+ * @property int $previous_soft_state The previous soft state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN)
+ * @property int $previous_hard_state The previous hard state code (0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = UNKNOWN)
+ * @property int $check_attempt The check attempt count
+ * @property int $severity The calculated severity
+ * @property ?string $output The check output
+ * @property ?string $long_output The long check output
+ * @property ?string $performance_data The performance data
+ * @property ?string $normalized_performance_data The normalized performance data (converted ms to s, GiB to byte etc.)
+ * @property ?string $check_commandline The executed check command
+ * @property bool $is_problem Whether in non-OK state
+ * @property bool $is_handled Whether the state is handled
+ * @property bool $is_reachable Whether the node is reachable
+ * @property bool $is_flapping Whether the state is flapping
+ * @property bool $is_overdue Whether the check is overdue
+ * @property bool|string $is_acknowledged Whether the state is acknowledged (bool), can also be `sticky` (string)
+ * @property ?string $acknowledgement_comment_id The id of acknowledgement comment
+ * @property ?string $last_comment_id The id of last comment
+ * @property bool $in_downtime Whether the node is in downtime
+ * @property ?int $execution_time The check execution time
+ * @property ?int $latency The check latency
+ * @property ?int $check_timeout The check timeout
+ * @property ?string $check_source The name of the node that executes the check
+ * @property ?string $scheduling_source The name of the node that schedules the check
+ * @property ?DateTime $last_update The time when the node was last updated
+ * @property ?DateTime $last_state_change The time when the node last got a status change
+ * @property ?DateTime $next_check The time when the node will execute the next check
+ * @property ?DateTime $next_update The time when the next check of the node is expected to end
+ */
+abstract class State extends Model
+{
+ /**
+ * Get the state as the textual representation
+ *
+ * @return string
+ */
+ abstract public function getStateText(): string;
+
+ /**
+ * Get the state as the translated textual representation
+ *
+ * @return string
+ */
+ abstract public function getStateTextTranslated(): string;
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'state_type',
+ 'soft_state',
+ 'hard_state',
+ 'previous_soft_state',
+ 'previous_hard_state',
+ 'check_attempt',
+ 'severity',
+ 'output',
+ 'long_output',
+ 'performance_data',
+ 'normalized_performance_data',
+ 'check_commandline',
+ 'is_problem',
+ 'is_handled',
+ 'is_reachable',
+ 'is_flapping',
+ 'is_overdue',
+ 'is_acknowledged',
+ 'acknowledgement_comment_id',
+ 'last_comment_id',
+ 'in_downtime',
+ 'execution_time',
+ 'latency',
+ 'check_timeout',
+ 'check_source',
+ 'scheduling_source',
+ 'last_update',
+ 'last_state_change',
+ 'next_check',
+ 'next_update'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new BoolCast([
+ 'is_problem',
+ 'is_handled',
+ 'is_reachable',
+ 'is_flapping',
+ 'is_overdue',
+ 'is_acknowledged',
+ 'in_downtime'
+ ]));
+
+ $behaviors->add(new MillisecondTimestamp([
+ 'last_update',
+ 'last_state_change',
+ 'next_check',
+ 'next_update'
+ ]));
+
+ $behaviors->add(new Binary([
+ $this->getKeyName(),
+ 'environment_id',
+ 'acknowledgement_comment_id',
+ 'last_comment_id'
+ ]));
+ }
+
+ /**
+ * Get the state icon
+ *
+ * @return Icon|null
+ */
+ public function getIcon(): ?Icon
+ {
+ $icon = null;
+ switch (true) {
+ case $this->is_acknowledged:
+ $icon = new Icon(Icons::IS_ACKNOWLEDGED);
+
+ break;
+ case $this->in_downtime:
+ $icon = new Icon(
+ Icons::IN_DOWNTIME,
+ ['title' => sprintf(
+ '%s (%s)',
+ strtoupper($this->getStateTextTranslated()),
+ $this->is_handled ? t('handled by Downtime') : t('in Downtime')
+ )]
+ );
+
+ break;
+ case $this->is_flapping:
+ $icon = new Icon(Icons::IS_FLAPPING);
+
+ break;
+ case ! $this->is_reachable:
+ $icon = new Icon(Icons::HOST_DOWN, [
+ 'title' => sprintf(
+ '%s (%s)',
+ strtoupper($this->getStateTextTranslated()),
+ t('is unreachable')
+ )
+ ]);
+
+ break;
+ case $this->is_handled:
+ $icon = new Icon(Icons::HOST_DOWN);
+
+ break;
+ }
+
+ return $icon;
+ }
+}
diff --git a/library/Icingadb/Model/StateHistory.php b/library/Icingadb/Model/StateHistory.php
new file mode 100644
index 0000000..9d80cb2
--- /dev/null
+++ b/library/Icingadb/Model/StateHistory.php
@@ -0,0 +1,101 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behavior\MillisecondTimestamp;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+/**
+ * Model for table `state_history`
+ *
+ * Please note that using this model will fetch history entries for decommissioned services. To avoid this,
+ * the query needs a `state_history.service_id IS NULL OR state_history_service.id IS NOT NULL` where.
+ */
+class StateHistory extends Model
+{
+ public function getTableName()
+ {
+ return 'state_history';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'endpoint_id',
+ 'object_type',
+ 'host_id',
+ 'service_id',
+ 'event_time',
+ 'state_type',
+ 'soft_state',
+ 'hard_state',
+ 'check_attempt',
+ 'previous_soft_state',
+ 'previous_hard_state',
+ 'output',
+ 'long_output',
+ 'max_check_attempts',
+ 'check_source',
+ 'scheduling_source'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'endpoint_id' => t('Endpoint Id'),
+ 'object_type' => t('Object Type'),
+ 'host_id' => t('Host Id'),
+ 'service_id' => t('Service Id'),
+ 'event_time' => t('Event Time'),
+ 'state_type' => t('Event State Type'),
+ 'soft_state' => t('Event Soft State'),
+ 'hard_state' => t('Event Hard State'),
+ 'check_attempt' => t('Event Check Attempt No.'),
+ 'previous_soft_state' => t('Event Previous Soft State'),
+ 'previous_hard_state' => t('Event Previous Hard State'),
+ 'output' => t('Event Output'),
+ 'long_output' => t('Event Long Output'),
+ 'max_check_attempts' => t('Event Max Check Attempts'),
+ 'check_source' => t('Event Check Source')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new MillisecondTimestamp([
+ 'event_time'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'endpoint_id',
+ 'host_id',
+ 'service_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('endpoint', Endpoint::class);
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('history', History::class)
+ ->setCandidateKey('id')
+ ->setForeignKey('state_history_id');
+ $relations->belongsTo('host', Host::class);
+ $relations->belongsTo('service', Service::class)->setJoinType('LEFT');
+ }
+}
diff --git a/library/Icingadb/Model/Timeperiod.php b/library/Icingadb/Model/Timeperiod.php
new file mode 100644
index 0000000..26dd722
--- /dev/null
+++ b/library/Icingadb/Model/Timeperiod.php
@@ -0,0 +1,91 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Timeperiod extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'prefer_includes',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Timeperiod Name Checksum'),
+ 'properties_checksum' => t('Timeperiod Properties Checksum'),
+ 'name' => t('Timeperiod Name'),
+ 'name_ci' => t('Timeperiod Name (CI)'),
+ 'display_name' => t('Timeperiod Display Name'),
+ 'prefer_includes' => t('Timeperiod Prefer Includes'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'hostgroup' => 'host.hostgroup',
+ 'servicegroup' => 'service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(TimeperiodCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(TimeperiodCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(TimeperiodCustomvar::class);
+
+ // TODO: Decide how to establish the override relations
+
+ $relations->hasMany('range', TimeperiodRange::class);
+ $relations->hasMany('host', Host::class)
+ ->setForeignKey('check_timeperiod_id');
+ $relations->hasMany('Notification', Notification::class);
+ $relations->hasMany('service', Service::class)
+ ->setForeignKey('check_timeperiod_id');
+ $relations->hasMany('user', User::class);
+ }
+}
diff --git a/library/Icingadb/Model/TimeperiodCustomvar.php b/library/Icingadb/Model/TimeperiodCustomvar.php
new file mode 100644
index 0000000..614a312
--- /dev/null
+++ b/library/Icingadb/Model/TimeperiodCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class TimeperiodCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'timeperiod_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'timeperiod_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/TimeperiodOverrideExclude.php b/library/Icingadb/Model/TimeperiodOverrideExclude.php
new file mode 100644
index 0000000..c33df77
--- /dev/null
+++ b/library/Icingadb/Model/TimeperiodOverrideExclude.php
@@ -0,0 +1,51 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class TimeperiodOverrideExclude extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod_override_exclude';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'timeperiod_id',
+ 'override_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'timeperiod_id',
+ 'override_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ // TODO: `timeperiod` cannot be used again, find a better name
+ $relations->belongsTo('timeperiod', Timeperiod::class)
+ ->setCandidateKey('override_id');
+ }
+}
diff --git a/library/Icingadb/Model/TimeperiodOverrideInclude.php b/library/Icingadb/Model/TimeperiodOverrideInclude.php
new file mode 100644
index 0000000..5418596
--- /dev/null
+++ b/library/Icingadb/Model/TimeperiodOverrideInclude.php
@@ -0,0 +1,51 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class TimeperiodOverrideInclude extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod_override_include';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'timeperiod_id',
+ 'override_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'timeperiod_id',
+ 'override_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ // TODO: `timeperiod` cannot be used again, find a better name
+ $relations->belongsTo('timeperiod', Timeperiod::class)
+ ->setCandidateKey('override_id');
+ }
+}
diff --git a/library/Icingadb/Model/TimeperiodRange.php b/library/Icingadb/Model/TimeperiodRange.php
new file mode 100644
index 0000000..62e87f8
--- /dev/null
+++ b/library/Icingadb/Model/TimeperiodRange.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class TimeperiodRange extends Model
+{
+ public function getTableName()
+ {
+ return 'timeperiod_range';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'timeperiod_id',
+ 'range_key',
+ 'environment_id',
+ 'range_value'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'timeperiod_id' => t('Timeperiod Id'),
+ 'range_key' => t('Timeperiod Range Date(s)/Day'),
+ 'environment_id' => t('Environment Id'),
+ 'range_value' => t('Timeperiod Range Time')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'timeperiod_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ }
+}
diff --git a/library/Icingadb/Model/User.php b/library/Icingadb/Model/User.php
new file mode 100644
index 0000000..91d0d71
--- /dev/null
+++ b/library/Icingadb/Model/User.php
@@ -0,0 +1,134 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\Bitmask;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class User extends Model
+{
+ public function getTableName()
+ {
+ return 'user';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'email',
+ 'pager',
+ 'notifications_enabled',
+ 'timeperiod_id',
+ 'states',
+ 'types',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('User Name Checksum'),
+ 'properties_checksum' => t('User Properties Checksum'),
+ 'name' => t('User Name'),
+ 'name_ci' => t('User Name (CI)'),
+ 'display_name' => t('User Display Name'),
+ 'email' => t('User Email'),
+ 'pager' => t('User Pager'),
+ 'notifications_enabled' => t('User Receives Notifications'),
+ 'timeperiod_id' => t('Timeperiod Id'),
+ 'states' => t('Notification State Filter'),
+ 'types' => t('Notification Type Filter'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'user.display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'host' => 'notification.host',
+ 'service' => 'notification.service',
+ 'hostgroup' => 'notification.host.hostgroup',
+ 'servicegroup' => 'notification.service.servicegroup'
+ ]));
+
+ $behaviors->add(new Bitmask([
+ 'states' => [
+ 'ok' => 1,
+ 'warning' => 2,
+ 'critical' => 4,
+ 'unknown' => 8,
+ 'up' => 16,
+ 'down' => 32
+ ],
+ 'types' => [
+ 'downtime_start' => 1,
+ 'downtime_end' => 2,
+ 'downtime_removed' => 4,
+ 'custom' => 8,
+ 'ack' => 16,
+ 'problem' => 32,
+ 'recovery' => 64,
+ 'flapping_start' => 128,
+ 'flapping_end' => 256
+ ]
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'timeperiod_id',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('timeperiod', Timeperiod::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(UserCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(UserCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(UserCustomvar::class);
+ $relations->belongsToMany('notification', Notification::class)
+ ->through('notification_recipient');
+ $relations->belongsToMany('notification_history', NotificationHistory::class)
+ ->through('user_notification_history');
+ $relations->belongsToMany('usergroup', Usergroup::class)
+ ->through(UsergroupMember::class);
+ }
+}
diff --git a/library/Icingadb/Model/UserCustomvar.php b/library/Icingadb/Model/UserCustomvar.php
new file mode 100644
index 0000000..a702b68
--- /dev/null
+++ b/library/Icingadb/Model/UserCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class UserCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'user_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'user_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'user_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('user', User::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/Usergroup.php b/library/Icingadb/Model/Usergroup.php
new file mode 100644
index 0000000..34b0647
--- /dev/null
+++ b/library/Icingadb/Model/Usergroup.php
@@ -0,0 +1,95 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Usergroup extends Model
+{
+ public function getTableName()
+ {
+ return 'usergroup';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'display_name',
+ 'zone_id'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Usergroup Name Checksum'),
+ 'properties_checksum' => t('Usergroup Properties Checksum'),
+ 'name' => t('Usergroup Name'),
+ 'name_ci' => t('Usergroup Name (CI)'),
+ 'display_name' => t('Usergroup Display Name'),
+ 'zone_id' => t('Zone Id')
+ ];
+ }
+
+ public function getSearchColumns()
+ {
+ return ['name_ci', 'display_name'];
+ }
+
+ public function getDefaultSort()
+ {
+ return 'usergroup.display_name';
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new ReRoute([
+ 'host' => 'notification.host',
+ 'service' => 'notification.service',
+ 'hostgroup' => 'notification.host.hostgroup',
+ 'servicegroup' => 'notification.service.servicegroup'
+ ]));
+
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'zone_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('zone', Zone::class);
+
+ $relations->belongsToMany('customvar', Customvar::class)
+ ->through(UsergroupCustomvar::class);
+ $relations->belongsToMany('customvar_flat', CustomvarFlat::class)
+ ->through(UsergroupCustomvar::class);
+ $relations->belongsToMany('vars', Vars::class)
+ ->through(UsergroupCustomvar::class);
+ $relations->belongsToMany('user', User::class)
+ ->through(UsergroupMember::class);
+ $relations->belongsToMany('notification', Notification::class)
+ ->through('notification_recipient');
+ }
+}
diff --git a/library/Icingadb/Model/UsergroupCustomvar.php b/library/Icingadb/Model/UsergroupCustomvar.php
new file mode 100644
index 0000000..ab97273
--- /dev/null
+++ b/library/Icingadb/Model/UsergroupCustomvar.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class UsergroupCustomvar extends Model
+{
+ public function getTableName()
+ {
+ return 'usergroup_customvar';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'usergroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'usergroup_id',
+ 'customvar_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('usergroup', Usergroup::class);
+ $relations->belongsTo('customvar', Customvar::class);
+ $relations->belongsTo('customvar_flat', CustomvarFlat::class)
+ ->setCandidateKey('customvar_id')
+ ->setForeignKey('customvar_id');
+ }
+}
diff --git a/library/Icingadb/Model/UsergroupMember.php b/library/Icingadb/Model/UsergroupMember.php
new file mode 100644
index 0000000..7c61d67
--- /dev/null
+++ b/library/Icingadb/Model/UsergroupMember.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class UsergroupMember extends Model
+{
+ public function getTableName()
+ {
+ return 'usergroup_member';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'user_id',
+ 'usergroup_id',
+ 'environment_id'
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'user_id',
+ 'usergroup_id',
+ 'environment_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+ $relations->belongsTo('usergroup', Usergroup::class);
+ $relations->belongsTo('user', User::class);
+ }
+}
diff --git a/library/Icingadb/Model/Vars.php b/library/Icingadb/Model/Vars.php
new file mode 100644
index 0000000..304d526
--- /dev/null
+++ b/library/Icingadb/Model/Vars.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use Icinga\Module\Icingadb\Model\Behavior\FlattenedObjectVars;
+use ipl\Orm\Behaviors;
+use ipl\Sql\Connection;
+
+class Vars extends CustomvarFlat
+{
+ /**
+ * @internal Don't use. This model acts only as relation target and is not supposed to be directly used as query
+ * target. Use {@see CustomvarFlat} instead.
+ */
+ public static function on(Connection $_)
+ {
+ throw new \LogicException('Documentation says: DO NOT USE. Can\'t you read?');
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ parent::createBehaviors($behaviors);
+
+ $behaviors->add(new FlattenedObjectVars());
+ }
+}
diff --git a/library/Icingadb/Model/Zone.php b/library/Icingadb/Model/Zone.php
new file mode 100644
index 0000000..aaf3bbf
--- /dev/null
+++ b/library/Icingadb/Model/Zone.php
@@ -0,0 +1,82 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Model;
+
+use ipl\Orm\Behavior\Binary;
+use ipl\Orm\Behaviors;
+use ipl\Orm\Model;
+use ipl\Orm\Relations;
+
+class Zone extends Model
+{
+ public function getTableName()
+ {
+ return 'zone';
+ }
+
+ public function getKeyName()
+ {
+ return 'id';
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'name',
+ 'name_ci',
+ 'is_global',
+ 'parent_id',
+ 'depth'
+ ];
+ }
+
+ public function getColumnDefinitions()
+ {
+ return [
+ 'environment_id' => t('Environment Id'),
+ 'name_checksum' => t('Zone Name Checksum'),
+ 'properties_checksum' => t('Zone Properties Checksum'),
+ 'name' => t('Zone Name'),
+ 'name_ci' => t('Zone Name (CI)'),
+ 'is_global' => t('Zone Is Global'),
+ 'parent_id' => t('Parent Zone Id'),
+ 'depth' => t('Zone Depth')
+ ];
+ }
+
+ public function createBehaviors(Behaviors $behaviors)
+ {
+ $behaviors->add(new Binary([
+ 'id',
+ 'environment_id',
+ 'name_checksum',
+ 'properties_checksum',
+ 'parent_id'
+ ]));
+ }
+
+ public function createRelations(Relations $relations)
+ {
+ $relations->belongsTo('environment', Environment::class);
+
+ $relations->hasMany('comment', Comment::class);
+ $relations->hasMany('downtime', Downtime::class);
+ $relations->hasMany('endpoint', Endpoint::class);
+ $relations->hasMany('eventcommand', Eventcommand::class);
+ $relations->hasMany('host', Host::class);
+ $relations->hasMany('hostgroup', Hostgroup::class);
+ $relations->hasMany('notification', Notification::class);
+ $relations->hasMany('service', Service::class);
+ $relations->hasMany('servicegroup', Servicegroup::class);
+ $relations->hasMany('timeperiod', Timeperiod::class);
+ $relations->hasMany('user', User::class);
+ $relations->hasMany('usergroup', Usergroup::class);
+
+ // TODO: Decide how to establish recursive relations
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/ApplicationState.php b/library/Icingadb/ProvidedHook/ApplicationState.php
new file mode 100644
index 0000000..8c7b008
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/ApplicationState.php
@@ -0,0 +1,111 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Exception;
+use Icinga\Application\Hook\ApplicationStateHook;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\IcingaRedis;
+use Icinga\Module\Icingadb\Model\Instance;
+use Icinga\Web\Session;
+use ipl\Stdlib\Filter;
+
+class ApplicationState extends ApplicationStateHook
+{
+ use Database;
+
+ public function collectMessages()
+ {
+ try {
+ $lastIcingaHeartbeat = IcingaRedis::getLastIcingaHeartbeat();
+ } catch (Exception $e) {
+ $downSince = Session::getSession()->getNamespace('icingadb')->get('redis.down-since');
+
+ if ($downSince === null) {
+ $downSince = time();
+ Session::getSession()->getNamespace('icingadb')->set('redis.down-since', $downSince);
+ }
+
+ $this->addError(
+ 'icingadb/redis-down',
+ $downSince,
+ sprintf(t("Can't connect to Icinga Redis: %s"), $e->getMessage())
+ );
+
+ return;
+ }
+
+ $instance = Instance::on($this->getDb())
+ ->with(['endpoint'])
+ ->filter(Filter::equal('responsible', true))
+ ->orderBy('heartbeat', 'desc')
+ ->first();
+
+ if ($instance === null) {
+ $noInstanceSince = Session::getSession()
+ ->getNamespace('icingadb')->get('icingadb.no-instance-since');
+
+ if ($noInstanceSince === null) {
+ $noInstanceSince = time();
+ Session::getSession()
+ ->getNamespace('icingadb')->set('icingadb.no-instance-since', $noInstanceSince);
+ }
+
+ $this->addError(
+ 'icingadb/no-instance',
+ $noInstanceSince,
+ t(
+ 'It seems that Icinga DB is not running.'
+ . ' Make sure Icinga DB is running and writing into the database.'
+ )
+ );
+
+ return;
+ } else {
+ Session::getSession()->getNamespace('icingadb')->delete('db.no-instance-since');
+ }
+
+ $outdatedDbHeartbeat = $instance->heartbeat->getTimestamp() < time() - 60;
+
+ if ($lastIcingaHeartbeat === null) {
+ $missingSince = Session::getSession()
+ ->getNamespace('icingadb')->get('redis.heartbeat-missing-since');
+
+ if ($missingSince === null) {
+ $missingSince = time();
+ Session::getSession()
+ ->getNamespace('icingadb')->set('redis.heartbeat-missing-since', $missingSince);
+ }
+
+ $lastIcingaHeartbeat = $missingSince;
+ } else {
+ Session::getSession()->getNamespace('icingadb')->delete('redis.heartbeat-missing-since');
+ }
+
+ switch (true) {
+ case $outdatedDbHeartbeat && $instance->heartbeat->getTimestamp() > $lastIcingaHeartbeat:
+ $this->addError(
+ 'icingadb/redis-outdated',
+ $lastIcingaHeartbeat,
+ t('Icinga Redis is outdated. Make sure Icinga 2 is running and connected to Redis.')
+ );
+
+ break;
+ case $outdatedDbHeartbeat:
+ $this->addError(
+ 'icingadb/icingadb-down',
+ $instance->heartbeat->getTimestamp(),
+ t(
+ 'It seems that Icinga DB is not running.'
+ . ' Make sure Icinga DB is running and writing into the database.'
+ )
+ );
+
+ break;
+ }
+
+ Session::getSession()->getNamespace('icingadb')->delete('redis.down-since');
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/CreateHostSlaReport.php b/library/Icingadb/ProvidedHook/CreateHostSlaReport.php
new file mode 100644
index 0000000..83ed911
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/CreateHostSlaReport.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\HostActionsHook;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+class CreateHostSlaReport extends HostActionsHook
+{
+ use Translation;
+
+ public function getActionsForObject(Host $host): array
+ {
+ if (! Auth::getInstance()->hasPermission('reporting/reports')) {
+ return [];
+ }
+
+ $filter = QueryString::render(Filter::equal('host.name', $host->name));
+
+ return [
+ new Link(
+ $this->translate('Create Host SLA Report'),
+ Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'host']),
+ [
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ )
+ ];
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/CreateHostsSlaReport.php b/library/Icingadb/ProvidedHook/CreateHostsSlaReport.php
new file mode 100644
index 0000000..6da9fca
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/CreateHostsSlaReport.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\HostsDetailExtensionHook;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\ValidHtml;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+class CreateHostsSlaReport extends HostsDetailExtensionHook
+{
+ use Translation;
+
+ public function getHtmlForObjects(Query $hosts): ValidHtml
+ {
+ if (Auth::getInstance()->hasPermission('reporting/reports')) {
+ $filter = QueryString::render($this->getBaseFilter());
+
+ return (new HtmlDocument())
+ ->addHtml(Html::tag('h2', $this->translate('Reporting')))
+ ->addHtml(new Link(
+ $this->translate('Create Host SLA Report'),
+ Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'host']),
+ [
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ ));
+ }
+
+ return new HtmlDocument();
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/CreateServiceSlaReport.php b/library/Icingadb/ProvidedHook/CreateServiceSlaReport.php
new file mode 100644
index 0000000..eeab603
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/CreateServiceSlaReport.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\ServiceActionsHook;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+class CreateServiceSlaReport extends ServiceActionsHook
+{
+ use Translation;
+
+ public function getActionsForObject(Service $service): array
+ {
+ if (! Auth::getInstance()->hasPermission('reporting/reports')) {
+ return [];
+ }
+
+ $filter = QueryString::render(Filter::all(
+ Filter::equal('service.name', $service->name),
+ Filter::equal('host.name', $service->host->name)
+ ));
+
+ return [
+ new Link(
+ $this->translate('Create Service SLA Report'),
+ Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'service']),
+ [
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ )
+ ];
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/CreateServicesSlaReport.php b/library/Icingadb/ProvidedHook/CreateServicesSlaReport.php
new file mode 100644
index 0000000..a65b54e
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/CreateServicesSlaReport.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Icingadb\Hook\ServicesDetailExtensionHook;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\ValidHtml;
+use ipl\I18n\Translation;
+use ipl\Orm\Query;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+
+class CreateServicesSlaReport extends ServicesDetailExtensionHook
+{
+ use Translation;
+
+ public function getHtmlForObjects(Query $services): ValidHtml
+ {
+ if (Auth::getInstance()->hasPermission('reporting/reports')) {
+ $filter = QueryString::render($this->getBaseFilter());
+ return (new HtmlDocument())
+ ->addHtml(Html::tag('h2', $this->translate('Reporting')))
+ ->addHtml(new Link(
+ $this->translate('Create Service SLA Report'),
+ Url::fromPath('reporting/reports/new')->addParams(['filter' => $filter, 'report' => 'service']),
+ [
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ ));
+ }
+
+ return new HtmlDocument();
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/IcingaHealth.php b/library/Icingadb/ProvidedHook/IcingaHealth.php
new file mode 100644
index 0000000..54e22c7
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/IcingaHealth.php
@@ -0,0 +1,115 @@
+<?php
+
+// Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Icinga\Application\Hook\HealthHook;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\Instance;
+use ipl\Web\Url;
+
+class IcingaHealth extends HealthHook
+{
+ use Database;
+
+ /** @var Instance */
+ protected $instance;
+
+ public function getName(): string
+ {
+ return 'Icinga DB';
+ }
+
+ public function getUrl(): Url
+ {
+ return Url::fromPath('icingadb/health');
+ }
+
+ public function checkHealth()
+ {
+ $instance = $this->getInstance();
+
+ if ($instance === null) {
+ $this->setState(self::STATE_UNKNOWN);
+ $this->setMessage(t(
+ 'Icinga DB is not running or not writing into the database'
+ . ' (make sure the icinga feature "icingadb" is enabled)'
+ ));
+ } elseif ($instance->heartbeat->getTimestamp() < time() - 60) {
+ $this->setState(self::STATE_CRITICAL);
+ $this->setMessage(t(
+ 'Icinga DB is not running or not writing into the database'
+ . ' (make sure the icinga feature "icingadb" is enabled)'
+ ));
+ } else {
+ $this->setState(self::STATE_OK);
+ $this->setMessage(t('Icinga DB is running and writing into the database'));
+ $warningMessages = [];
+
+ if (! $instance->icinga2_active_host_checks_enabled) {
+ $this->setState(self::STATE_WARNING);
+ $warningMessages[] = t('Active host checks are disabled');
+ }
+
+ if (! $instance->icinga2_active_service_checks_enabled) {
+ $this->setState(self::STATE_WARNING);
+ $warningMessages[] = t('Active service checks are disabled');
+ }
+
+ if (! $instance->icinga2_notifications_enabled) {
+ $this->setState(self::STATE_WARNING);
+ $warningMessages[] = t('Notifications are disabled');
+ }
+
+ if ($this->getState() === self::STATE_WARNING) {
+ $this->setMessage(implode("; ", $warningMessages));
+ }
+ }
+
+ if ($instance !== null) {
+ $this->setMetrics([
+ 'heartbeat' => $instance->heartbeat->getTimestamp(),
+ 'responsible' => $instance->responsible,
+ 'icinga2_active_host_checks_enabled' => $instance->icinga2_active_host_checks_enabled,
+ 'icinga2_active_service_checks_enabled' => $instance->icinga2_active_service_checks_enabled,
+ 'icinga2_event_handlers_enabled' => $instance->icinga2_event_handlers_enabled,
+ 'icinga2_flap_detection_enabled' => $instance->icinga2_flap_detection_enabled,
+ 'icinga2_notifications_enabled' => $instance->icinga2_notifications_enabled,
+ 'icinga2_performance_data_enabled' => $instance->icinga2_performance_data_enabled,
+ 'icinga2_start_time' => $instance->icinga2_start_time->getTimestamp(),
+ 'icinga2_version' => $instance->icinga2_version,
+ 'endpoint' => ['name' => $instance->endpoint->name]
+ ]);
+ }
+ }
+
+ /**
+ * Get an Icinga DB instance
+ *
+ * @return ?Instance
+ */
+ protected function getInstance()
+ {
+ if ($this->instance === null) {
+ $this->instance = Instance::on($this->getDb())
+ ->with('endpoint')
+ ->columns([
+ 'heartbeat',
+ 'responsible',
+ 'icinga2_active_host_checks_enabled',
+ 'icinga2_active_service_checks_enabled',
+ 'icinga2_event_handlers_enabled',
+ 'icinga2_flap_detection_enabled',
+ 'icinga2_notifications_enabled',
+ 'icinga2_performance_data_enabled',
+ 'icinga2_start_time',
+ 'icinga2_version',
+ 'endpoint.name'
+ ])
+ ->first();
+ }
+
+ return $this->instance;
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/RedisHealth.php b/library/Icingadb/ProvidedHook/RedisHealth.php
new file mode 100644
index 0000000..1471aba
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/RedisHealth.php
@@ -0,0 +1,55 @@
+<?php
+
+// Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2
+
+namespace Icinga\Module\Icingadb\ProvidedHook;
+
+use Exception;
+use Icinga\Application\Hook\HealthHook;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\IcingaRedis;
+use Icinga\Module\Icingadb\Model\Instance;
+
+class RedisHealth extends HealthHook
+{
+ use Database;
+
+ public function getName(): string
+ {
+ return 'Icinga Redis';
+ }
+
+ public function checkHealth()
+ {
+ try {
+ $lastIcingaHeartbeat = IcingaRedis::getLastIcingaHeartbeat();
+ if ($lastIcingaHeartbeat === null) {
+ $lastIcingaHeartbeat = time();
+ }
+
+ $instance = Instance::on($this->getDb())->columns('heartbeat')->first();
+
+ if ($instance === null) {
+ $this->setState(self::STATE_UNKNOWN);
+ $this->setMessage(t(
+ 'Can\'t check Icinga Redis: Icinga DB is not running or not writing into the database'
+ . ' (make sure the icinga feature "icingadb" is enabled)'
+ ));
+
+ return;
+ }
+
+ $outdatedDbHeartbeat = $instance->heartbeat->getTimestamp() < time() - 60;
+ if (! $outdatedDbHeartbeat || $instance->heartbeat->getTimestamp() <= $lastIcingaHeartbeat) {
+ $this->setState(self::STATE_OK);
+ $this->setMessage(t('Icinga Redis available and up to date.'));
+ } elseif ($instance->heartbeat->getTimestamp() > $lastIcingaHeartbeat) {
+ $this->setState(self::STATE_CRITICAL);
+ $this->setMessage(t('Icinga Redis outdated. Make sure Icinga 2 is running and connected to Redis.'));
+ }
+ } catch (Exception $e) {
+ $this->setState(self::STATE_CRITICAL);
+ $this->setMessage(sprintf(t("Can't connect to Icinga Redis: %s"), $e->getMessage()));
+ }
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php
new file mode 100644
index 0000000..d9c4f4f
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/HostSlaReport.php
@@ -0,0 +1,68 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Reporting\ReportData;
+use Icinga\Module\Reporting\ReportRow;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter\Rule;
+
+use function ipl\I18n\t;
+
+class HostSlaReport extends SlaReport
+{
+ public function getName()
+ {
+ $name = t('Host SLA');
+ if (Icinga::app()->getModuleManager()->hasEnabled('idoreports')) {
+ $name .= ' (Icinga DB)';
+ }
+
+ return $name;
+ }
+
+ protected function createReportData()
+ {
+ return (new ReportData())
+ ->setDimensions([t('Hostname')])
+ ->setValues([t('SLA in %')]);
+ }
+
+ protected function createReportRow($row)
+ {
+ if ($row->sla === null) {
+ return null;
+ }
+
+ return (new ReportRow())
+ ->setDimensions([$row->display_name])
+ ->setValues([(float) $row->sla]);
+ }
+
+ protected function fetchSla(Timerange $timerange, Rule $filter = null)
+ {
+ $sla = Host::on($this->getDb())
+ ->columns([
+ 'display_name',
+ 'sla' => new Expression(sprintf(
+ "get_sla_ok_percent(%s, NULL, '%s', '%s')",
+ 'host.id',
+ $timerange->getStart()->format('Uv'),
+ $timerange->getEnd()->format('Uv')
+ ))
+ ]);
+
+ $this->applyRestrictions($sla);
+
+ if ($filter !== null) {
+ $sla->filter($filter);
+ }
+
+ return $sla;
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php
new file mode 100644
index 0000000..46a0684
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/ServiceSlaReport.php
@@ -0,0 +1,72 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Reporting\ReportData;
+use Icinga\Module\Reporting\ReportRow;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Sql\Expression;
+use ipl\Stdlib\Filter\Rule;
+
+use function ipl\I18n\t;
+
+class ServiceSlaReport extends SlaReport
+{
+ public function getName()
+ {
+ $name = t('Service SLA');
+ if (Icinga::app()->getModuleManager()->hasEnabled('idoreports')) {
+ $name .= ' (Icinga DB)';
+ }
+
+ return $name;
+ }
+
+ protected function createReportData()
+ {
+ return (new ReportData())
+ ->setDimensions([t('Hostname'), t('Service Name')])
+ ->setValues([t('SLA in %')]);
+ }
+
+ protected function createReportRow($row)
+ {
+ if ($row->sla === null) {
+ return null;
+ }
+
+ return (new ReportRow())
+ ->setDimensions([$row->host->display_name, $row->display_name])
+ ->setValues([(float) $row->sla]);
+ }
+
+ protected function fetchSla(Timerange $timerange, Rule $filter = null)
+ {
+ $sla = Service::on($this->getDb())
+ ->columns([
+ 'host.display_name',
+ 'display_name',
+ 'sla' => new Expression(sprintf(
+ "get_sla_ok_percent(%s, %s, '%s', '%s')",
+ 'service.host_id',
+ 'service.id',
+ $timerange->getStart()->format('Uv'),
+ $timerange->getEnd()->format('Uv')
+ ))
+ ]);
+
+ $sla->resetOrderBy()->orderBy('host.display_name')->orderBy('display_name');
+
+ $this->applyRestrictions($sla);
+
+ if ($filter !== null) {
+ $sla->filter($filter);
+ }
+
+ return $sla;
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/SlaReport.php b/library/Icingadb/ProvidedHook/Reporting/SlaReport.php
new file mode 100644
index 0000000..8dcc64e
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/SlaReport.php
@@ -0,0 +1,297 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use DateInterval;
+use DatePeriod;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Reporting\Hook\ReportHook;
+use Icinga\Module\Reporting\ReportData;
+use Icinga\Module\Reporting\ReportRow;
+use Icinga\Module\Reporting\Timerange;
+use ipl\Html\Form;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter\Rule;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\EmptyState;
+
+use function ipl\I18n\t;
+
+/**
+ * Base class for host and service SLA reports
+ */
+abstract class SlaReport extends ReportHook
+{
+ use Auth;
+ use Database;
+
+ /** @var float If an SLA value is lower than the threshold, it is considered not ok */
+ const DEFAULT_THRESHOLD = 99.5;
+
+ /** @var int The amount of decimal places for the report result */
+ const DEFAULT_REPORT_PRECISION = 2;
+
+ /**
+ * Create and return a {@link ReportData} container
+ *
+ * @return ReportData Container initialized with the expected dimensions and value labels for the specific report
+ */
+ abstract protected function createReportData();
+
+ /**
+ * Create and return a {@link ReportRow}
+ *
+ * @param mixed $row Data for the row
+ *
+ * @return ReportRow|null Row with the dimensions and values for the specific report set according to the data
+ * expected in {@link createRepportData()} or null for no data
+ */
+ abstract protected function createReportRow($row);
+
+ /**
+ * Fetch SLA according to specified time range and filter
+ *
+ * @param Timerange $timerange
+ * @param Rule|null $filter
+ *
+ * @return iterable
+ */
+ abstract protected function fetchSla(Timerange $timerange, Rule $filter = null);
+
+ protected function fetchReportData(Timerange $timerange, array $config = null)
+ {
+ $rd = $this->createReportData();
+ $rows = [];
+
+ $filter = trim((string) $config['filter']) ?: '*';
+ $filter = $filter !== '*' ? QueryString::parse($filter) : null;
+
+ $interval = null;
+ $boundary = null;
+ $format = null;
+ if (isset($config['breakdown']) && $config['breakdown'] !== 'none') {
+ switch ($config['breakdown']) {
+ case 'hour':
+ $interval = new DateInterval('PT1H');
+ $format = 'H:i:s';
+ $boundary = '+1 hour';
+
+ break;
+ case 'day':
+ $interval = new DateInterval('P1D');
+ $format = 'Y-m-d';
+ $boundary = 'tomorrow midnight';
+
+ break;
+ case 'week':
+ $interval = new DateInterval('P1W');
+ $format = 'Y-\WW';
+ $boundary = 'monday next week midnight';
+
+ break;
+ case 'month':
+ $interval = new DateInterval('P1M');
+ $format = 'Y-m';
+ $boundary = 'first day of next month midnight';
+
+ break;
+ }
+
+ $dimensions = $rd->getDimensions();
+ $dimensions[] = ucfirst($config['breakdown']);
+ $rd->setDimensions($dimensions);
+
+ foreach ($this->yieldTimerange($timerange, $interval, $boundary) as list($start, $end)) {
+ foreach ($this->fetchSla(new Timerange($start, $end), $filter) as $row) {
+ $row = $this->createReportRow($row);
+
+ if ($row === null) {
+ continue;
+ }
+
+ $dimensions = $row->getDimensions();
+ $dimensions[] = $start->format($format);
+ $row->setDimensions($dimensions);
+
+ $rows[] = $row;
+ }
+ }
+ } else {
+ foreach ($this->fetchSla($timerange, $filter) as $row) {
+ $rows[] = $this->createReportRow($row);
+ }
+ }
+
+ $rd->setRows($rows);
+
+ return $rd;
+ }
+
+ /**
+ * Yield start and end times that recur at the specified interval over the given time range
+ *
+ * @param Timerange $timerange
+ * @param DateInterval $interval
+ * @param string|null $boundary English text datetime description for calculating bounds to get
+ * calendar days, weeks or months instead of relative times according to interval
+ *
+ * @return \Generator
+ */
+ protected function yieldTimerange(Timerange $timerange, DateInterval $interval, $boundary = null)
+ {
+ $start = clone $timerange->getStart();
+ $end = clone $timerange->getEnd();
+ $oneSecond = new DateInterval('PT1S');
+
+ if ($boundary !== null) {
+ $intermediate = (clone $start)->modify($boundary);
+ if ($intermediate < $end) {
+ yield [clone $start, $intermediate->sub($oneSecond)];
+
+ $start->modify($boundary);
+ }
+ }
+
+ $period = new DatePeriod($start, $interval, $end, DatePeriod::EXCLUDE_START_DATE);
+
+ foreach ($period as $date) {
+ /** @var \DateTime $date */
+ yield [$start, (clone $date)->sub($oneSecond)];
+
+ $start = $date;
+ }
+
+ yield [$start, $end];
+ }
+
+ public function initConfigForm(Form $form)
+ {
+ $form->addElement('text', 'filter', [
+ 'label' => t('Filter')
+ ]);
+
+ $form->addElement('select', 'breakdown', [
+ 'label' => t('Breakdown'),
+ 'options' => [
+ 'none' => t('None', 'SLA Report Breakdown'),
+ 'hour' => t('Hour'),
+ 'day' => t('Day'),
+ 'week' => t('Week'),
+ 'month' => t('Month')
+ ]
+ ]);
+
+ $form->addElement('number', 'threshold', [
+ 'label' => t('Threshold'),
+ 'placeholder' => static::DEFAULT_THRESHOLD,
+ 'step' => '0.01',
+ 'min' => '1',
+ 'max' => '100'
+ ]);
+
+ $form->addElement('number', 'sla_precision', [
+ 'label' => t('Amount Decimal Places'),
+ 'placeholder' => static::DEFAULT_REPORT_PRECISION,
+ 'min' => '1',
+ 'max' => '12'
+ ]);
+
+ $form->addElement('checkbox', 'export_total', [
+ 'label' => t('Export Total Averages'),
+ 'description' => t('Export total averages to CSV and JSON'),
+ // Instead of y/n, 0/1 can be implicitly cast to bool which is done where the config is actually used.
+ 'checkedValue' => '1',
+ 'uncheckedValue' => '0'
+ ]);
+ }
+
+ public function getData(Timerange $timerange, array $config = null)
+ {
+ return $this->fetchReportData($timerange, $config);
+ }
+
+ public function getHtml(Timerange $timerange, array $config = null)
+ {
+ $data = $this->getData($timerange, $config);
+
+ if (! count($data)) {
+ return new EmptyState(t('No data found.'));
+ }
+
+ $threshold = isset($config['threshold']) ? (float) $config['threshold'] : static::DEFAULT_THRESHOLD;
+
+ $tableHeaderCells = [];
+
+ foreach ($data->getDimensions() as $dimension) {
+ $tableHeaderCells[] = Html::tag('th', null, $dimension);
+ }
+
+ foreach ($data->getValues() as $value) {
+ $tableHeaderCells[] = Html::tag('th', null, $value);
+ }
+
+ $tableRows = [];
+ $precision = $config['sla_precision'] ?? static::DEFAULT_REPORT_PRECISION;
+
+ foreach ($data->getRows() as $row) {
+ $cells = [];
+
+ foreach ($row->getDimensions() as $dimension) {
+ $cells[] = Html::tag('td', null, $dimension);
+ }
+
+ // We only have one metric
+ $sla = $row->getValues()[0];
+
+ if ($sla < $threshold) {
+ $slaClass = 'nok';
+ } else {
+ $slaClass = 'ok';
+ }
+
+ $cells[] = Html::tag('td', ['class' => "sla-column $slaClass"], round($sla, $precision));
+
+ $tableRows[] = Html::tag('tr', null, $cells);
+ }
+
+ // We only have one average
+ $average = $data->getAverages()[0];
+
+ if ($average < $threshold) {
+ $slaClass = 'nok';
+ } else {
+ $slaClass = 'ok';
+ }
+
+ $total = $this instanceof HostSlaReport
+ ? sprintf(t('Total (%d Hosts)'), $data->count())
+ : sprintf(t('Total (%d Services)'), $data->count());
+
+ $tableRows[] = Html::tag('tr', null, [
+ Html::tag('td', ['colspan' => count($data->getDimensions())], $total),
+ Html::tag('td', ['class' => "sla-column $slaClass"], round($average, $precision))
+ ]);
+
+ $table = Html::tag(
+ 'table',
+ ['class' => 'common-table sla-table'],
+ [
+ Html::tag(
+ 'thead',
+ null,
+ Html::tag(
+ 'tr',
+ null,
+ $tableHeaderCells
+ )
+ ),
+ Html::tag('tbody', null, $tableRows)
+ ]
+ );
+
+ return $table;
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php
new file mode 100644
index 0000000..b09ffb7
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/TotalHostSlaReport.php
@@ -0,0 +1,19 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use Icinga\Module\Icingadb\Hook\Common\TotalSlaReportUtils;
+
+use function ipl\I18n\t;
+
+class TotalHostSlaReport extends HostSlaReport
+{
+ use TotalSlaReportUtils;
+
+ public function getName()
+ {
+ return t('Total Host SLA');
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php b/library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php
new file mode 100644
index 0000000..e5ebf57
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/Reporting/TotalServiceSlaReport.php
@@ -0,0 +1,19 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\Reporting;
+
+use Icinga\Module\Icingadb\Hook\Common\TotalSlaReportUtils;
+
+use function ipl\I18n\t;
+
+class TotalServiceSlaReport extends ServiceSlaReport
+{
+ use TotalSlaReportUtils;
+
+ public function getName()
+ {
+ return t('Total Service SLA');
+ }
+}
diff --git a/library/Icingadb/ProvidedHook/X509/Sni.php b/library/Icingadb/ProvidedHook/X509/Sni.php
new file mode 100644
index 0000000..6f20a7d
--- /dev/null
+++ b/library/Icingadb/ProvidedHook/X509/Sni.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\ProvidedHook\X509;
+
+use Generator;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\X509\Hook\SniHook;
+use ipl\Web\Filter\QueryString;
+
+class Sni extends SniHook
+{
+ use Auth;
+ use Database;
+
+ /**
+ * @inheritDoc
+ */
+ public function getHosts(Filter $filter = null): Generator
+ {
+ $this->getDb()->ping();
+
+ $queryHost = Host::on($this->getDb())
+ ->columns([
+ 'host_name' => 'name',
+ 'host_address' => 'address',
+ 'host_address6' => 'address6'
+ ]);
+
+ $this->applyRestrictions($queryHost);
+
+ if ($filter !== null) {
+ $queryString = $filter->toQueryString();
+ $filterCondition = QueryString::parse($queryString);
+ $queryHost->filter($filterCondition);
+ }
+
+ $hosts = $this->getDb()->select($queryHost->assembleSelect());
+
+ /** @var Host $host */
+ foreach ($hosts as $host) {
+ if (! empty($host->host_address)) {
+ yield $host->host_address => $host->host_name;
+ }
+
+ if (! empty($host->host_address6)) {
+ yield $host->host_address6 => $host->host_name;
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Redis/VolatileStateResults.php b/library/Icingadb/Redis/VolatileStateResults.php
new file mode 100644
index 0000000..9418398
--- /dev/null
+++ b/library/Icingadb/Redis/VolatileStateResults.php
@@ -0,0 +1,170 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Redis;
+
+use Icinga\Application\Benchmark;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\IcingaRedis;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Orm\Query;
+use ipl\Orm\Resolver;
+use ipl\Orm\ResultSet;
+use RuntimeException;
+
+class VolatileStateResults extends ResultSet
+{
+ use Auth;
+
+ /** @var Resolver */
+ private $resolver;
+
+ /** @var bool Whether Redis is unavailable */
+ private $redisUnavailable;
+
+ /** @var bool Whether Redis updates were applied */
+ private $updatesApplied = false;
+
+ public static function fromQuery(Query $query)
+ {
+ $self = parent::fromQuery($query);
+ $self->resolver = $query->getResolver();
+ $self->redisUnavailable = IcingaRedis::isUnavailable();
+
+ return $self;
+ }
+
+ /**
+ * Get whether Redis is unavailable
+ *
+ * @return bool
+ */
+ public function isRedisUnavailable(): bool
+ {
+ return $this->redisUnavailable;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ if (! $this->redisUnavailable && ! $this->updatesApplied && ! $this->isCacheDisabled) {
+ $this->rewind();
+ }
+
+ return parent::current();
+ }
+
+ public function next(): void
+ {
+ parent::next();
+
+ if (! $this->redisUnavailable && $this->isCacheDisabled && $this->valid()) {
+ $this->applyRedisUpdates([parent::current()]);
+ }
+ }
+
+ public function key(): int
+ {
+ if (! $this->redisUnavailable && ! $this->updatesApplied && ! $this->isCacheDisabled) {
+ $this->rewind();
+ }
+
+ return parent::key();
+ }
+
+ public function rewind(): void
+ {
+ if (! $this->redisUnavailable && ! $this->updatesApplied && ! $this->isCacheDisabled) {
+ $this->updatesApplied = true;
+ $this->advance();
+
+ Benchmark::measure('Applying Redis updates');
+ $this->applyRedisUpdates($this);
+ Benchmark::measure('Redis updates applied');
+ }
+
+ parent::rewind();
+ }
+
+ /**
+ * Apply redis state details to the given results
+ *
+ * @param self|array<int, mixed> $rows
+ *
+ * @return void
+ */
+ protected function applyRedisUpdates($rows)
+ {
+ $type = null;
+ $behaviors = null;
+
+ $keys = [];
+ $hostStateKeys = [];
+
+ $showSourceGranted = $this->getAuth()->hasPermission('icingadb/object/show-source');
+
+ $states = [];
+ $hostStates = [];
+ foreach ($rows as $row) {
+ if ($type === null) {
+ $behaviors = $this->resolver->getBehaviors($row->state);
+
+ switch (true) {
+ case $row instanceof Host:
+ $type = 'host';
+ break;
+ case $row instanceof Service:
+ $type = 'service';
+ break;
+ default:
+ throw new RuntimeException('Volatile states can only be fetched for hosts and services');
+ }
+ }
+
+ $states[bin2hex($row->id)] = $row->state;
+ if (empty($keys)) {
+ $keys = $row->state->getColumns();
+ if (! $showSourceGranted) {
+ $keys = array_diff($keys, ['check_commandline']);
+ }
+ }
+
+ if ($type === 'service' && $row->host instanceof Host) {
+ $hostStates[bin2hex($row->host->id)] = $row->host->state;
+ if (empty($hostStateKeys)) {
+ $hostStateKeys = $row->host->state->getColumns();
+ }
+ }
+ }
+
+ if (empty($states)) {
+ return;
+ }
+
+ if ($type === 'service') {
+ $results = IcingaRedis::fetchServiceState(array_keys($states), $keys);
+ } else {
+ $results = IcingaRedis::fetchHostState(array_keys($states), $keys);
+ }
+
+ foreach ($results as $id => $data) {
+ foreach ($data as $key => $value) {
+ $data[$key] = $behaviors->retrieveProperty($value, $key);
+ }
+
+ $states[$id]->setProperties($data);
+ }
+
+ if ($type === 'service' && ! empty($hostStates)) {
+ foreach (IcingaRedis::fetchHostState(array_keys($hostStates), $hostStateKeys) as $id => $data) {
+ foreach ($data as $key => $value) {
+ $data[$key] = $behaviors->retrieveProperty($value, $key);
+ }
+
+ $hostStates[$id]->setProperties($data);
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Setup/ApiTransportPage.php b/library/Icingadb/Setup/ApiTransportPage.php
new file mode 100644
index 0000000..e727e99
--- /dev/null
+++ b/library/Icingadb/Setup/ApiTransportPage.php
@@ -0,0 +1,128 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransport;
+use Icinga\Module\Icingadb\Command\Transport\CommandTransportException;
+use Icinga\Web\Form;
+
+class ApiTransportPage extends Form
+{
+ public function init()
+ {
+ $this->setName('setup_icingadb_api_transport');
+ $this->setTitle(t('Icinga 2 API'));
+ $this->addDescription(t(
+ 'Please fill out the connection details to the Icinga 2 API.'
+ ));
+ $this->setValidatePartial(true);
+ }
+
+ public function createElements(array $formData)
+ {
+ if (isset($formData['skip_validation']) && $formData['skip_validation']) {
+ // In case another error occured and the checkbox was displayed before
+ $this->addSkipValidationCheckbox();
+ } else {
+ $this->addElement('hidden', 'skip_validation', ['value' => 0]);
+ }
+
+ $this->addElement('hidden', 'transport', [
+ 'required' => true,
+ 'disabled' => true,
+ 'value' => 'api'
+ ]);
+ $this->addElement('hidden', 'name', [
+ 'required' => true,
+ 'disabled' => true,
+ 'value' => 'icinga2'
+ ]);
+ $this->addElement('text', 'host', [
+ 'required' => true,
+ 'label' => t('Host'),
+ 'description' => t('Hostname or address of the Icinga master')
+ ]);
+ $this->addElement('number', 'port', [
+ 'required' => true,
+ 'label' => t('Port'),
+ 'value' => 5665,
+ 'min' => 1,
+ 'max' => 65536
+ ]);
+ $this->addElement('text', 'username', [
+ 'required' => true,
+ 'label' => t('API Username'),
+ 'description' => t('User to authenticate with using HTTP Basic Auth')
+ ]);
+ $this->addElement('password', 'password', [
+ 'required' => true,
+ 'renderPassword' => true,
+ 'label' => t('API Password'),
+ 'autocomplete' => 'new-password'
+ ]);
+ }
+
+ public function isValid($formData)
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (! isset($formData['skip_validation']) || !$formData['skip_validation']) {
+ if (! $this->validateConfiguration()) {
+ $this->addSkipValidationCheckbox();
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function isValidPartial(array $formData)
+ {
+ if (isset($formData['backend_validation']) && parent::isValid($formData)) {
+ if (! $this->validateConfiguration()) {
+ return false;
+ }
+
+ $this->info(t('The configuration has been successfully validated.'));
+ } elseif (! isset($formData['backend_validation'])) {
+ // This is usually done by isValid(Partial), but as we're not calling any of these...
+ $this->populate($formData);
+ }
+
+ return true;
+ }
+
+ protected function validateConfiguration(): bool
+ {
+ try {
+ CommandTransport::createTransport(new ConfigObject($this->getValues()))->probe();
+ } catch (CommandTransportException $e) {
+ $this->error(sprintf(
+ t('Failed to successfully validate the configuration: %s'),
+ $e->getMessage()
+ ));
+
+ return false;
+ }
+
+ return true;
+ }
+
+ protected function addSkipValidationCheckbox()
+ {
+ $this->addElement(
+ 'checkbox',
+ 'skip_validation',
+ [
+ 'ignore' => true,
+ 'label' => t('Skip Validation'),
+ 'description' => t('Check this to not to validate the configuration')
+ ]
+ );
+ }
+}
diff --git a/library/Icingadb/Setup/ApiTransportStep.php b/library/Icingadb/Setup/ApiTransportStep.php
new file mode 100644
index 0000000..1e2e905
--- /dev/null
+++ b/library/Icingadb/Setup/ApiTransportStep.php
@@ -0,0 +1,102 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Setup\Step;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+
+class ApiTransportStep extends Step
+{
+ /** @var array */
+ protected $data;
+
+ /** @var Exception */
+ protected $error;
+
+ public function __construct(array $data)
+ {
+ $this->data = $data;
+ }
+
+ public function apply()
+ {
+ $transportConfig = $this->data;
+ $transportName = $transportConfig['name'];
+ unset($transportConfig['name']);
+
+ try {
+ $config = Config::module('icingadb', 'commandtransports', true);
+ $config->setSection($transportName, $transportConfig);
+ $config->saveIni();
+ } catch (Exception $e) {
+ $this->error = $e;
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getSummary()
+ {
+ $description = new HtmlElement('p', null, Text::create(mt(
+ 'icingadb',
+ 'The Icinga 2 API will be accessed using the following connection details:'
+ )));
+
+ $apiOptions = new Table();
+ $apiOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Host'))),
+ $this->data['host']
+ ]));
+ $apiOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Port'))),
+ $this->data['port']
+ ]));
+ $apiOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Username'))),
+ $this->data['username']
+ ]));
+ $apiOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Password'))),
+ str_repeat('*', strlen($this->data['password']))
+ ]));
+
+ $topic = new HtmlElement('div', Attributes::create(['class' => 'topic']));
+ $topic->addHtml($description, $apiOptions);
+
+ $summary = new HtmlDocument();
+ $summary->addHtml(
+ new HtmlElement('h2', null, Text::create(mt('icingadb', 'Icinga 2 API'))),
+ $topic
+ );
+
+ return $summary->render();
+ }
+
+ public function getReport()
+ {
+ if ($this->error === null) {
+ return [sprintf(
+ mt('icingadb', 'Commandtransport configuration update successful: %s'),
+ Config::module('icingadb', 'commandtransports')->getConfigFile()
+ )];
+ } else {
+ return [
+ sprintf(
+ mt('icingadb', 'Commandtransport configuration update failed: %s'),
+ Config::module('icingadb', 'commandtransports')->getConfigFile()
+ ),
+ sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error))
+ ];
+ }
+ }
+}
diff --git a/library/Icingadb/Setup/DbResourcePage.php b/library/Icingadb/Setup/DbResourcePage.php
new file mode 100644
index 0000000..cc99dcc
--- /dev/null
+++ b/library/Icingadb/Setup/DbResourcePage.php
@@ -0,0 +1,145 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Forms\Config\ResourceConfigForm;
+use Icinga\Forms\Config\Resource\DbResourceForm;
+use Icinga\Web\Form;
+
+class DbResourcePage extends Form
+{
+ public function init()
+ {
+ $this->setName('setup_icingadb_resource');
+ $this->setTitle(t('Icinga DB Resource'));
+ $this->addDescription(t(
+ 'Please fill out the connection details below to access Icinga DB.'
+ ));
+ $this->setValidatePartial(true);
+ }
+
+ public function createElements(array $formData)
+ {
+ $this->addElement(
+ 'hidden',
+ 'type',
+ [
+ 'required' => true,
+ 'disabled' => true,
+ 'value' => 'db'
+ ]
+ );
+
+ if (isset($formData['skip_validation']) && $formData['skip_validation']) {
+ // In case another error occured and the checkbox was displayed before
+ $this->addSkipValidationCheckbox();
+ } else {
+ $this->addElement('hidden', 'skip_validation', ['value' => 0]);
+ }
+
+ $dbResourceForm = new DbResourceForm();
+ $this->addElements($dbResourceForm->createElements($formData)->getElements());
+ $this->getElement('name')->setValue('icingadb');
+ $this->getElement('db')->setMultiOptions([
+ 'mysql' => 'MySQL',
+ 'pgsql' => 'PostgreSQL'
+ ]);
+
+ $this->removeElement('name');
+ $this->addElement(
+ 'hidden',
+ 'name',
+ [
+ 'required' => true,
+ 'disabled' => true,
+ 'value' => 'icingadb'
+ ]
+ );
+
+ if (! isset($formData['db']) || $formData['db'] === 'mysql') {
+ $this->getElement('charset')->setValue('utf8mb4');
+ }
+ }
+
+ public function isValid($formData)
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (! isset($formData['skip_validation']) || !$formData['skip_validation']) {
+ if (! $this->validateConfiguration()) {
+ $this->addSkipValidationCheckbox();
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function isValidPartial(array $formData)
+ {
+ if (isset($formData['backend_validation']) && parent::isValid($formData)) {
+ if (! $this->validateConfiguration(true)) {
+ return false;
+ }
+
+ $this->info(t('The configuration has been successfully validated.'));
+ } elseif (! isset($formData['backend_validation'])) {
+ // This is usually done by isValid(Partial), but as we're not calling any of these...
+ $this->populate($formData);
+ }
+
+ return true;
+ }
+
+ protected function validateConfiguration(bool $showLog = false): bool
+ {
+ $inspection = ResourceConfigForm::inspectResource($this);
+ if ($inspection !== null) {
+ if ($showLog) {
+ $join = function ($e) use (&$join) {
+ return is_string($e) ? $e : join("\n", array_map($join, $e));
+ };
+ $this->addElement(
+ 'note',
+ 'inspection_output',
+ [
+ 'order' => 0,
+ 'value' => '<strong>' . t('Validation Log') . "</strong>\n\n"
+ . join("\n", array_map($join, $inspection->toArray())),
+ 'decorators' => [
+ 'ViewHelper',
+ ['HtmlTag', ['tag' => 'pre', 'class' => 'log-output']],
+ ]
+ ]
+ );
+ }
+
+ if ($inspection->hasError()) {
+ $this->error(sprintf(
+ t('Failed to successfully validate the configuration: %s'),
+ $inspection->getError()
+ ));
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ protected function addSkipValidationCheckbox()
+ {
+ $this->addElement(
+ 'checkbox',
+ 'skip_validation',
+ [
+ 'ignore' => true,
+ 'label' => t('Skip Validation'),
+ 'description' => t('Check this to not to validate the configuration')
+ ]
+ );
+ }
+}
diff --git a/library/Icingadb/Setup/DbResourceStep.php b/library/Icingadb/Setup/DbResourceStep.php
new file mode 100644
index 0000000..970d367
--- /dev/null
+++ b/library/Icingadb/Setup/DbResourceStep.php
@@ -0,0 +1,148 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Setup\Step;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+
+class DbResourceStep extends Step
+{
+ /** @var array */
+ protected $data;
+
+ /** @var Exception */
+ protected $error;
+
+ public function __construct(array $data)
+ {
+ $this->data = $data;
+ }
+
+ public function apply()
+ {
+ $resourceConfig = $this->data;
+ $resourceName = $resourceConfig['name'];
+ unset($resourceConfig['name']);
+
+ try {
+ $config = Config::app('resources', true);
+ $config->setSection($resourceName, $resourceConfig);
+ $config->saveIni();
+ } catch (Exception $e) {
+ $this->error = $e;
+ return false;
+ }
+
+ try {
+ $config = Config::module('icingadb', 'config', true);
+ $config->setSection('icingadb', ['resource' => $resourceName]);
+ $config->saveIni();
+ } catch (Exception $e) {
+ $this->error = $e;
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getSummary()
+ {
+ $description = new HtmlElement('p', null, Text::create(mt(
+ 'icingadb',
+ 'Icinga DB will be accessed using the following connection details:'
+ )));
+
+ $resourceOptions = new Table();
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Host'))),
+ $this->data['host']
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Port'))),
+ $this->data['port'] ?: ($this->data['db'] === 'mysql' ? 3306 : 5432)
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Database'))),
+ $this->data['dbname']
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Username'))),
+ $this->data['username']
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Password'))),
+ str_repeat('*', strlen($this->data['password']))
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Charset'))),
+ $this->data['charset']
+ ]));
+
+ if (isset($this->data['use_ssl']) && $this->data['use_ssl']) {
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL Do Not Verify Server Certificate'))),
+ isset($this->data['ssl_do_not_verify_server_cert']) && $this->data['ssl_do_not_verify_server_cert']
+ ? t('Yes')
+ : t('No')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL Key'))),
+ $this->data['ssl_key'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL Certificate'))),
+ $this->data['ssl_cert'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL CA'))),
+ $this->data['ssl_ca'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('The CA certificate file path'))),
+ $this->data['ssl_capath'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ $resourceOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('SSL CA Path'))),
+ $this->data['ssl_cipher'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+ }
+
+ $topic = new HtmlElement('div', Attributes::create(['class' => 'topic']));
+ $topic->addHtml($description, $resourceOptions);
+
+ $summary = new HtmlDocument();
+ $summary->addHtml(
+ new HtmlElement('h2', null, Text::create(mt('icingadb', 'Icinga DB Resource'))),
+ $topic
+ );
+
+ return $summary->render();
+ }
+
+ public function getReport()
+ {
+ if ($this->error === null) {
+ return [sprintf(
+ mt('icingadb', 'Resource configuration update successful: %s'),
+ Config::resolvePath('resources.ini')
+ )];
+ } else {
+ return [
+ sprintf(
+ mt('icingadb', 'Resource configuration update failed: %s'),
+ Config::resolvePath('resources.ini')
+ ),
+ sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error))
+ ];
+ }
+ }
+}
diff --git a/library/Icingadb/Setup/IcingaDbWizard.php b/library/Icingadb/Setup/IcingaDbWizard.php
new file mode 100644
index 0000000..f99f240
--- /dev/null
+++ b/library/Icingadb/Setup/IcingaDbWizard.php
@@ -0,0 +1,89 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Setup\Forms\SummaryPage;
+use Icinga\Module\Setup\Requirement\PhpModuleRequirement;
+use Icinga\Module\Setup\Requirement\WebLibraryRequirement;
+use Icinga\Module\Setup\RequirementSet;
+use Icinga\Module\Setup\Setup;
+use Icinga\Module\Setup\SetupWizard;
+use Icinga\Web\Form;
+use Icinga\Web\Request;
+use Icinga\Web\Wizard;
+
+class IcingaDbWizard extends Wizard implements SetupWizard
+{
+ protected function init()
+ {
+ $this->addPage(new WelcomePage());
+ $this->addPage(new DbResourcePage());
+ $this->addPage(new RedisPage());
+ $this->addPage(new ApiTransportPage());
+ $this->addPage(new SummaryPage(['name' => 'setup_icingadb_summary']));
+ }
+
+ public function setupPage(Form $page, Request $request)
+ {
+ if ($page->getName() === 'setup_icingadb_summary') {
+ $page->setSummary($this->getSetup()->getSummary());
+ $page->setSubjectTitle('Icinga DB Web');
+ }
+ }
+
+ public function getSetup()
+ {
+ $pageData = $this->getPageData();
+ $setup = new Setup();
+
+ $setup->addStep(new DbResourceStep($pageData['setup_icingadb_resource']));
+ $setup->addStep(new RedisStep($pageData['setup_icingadb_redis']));
+ $setup->addStep(new ApiTransportStep($pageData['setup_icingadb_api_transport']));
+
+ return $setup;
+ }
+
+ public function getRequirements()
+ {
+ $set = new RequirementSet();
+
+ $requiredVersions = Icinga::app()->getModuleManager()->getModule('icingadb')->getRequiredLibraries();
+
+ $set->add(new WebLibraryRequirement([
+ 'condition' => ['icinga-php-library', '', $requiredVersions['icinga-php-library']],
+ 'alias' => 'Icinga PHP library',
+ 'description' => t('The Icinga PHP library (IPL) is required for Icinga DB Web')
+ ]));
+
+ $set->add(new WebLibraryRequirement([
+ 'condition' => ['icinga-php-thirdparty', '', $requiredVersions['icinga-php-thirdparty']],
+ 'alias' => 'Icinga PHP Thirdparty',
+ 'description' => t('The Icinga PHP Thirdparty library is required for Icinga DB Web')
+ ]));
+
+ $set->add(new PhpModuleRequirement([
+ 'condition' => 'libxml',
+ 'alias' => 'libxml',
+ 'description' => t('For check plugins that output HTML the libxml extension is required')
+ ]));
+
+ $set->add(new PhpModuleRequirement([
+ 'condition' => 'dom',
+ 'alias' => 'dom',
+ 'description' => t('For check plugins that output HTML the dom extension is required')
+ ]));
+
+ $set->add(new PhpModuleRequirement([
+ 'condition' => 'curl',
+ 'alias' => 'cURL',
+ 'description' => t(
+ 'To send external commands over Icinga 2\'s API, the cURL module for PHP is required.'
+ )
+ ]));
+
+ return $set;
+ }
+}
diff --git a/library/Icingadb/Setup/RedisPage.php b/library/Icingadb/Setup/RedisPage.php
new file mode 100644
index 0000000..3c0a741
--- /dev/null
+++ b/library/Icingadb/Setup/RedisPage.php
@@ -0,0 +1,68 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Module\Icingadb\Forms\RedisConfigForm;
+use Icinga\Web\Form;
+
+class RedisPage extends Form
+{
+ public function init()
+ {
+ $this->setName('setup_icingadb_redis');
+ $this->setTitle(t('Icinga DB Redis'));
+ $this->addDescription(t(
+ 'Please fill out the connection details to access the Icinga DB Redis.'
+ ));
+ $this->setValidatePartial(true);
+ }
+
+ public function createElements(array $formData)
+ {
+ $redisConfigForm = new RedisConfigForm();
+ $redisConfigForm->createElements($formData);
+ if (isset($formData['redis_tls']) && $formData['redis_tls']) {
+ $redisConfigForm->getElement('redis_ca_pem')->setIgnore(false);
+ $redisConfigForm->getElement('redis_cert_pem')->setIgnore(false);
+ $redisConfigForm->getElement('redis_key_pem')->setIgnore(false);
+ }
+
+ $this->addElements($redisConfigForm->getElements());
+ $this->addDisplayGroups($redisConfigForm->getDisplayGroups());
+ }
+
+ public function isValid($formData)
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (($el = $this->getElement('skip_validation')) === null || ! $el->isChecked()) {
+ if (! RedisConfigForm::checkRedis($this)) {
+ if ($el === null) {
+ RedisConfigForm::addSkipValidationCheckbox($this);
+ RedisConfigForm::addInsecureCheckboxIfTls($this);
+ }
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function isValidPartial(array $formData)
+ {
+ if (! parent::isValidPartial($formData)) {
+ return false;
+ }
+
+ if (isset($formData['backend_validation'])) {
+ return RedisConfigForm::checkRedis($this);
+ }
+
+ return true;
+ }
+}
diff --git a/library/Icingadb/Setup/RedisStep.php b/library/Icingadb/Setup/RedisStep.php
new file mode 100644
index 0000000..97e50e0
--- /dev/null
+++ b/library/Icingadb/Setup/RedisStep.php
@@ -0,0 +1,205 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotWritableError;
+use Icinga\File\Storage\LocalFileStorage;
+use Icinga\Module\Setup\Step;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Table;
+use ipl\Html\Text;
+
+class RedisStep extends Step
+{
+ /** @var array */
+ protected $data;
+
+ /** @var Exception */
+ protected $error;
+
+ public function __construct(array $data)
+ {
+ $this->data = $data;
+ }
+
+ public function apply()
+ {
+ $moduleConfig = [
+ 'redis' => [
+ 'tls' => 0
+ ]
+ ];
+ $redisConfig = [
+ 'redis1' => [
+ 'host' => $this->data['redis1_host'],
+ 'port' => $this->data['redis1_port'] ?: null,
+ 'password' => $this->data['redis1_password'] ?: null
+ ]
+ ];
+ if (isset($this->data['redis2_host']) && $this->data['redis2_host']) {
+ $redisConfig['redis2'] = [
+ 'host' => $this->data['redis2_host'],
+ 'port' => $this->data['redis2_port'] ?: null,
+ 'password' => $this->data['redis2_password'] ?: null
+ ];
+ }
+
+ if (isset($this->data['redis_tls']) && $this->data['redis_tls']) {
+ $moduleConfig['redis']['tls'] = 1;
+ if (isset($this->data['redis_insecure']) && $this->data['redis_insecure']) {
+ $moduleConfig['redis']['insecure'] = 1;
+ }
+
+ $storage = new LocalFileStorage(Icinga::app()->getStorageDir(
+ join(DIRECTORY_SEPARATOR, ['modules', 'icingadb', 'redis'])
+ ));
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $textareaName = 'redis_' . $name . '_pem';
+ if (isset($this->data[$textareaName]) && $this->data[$textareaName]) {
+ $pem = $this->data[$textareaName];
+ $pemFile = md5($pem) . '-' . $name . '.pem';
+ if (! $storage->has($pemFile)) {
+ try {
+ $storage->create($pemFile, $pem);
+ } catch (NotWritableError $e) {
+ $this->error = $e;
+ return false;
+ }
+ }
+
+ $moduleConfig['redis'][$name] = $storage->resolvePath($pemFile);
+ }
+ }
+ }
+
+ try {
+ $config = Config::module('icingadb', 'config', true);
+ foreach ($moduleConfig as $section => $options) {
+ $config->setSection($section, $options);
+ }
+
+ $config->saveIni();
+
+ $config = Config::module('icingadb', 'redis', true);
+ foreach ($redisConfig as $section => $options) {
+ $config->setSection($section, $options);
+ }
+
+ $config->saveIni();
+ } catch (Exception $e) {
+ $this->error = $e;
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getSummary()
+ {
+ $topic = new HtmlElement('div', Attributes::create(['class' => 'topic']));
+ $topic->addHtml(new HtmlElement('p', null, Text::create(mt(
+ 'icingadb',
+ 'The Icinga DB Redis will be accessed using the following connection details:'
+ ))));
+
+ $primaryOptions = new Table();
+ $primaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Host'))),
+ $this->data['redis1_host']
+ ]));
+ $primaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Port'))),
+ $this->data['redis1_port'] ?: 6380
+ ]));
+ $primaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Password'))),
+ $this->data['redis1_password'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+
+ if (isset($this->data['redis2_host']) && $this->data['redis2_host']) {
+ $topic->addHtml(
+ new HtmlElement('h3', null, Text::create(mt('icingadb', 'Primary'))),
+ $primaryOptions
+ );
+
+ $secondaryOptions = new Table();
+ $secondaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Host'))),
+ $this->data['redis2_host']
+ ]));
+ $secondaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Port'))),
+ $this->data['redis2_port'] ?: 6380
+ ]));
+ $secondaryOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create(t('Password'))),
+ $this->data['redis2_password'] ?: mt('icingadb', 'None', 'non-existence of a value')
+ ]));
+
+ $topic->addHtml(
+ new HtmlElement('h3', null, Text::create(mt('icingadb', 'Secondary'))),
+ $secondaryOptions
+ );
+ } else {
+ $topic->addHtml($primaryOptions);
+ }
+
+ $tlsOptions = new Table();
+ $topic->addHtml($tlsOptions);
+ if (isset($this->data['redis_tls']) && $this->data['redis_tls']) {
+ if (isset($this->data['redis_cert_pem']) && $this->data['redis_cert_pem']) {
+ $tlsOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create('TLS')),
+ Text::create(
+ t('Icinga DB Web will authenticate against Redis with a client'
+ . ' certificate and private key over a secured connection')
+ )
+ ]));
+ } else {
+ $tlsOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create('TLS')),
+ Text::create(t('Icinga DB Web will use secured Redis connections'))
+ ]));
+ }
+ } else {
+ $tlsOptions->addHtml(Table::row([
+ new HtmlElement('strong', null, Text::create('TLS')),
+ Text::create(t('No'))
+ ]));
+ }
+
+ $summary = new HtmlDocument();
+ $summary->addHtml(
+ new HtmlElement('h2', null, Text::create(mt('icingadb', 'Icinga DB Redis'))),
+ $topic
+ );
+
+ return $summary->render();
+ }
+
+ public function getReport()
+ {
+ if ($this->error === null) {
+ return [sprintf(
+ mt('icingadb', 'Module configuration update successful: %s'),
+ Config::module('icingab')->getConfigFile()
+ )];
+ } else {
+ return [
+ sprintf(
+ mt('icingadb', 'Module configuration update failed: %s'),
+ Config::module('icingab')->getConfigFile()
+ ),
+ sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error))
+ ];
+ }
+ }
+}
diff --git a/library/Icingadb/Setup/WelcomePage.php b/library/Icingadb/Setup/WelcomePage.php
new file mode 100644
index 0000000..9f97c7d
--- /dev/null
+++ b/library/Icingadb/Setup/WelcomePage.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Setup;
+
+use Icinga\Web\Form;
+
+class WelcomePage extends Form
+{
+ public function init()
+ {
+ $this->setName('setup_icingadb_welcome');
+ }
+
+ public function createElements(array $formData)
+ {
+ $this->addElement(
+ 'note',
+ 'welcome',
+ array(
+ 'value' => t(
+ 'Welcome to the configuration of Icinga DB Web!'
+ ),
+ 'decorators' => array(
+ 'ViewHelper',
+ array('HtmlTag', array('tag' => 'h2'))
+ )
+ )
+ );
+
+ $this->addElement(
+ 'note',
+ 'description_1',
+ array(
+ 'value' => '<p>' . t(
+ 'Icinga DB Web is the UI for Icinga DB and provides'
+ . ' a graphical interface to your monitoring environment.'
+ ) . '</p>',
+ 'decorators' => array('ViewHelper')
+ )
+ );
+
+ $this->addElement(
+ 'note',
+ 'description_2',
+ array(
+ 'value' => '<p>' . t(
+ 'The wizard will guide you through the configuration to'
+ . ' establish a connection with Icinga DB and Icinga 2.'
+ ) . '</p>',
+ 'decorators' => array('ViewHelper')
+ )
+ );
+ }
+}
diff --git a/library/Icingadb/Util/FeatureStatus.php b/library/Icingadb/Util/FeatureStatus.php
new file mode 100644
index 0000000..94bf6d4
--- /dev/null
+++ b/library/Icingadb/Util/FeatureStatus.php
@@ -0,0 +1,50 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use ArrayObject;
+use Icinga\Module\Icingadb\Command\Object\ToggleObjectFeatureCommand;
+
+class FeatureStatus extends ArrayObject
+{
+ public function __construct(string $type, $summary)
+ {
+ $prefix = "{$type}s";
+
+ $featureStatus = [
+ ToggleObjectFeatureCommand::FEATURE_ACTIVE_CHECKS =>
+ $this->getFeatureStatus('active_checks_enabled', $prefix, $summary),
+ ToggleObjectFeatureCommand::FEATURE_PASSIVE_CHECKS =>
+ $this->getFeatureStatus('passive_checks_enabled', $prefix, $summary),
+ ToggleObjectFeatureCommand::FEATURE_NOTIFICATIONS =>
+ $this->getFeatureStatus('notifications_enabled', $prefix, $summary),
+ ToggleObjectFeatureCommand::FEATURE_EVENT_HANDLER =>
+ $this->getFeatureStatus('event_handler_enabled', $prefix, $summary),
+ ToggleObjectFeatureCommand::FEATURE_FLAP_DETECTION =>
+ $this->getFeatureStatus('flapping_enabled', $prefix, $summary)
+ ];
+
+ parent::__construct($featureStatus, ArrayObject::ARRAY_AS_PROPS);
+ }
+
+ protected function getFeatureStatus(string $feature, string $prefix, $summary): int
+ {
+ $key = "{$prefix}_{$feature}";
+ $value = (int) $summary->$key;
+
+ if ($value === 0) {
+ return 0;
+ }
+
+ $totalKey = "{$prefix}_total";
+ $total = (int) $summary->$totalKey;
+
+ if ($value === $total) {
+ return 1;
+ }
+
+ return 2;
+ }
+}
diff --git a/library/Icingadb/Util/ObjectSuggestionsCursor.php b/library/Icingadb/Util/ObjectSuggestionsCursor.php
new file mode 100644
index 0000000..0013b35
--- /dev/null
+++ b/library/Icingadb/Util/ObjectSuggestionsCursor.php
@@ -0,0 +1,25 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use ipl\Sql\Cursor;
+use Iterator;
+
+class ObjectSuggestionsCursor extends Cursor
+{
+ public function getIterator(): \Traversable
+ {
+ foreach (parent::getIterator() as $key => $value) {
+ // TODO(lippserd): This is a quick and dirty fix for PostgreSQL binary datatypes for which PDO returns
+ // PHP resources that would cause exceptions since resources are not a valid type for attribute values.
+ // We need to do it this way as the suggestion implementation bypasses ORM behaviors here and there.
+ if (is_resource($value)) {
+ $value = stream_get_contents($value);
+ }
+
+ yield $key => $value;
+ }
+ }
+}
diff --git a/library/Icingadb/Util/PerfData.php b/library/Icingadb/Util/PerfData.php
new file mode 100644
index 0000000..cc33d16
--- /dev/null
+++ b/library/Icingadb/Util/PerfData.php
@@ -0,0 +1,703 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Web\Widget\Chart\InlinePie;
+use InvalidArgumentException;
+use LogicException;
+
+class PerfData
+{
+ const PERFDATA_OK = 'ok';
+ const PERFDATA_WARNING = 'warning';
+ const PERFDATA_CRITICAL = 'critical';
+
+ /**
+ * The performance data value being parsed
+ *
+ * @var string
+ */
+ protected $perfdataValue;
+
+ /**
+ * Unit of measurement (UOM)
+ *
+ * @var string
+ */
+ protected $unit;
+
+ /**
+ * The label
+ *
+ * @var string
+ */
+ protected $label;
+
+ /**
+ * The value
+ *
+ * @var ?float
+ */
+ protected $value;
+
+ /**
+ * The minimum value
+ *
+ * @var ?float
+ */
+ protected $minValue;
+
+ /**
+ * The maximum value
+ *
+ * @var ?float
+ */
+ protected $maxValue;
+
+ /**
+ * The WARNING threshold
+ *
+ * @var ThresholdRange
+ */
+ protected $warningThreshold;
+
+ /**
+ * The CRITICAL threshold
+ *
+ * @var ThresholdRange
+ */
+ protected $criticalThreshold;
+
+ /**
+ * The raw value
+ *
+ * @var ?string
+ */
+ protected $rawValue;
+
+ /**
+ * The raw minimum value
+ *
+ * @var ?string
+ */
+ protected $rawMinValue;
+
+ /**
+ * The raw maximum value
+ *
+ * @var ?string
+ */
+ protected $rawMaxValue;
+
+ /**
+ * Create a new PerfData object based on the given performance data label and value
+ *
+ * @param string $label The perfdata label
+ * @param string $value The perfdata value
+ */
+ public function __construct(string $label, string $value)
+ {
+ $this->perfdataValue = $value;
+ $this->label = $label;
+ $this->parse();
+
+ if ($this->unit === '%') {
+ if ($this->minValue === null) {
+ $this->minValue = 0.0;
+ }
+ if ($this->maxValue === null) {
+ $this->maxValue = 100.0;
+ }
+ }
+
+ $warn = $this->warningThreshold->getMax();
+ if ($warn !== null) {
+ $crit = $this->criticalThreshold->getMax();
+ if ($crit !== null && $warn > $crit) {
+ $this->warningThreshold->setInverted();
+ $this->criticalThreshold->setInverted();
+ }
+ }
+ }
+
+ /**
+ * Return a new PerfData object based on the given performance data key=value pair
+ *
+ * @param string $perfdata The key=value pair to parse
+ *
+ * @return PerfData
+ *
+ * @throws InvalidArgumentException In case the given performance data has no content or a invalid format
+ */
+ public static function fromString(string $perfdata): self
+ {
+ if (empty($perfdata)) {
+ throw new InvalidArgumentException('PerfData::fromString expects a string with content');
+ } elseif (strpos($perfdata, '=') === false) {
+ throw new InvalidArgumentException(
+ 'PerfData::fromString expects a key=value formatted string. Got "' . $perfdata . '" instead'
+ );
+ }
+
+ list($label, $value) = explode('=', $perfdata, 2);
+ return new static(trim($label), trim($value));
+ }
+
+ /**
+ * Return whether this performance data's value is a number
+ *
+ * @return bool True in case it's a number, otherwise False
+ */
+ public function isNumber(): bool
+ {
+ return $this->unit === null;
+ }
+
+ /**
+ * Return whether this performance data's value are seconds
+ *
+ * @return bool True in case it's seconds, otherwise False
+ */
+ public function isSeconds(): bool
+ {
+ return $this->unit === 's';
+ }
+
+ /**
+ * Return whether this performance data's value is a temperature
+ *
+ * @return bool True in case it's temperature, otherwise False
+ */
+ public function isTemperature(): bool
+ {
+ return in_array($this->unit, array('C', 'F', 'K'));
+ }
+
+ /**
+ * Return whether this performance data's value is in percentage
+ *
+ * @return bool True in case it's in percentage, otherwise False
+ */
+ public function isPercentage(): bool
+ {
+ return $this->unit === '%';
+ }
+
+ /**
+ * Get whether this perf data's value is in packets
+ *
+ * @return bool True in case it's in packets
+ */
+ public function isPackets(): bool
+ {
+ return $this->unit === 'packets';
+ }
+
+ /**
+ * Get whether this perf data's value is in lumen
+ *
+ * @return bool
+ */
+ public function isLumens(): bool
+ {
+ return $this->unit === 'lm';
+ }
+
+ /**
+ * Get whether this perf data's value is in decibel-milliwatts
+ *
+ * @return bool
+ */
+ public function isDecibelMilliWatts(): bool
+ {
+ return $this->unit === 'dBm';
+ }
+
+ /**
+ * Get whether this data's value is in bits
+ *
+ * @return bool
+ */
+ public function isBits(): bool
+ {
+ return $this->unit === 'b';
+ }
+
+ /**
+ * Return whether this performance data's value is in bytes
+ *
+ * @return bool True in case it's in bytes, otherwise False
+ */
+ public function isBytes(): bool
+ {
+ return $this->unit === 'B';
+ }
+
+ /**
+ * Get whether this data's value is in watt hours
+ *
+ * @return bool
+ */
+ public function isWattHours(): bool
+ {
+ return $this->unit === 'Wh';
+ }
+
+ /**
+ * Get whether this data's value is in watt
+ *
+ * @return bool
+ */
+ public function isWatts(): bool
+ {
+ return $this->unit === 'W';
+ }
+
+ /**
+ * Get whether this data's value is in ampere
+ *
+ * @return bool
+ */
+ public function isAmperes(): bool
+ {
+ return $this->unit === 'A';
+ }
+
+ /**
+ * Get whether this data's value is in ampere seconds
+ *
+ * @return bool
+ */
+ public function isAmpSeconds(): bool
+ {
+ return $this->unit === 'As';
+ }
+
+ /**
+ * Get whether this data's value is in volts
+ *
+ * @return bool
+ */
+ public function isVolts(): bool
+ {
+ return $this->unit === 'V';
+ }
+
+ /**
+ * Get whether this data's value is in ohm
+ *
+ * @return bool
+ */
+ public function isOhms(): bool
+ {
+ return $this->unit === 'O';
+ }
+
+ /**
+ * Get whether this data's value is in grams
+ *
+ * @return bool
+ */
+ public function isGrams(): bool
+ {
+ return $this->unit === 'g';
+ }
+
+ /**
+ * Get whether this data's value is in Litters
+ *
+ * @return bool
+ */
+ public function isLiters(): bool
+ {
+ return $this->unit === 'l';
+ }
+
+ /**
+ * Return whether this performance data's value is a counter
+ *
+ * @return bool True in case it's a counter, otherwise False
+ */
+ public function isCounter(): bool
+ {
+ return $this->unit === 'c';
+ }
+
+ /**
+ * Returns whether it is possible to display a visual representation
+ *
+ * @return bool True when the perfdata is visualizable
+ */
+ public function isVisualizable(): bool
+ {
+ return isset($this->minValue, $this->maxValue, $this->value) && $this->isValid();
+ }
+
+ /**
+ * Return this perfomance data's label
+ */
+ public function getLabel(): string
+ {
+ return $this->label;
+ }
+
+ /**
+ * Return the value or null if it is unknown (U)
+ *
+ * @return null|float
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Return the unit as a string
+ *
+ * @return ?string
+ */
+ public function getUnit()
+ {
+ return $this->unit;
+ }
+
+ /**
+ * Return the value as percentage (0-100)
+ *
+ * @return null|float
+ */
+ public function getPercentage()
+ {
+ if ($this->isPercentage()) {
+ return $this->value;
+ }
+
+ if ($this->maxValue !== null) {
+ $minValue = $this->minValue !== null ? $this->minValue : 0.0;
+ if ($this->maxValue == $minValue) {
+ return null;
+ }
+
+ if ($this->value > $minValue) {
+ return (($this->value - $minValue) / ($this->maxValue - $minValue)) * 100;
+ }
+ }
+ }
+
+ /**
+ * Return this performance data's warning treshold
+ *
+ * @return ThresholdRange
+ */
+ public function getWarningThreshold(): ThresholdRange
+ {
+ return $this->warningThreshold;
+ }
+
+ /**
+ * Return this performance data's critical treshold
+ *
+ * @return ThresholdRange
+ */
+ public function getCriticalThreshold(): ThresholdRange
+ {
+ return $this->criticalThreshold;
+ }
+
+ /**
+ * Return the minimum value or null if it is not available
+ *
+ * @return ?float
+ */
+ public function getMinimumValue()
+ {
+ return $this->minValue;
+ }
+
+ /**
+ * Return the maximum value or null if it is not available
+ *
+ * @return null|float
+ */
+ public function getMaximumValue()
+ {
+ return $this->maxValue;
+ }
+
+ /**
+ * Return this performance data as string
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->formatLabel();
+ }
+
+ /**
+ * Parse the current performance data value
+ *
+ * @todo Handle optional min/max if UOM == %
+ */
+ protected function parse()
+ {
+ $parts = explode(';', $this->perfdataValue);
+
+ $matches = array();
+ if (preg_match('@^(U|-?(?:\d+)?(?:\.\d+)?)([a-zA-TV-Z%°]{1,3})$@u', $parts[0], $matches)) {
+ $this->unit = $matches[2];
+ $value = $matches[1];
+ } else {
+ $value = $parts[0];
+ }
+
+ if (! is_numeric($value)) {
+ if ($value !== 'U') {
+ $this->rawValue = $parts[0];
+ }
+
+ $this->value = null;
+ } else {
+ $this->value = floatval($value);
+ }
+
+ switch (count($parts)) {
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 5:
+ if ($parts[4] !== '') {
+ if (is_numeric($parts[4])) {
+ $this->maxValue = floatval($parts[4]);
+ } else {
+ $this->rawMaxValue = $parts[4];
+ }
+ }
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 4:
+ if ($parts[3] !== '') {
+ if (is_numeric($parts[3])) {
+ $this->minValue = floatval($parts[3]);
+ } else {
+ $this->rawMinValue = $parts[3];
+ }
+ }
+ /* @noinspection PhpMissingBreakStatementInspection */
+ case 3:
+ $this->criticalThreshold = ThresholdRange::fromString(trim($parts[2]));
+ // Fallthrough
+ case 2:
+ $this->warningThreshold = ThresholdRange::fromString(trim($parts[1]));
+ }
+
+ if ($this->warningThreshold === null) {
+ $this->warningThreshold = new ThresholdRange();
+ }
+ if ($this->criticalThreshold === null) {
+ $this->criticalThreshold = new ThresholdRange();
+ }
+ }
+
+ protected function calculatePieChartData(): array
+ {
+ $rawValue = $this->getValue();
+ $minValue = $this->getMinimumValue() !== null ? $this->getMinimumValue() : 0;
+ $usedValue = ($rawValue - $minValue);
+
+ $green = $orange = $red = 0;
+
+ if ($this->criticalThreshold->contains($rawValue)) {
+ if ($this->warningThreshold->contains($rawValue)) {
+ $green = $usedValue;
+ } else {
+ $orange = $usedValue;
+ }
+ } else {
+ $red = $usedValue;
+ }
+
+ return array($green, $orange, $red, ($this->getMaximumValue() - $minValue) - $usedValue);
+ }
+
+
+ public function asInlinePie(): InlinePie
+ {
+ if (! $this->isVisualizable()) {
+ throw new LogicException('Cannot calculate piechart data for unvisualizable perfdata entry.');
+ }
+
+ $data = $this->calculatePieChartData();
+ $pieChart = new InlinePie($data, $this);
+ $pieChart->setColors(array('#44bb77', '#ffaa44', '#ff5566', '#ddccdd'));
+
+ return $pieChart;
+ }
+
+ /**
+ * Format the given value depending on the currently used unit
+ */
+ protected function format($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if ($value instanceof ThresholdRange) {
+ if (! $value->isValid()) {
+ return (string) $value;
+ }
+
+ if ($value->getMin()) {
+ return (string) $value;
+ }
+
+ $max = $value->getMax();
+ return $max === null ? '' : $this->format($max);
+ }
+
+ switch (true) {
+ case $this->isPercentage():
+ return (string) $value . '%';
+ case $this->isPackets():
+ return (string) $value . 'packets';
+ case $this->isLumens():
+ return (string) $value . 'lm';
+ case $this->isDecibelMilliWatts():
+ return (string) $value . 'dBm';
+ case $this->isCounter():
+ return (string) $value . 'c';
+ case $this->isTemperature():
+ return (string) $value . $this->unit;
+ case $this->isBits():
+ return PerfDataFormat::bits($value);
+ case $this->isBytes():
+ return PerfDataFormat::bytes($value);
+ case $this->isSeconds():
+ return PerfDataFormat::seconds($value);
+ case $this->isWatts():
+ return PerfDataFormat::watts($value);
+ case $this->isWattHours():
+ return PerfDataFormat::wattHours($value);
+ case $this->isAmperes():
+ return PerfDataFormat::amperes($value);
+ case $this->isAmpSeconds():
+ return PerfDataFormat::ampereSeconds($value);
+ case $this->isVolts():
+ return PerfDataFormat::volts($value);
+ case $this->isOhms():
+ return PerfDataFormat::ohms($value);
+ case $this->isGrams():
+ return PerfDataFormat::grams($value);
+ case $this->isLiters():
+ return PerfDataFormat::liters($value);
+ case ! is_numeric($value):
+ return $value;
+ default:
+ return number_format($value, 2) . ($this->unit !== null ? ' ' . $this->unit : '');
+ }
+ }
+
+ /**
+ * Format the title string that represents this perfdata set
+ *
+ * @param bool $html
+ *
+ * @return string
+ */
+ public function formatLabel(bool $html = false): string
+ {
+ return sprintf(
+ $html ? '<b>%s %s</b> (%s%%)' : '%s %s (%s%%)',
+ htmlspecialchars($this->getLabel()),
+ $this->format($this->value),
+ number_format($this->getPercentage() ?? 0, 2)
+ );
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'label' => $this->getLabel(),
+ 'value' => isset($this->value) ? $this->format($this->value) : $this->rawValue,
+ 'min' => (string) (
+ ! $this->isPercentage()
+ ? (isset($this->minValue) ? $this->format($this->minValue) : $this->rawMinValue)
+ : null
+ ),
+ 'max' => (string) (
+ ! $this->isPercentage()
+ ? (isset($this->maxValue) ? $this->format($this->maxValue) : $this->rawMaxValue)
+ : null
+ ),
+ 'warn' => $this->format($this->warningThreshold),
+ 'crit' => $this->format($this->criticalThreshold)
+ ];
+ }
+
+ /**
+ * Return the state indicated by this perfdata
+ *
+ * @return int
+ */
+ public function getState(): int
+ {
+ if (! is_numeric($this->value)) {
+ return ServiceStates::UNKNOWN;
+ }
+
+ if (! $this->criticalThreshold->contains($this->value)) {
+ return ServiceStates::CRITICAL;
+ }
+
+ if (! $this->warningThreshold->contains($this->value)) {
+ return ServiceStates::WARNING;
+ }
+
+ return ServiceStates::OK;
+ }
+
+ /**
+ * Return whether the state indicated by this perfdata is worse than
+ * the state indicated by the other perfdata
+ * CRITICAL > UNKNOWN > WARNING > OK
+ *
+ * @param PerfData $rhs the other perfdata
+ *
+ * @return bool
+ */
+ public function worseThan(PerfData $rhs): bool
+ {
+ if (($state = $this->getState()) === ($rhsState = $rhs->getState())) {
+ return $this->getPercentage() > $rhs->getPercentage();
+ }
+
+ if ($state === ServiceStates::CRITICAL) {
+ return true;
+ }
+
+ if ($state === ServiceStates::UNKNOWN) {
+ return $rhsState !== ServiceStates::CRITICAL;
+ }
+
+ if ($state === ServiceStates::WARNING) {
+ return $rhsState === ServiceStates::OK;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns whether the performance data can be evaluated
+ *
+ * @return bool
+ */
+ public function isValid(): bool
+ {
+ return ! isset($this->rawValue)
+ && ! isset($this->rawMinValue)
+ && ! isset($this->rawMaxValue)
+ && $this->criticalThreshold->isValid()
+ && $this->warningThreshold->isValid();
+ }
+}
diff --git a/library/Icingadb/Util/PerfDataFormat.php b/library/Icingadb/Util/PerfDataFormat.php
new file mode 100644
index 0000000..1caffff
--- /dev/null
+++ b/library/Icingadb/Util/PerfDataFormat.php
@@ -0,0 +1,171 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+class PerfDataFormat
+{
+ protected static $instance;
+
+ protected static $generalBase = 1000;
+
+ protected static $bitPrefix = ['b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb'];
+
+ protected static $bytePrefix = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+ protected static $wattHourPrefix = ['Wh', 'kWh', 'MWh', 'GWh', 'TWh', 'PWh', 'EWh', 'ZWh', 'YWh'];
+
+ protected static $wattPrefix = [-1 => 'mW', 'W', 'kW', 'MW', 'GW'];
+
+ protected static $amperePrefix = [-3 => 'nA', -2 => 'µA', -1 => 'mA', 'A', 'kA', 'MA', 'GA'];
+
+ protected static $ampSecondPrefix = [-2 => 'µAs', -1 => 'mAs', 'As', 'kAs', 'MAs', 'GAs'];
+
+ protected static $voltPrefix = [-2 => 'µV', -1 => 'mV', 'V', 'kV', 'MV', 'GV'];
+
+ protected static $ohmPrefix = ['Ω'];
+
+ protected static $gramPrefix = [
+ -5 => 'fg',
+ -4 => 'pg',
+ -3 => 'ng',
+ -2 => 'µg',
+ -1 => 'mg',
+ 'g',
+ 'kg',
+ 't',
+ 'ktǂ',
+ 'Mt',
+ 'Gt'
+ ];
+
+ protected static $literPrefix = [
+ -5 => 'fl',
+ -4 => 'pl',
+ -3 => 'nl',
+ -2 => 'µl',
+ -1 => 'ml',
+ 'l',
+ 'kl',
+ 'Ml',
+ 'Gl',
+ 'Tl',
+ 'Pl'
+ ];
+
+ protected static $secondPrefix = [-3 => 'ns', -2 => 'µs', -1 => 'ms', 's'];
+
+ public static function getInstance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new PerfDataFormat();
+ }
+
+ return self::$instance;
+ }
+
+ public static function bits($value): string
+ {
+ return self::formatForUnits($value, self::$bitPrefix, self::$generalBase);
+ }
+
+ public static function bytes($value): string
+ {
+ return self::formatForUnits($value, self::$bytePrefix, self::$generalBase);
+ }
+
+ public static function wattHours($value): string
+ {
+ return self::formatForUnits($value, self::$wattHourPrefix, self::$generalBase);
+ }
+
+ public static function watts($value): string
+ {
+ return self::formatForUnits($value, self::$wattPrefix, self::$generalBase);
+ }
+
+ public static function amperes($value): string
+ {
+ return self::formatForUnits($value, self::$amperePrefix, self::$generalBase);
+ }
+
+ public static function ampereSeconds($value): string
+ {
+ return self::formatForUnits($value, self::$ampSecondPrefix, self::$generalBase);
+ }
+
+ public static function volts($value): string
+ {
+ return self::formatForUnits($value, self::$voltPrefix, self::$generalBase);
+ }
+
+ public static function ohms($value): string
+ {
+ return self::formatForUnits($value, self::$ohmPrefix, self::$generalBase);
+ }
+
+ public static function grams($value): string
+ {
+ return self::formatForUnits($value, self::$gramPrefix, self::$generalBase);
+ }
+
+ public static function liters($value): string
+ {
+ return self::formatForUnits($value, self::$literPrefix, self::$generalBase);
+ }
+
+ public static function seconds($value): string
+ {
+ $value = (float) $value;
+ $absValue = abs($value);
+
+ if ($absValue < 60) {
+ return self::formatForUnits($value, self::$secondPrefix, self::$generalBase);
+ } elseif ($absValue < 3600) {
+ return sprintf('%0.2f m', $value / 60);
+ } elseif ($absValue < 86400) {
+ return sprintf('%0.2f h', $value / 3600);
+ }
+
+ return sprintf('%0.2f d', $value / 86400);
+ }
+
+ protected static function formatForUnits(float $value, array &$units, int $base): string
+ {
+ $sign = '';
+ if ($value < 0) {
+ $value = abs($value);
+ $sign = '-';
+ }
+
+ if ($value == 0) {
+ $pow = $result = 0;
+ } else {
+ $pow = floor(log($value, $base));
+
+ // Identify nearest unit if unknown
+ while (! isset($units[$pow])) {
+ if ($pow < 0) {
+ $pow++;
+ } else {
+ $pow--;
+ }
+ }
+
+ $result = $value / pow($base, $pow);
+ }
+
+ // 1034.23 looks better than 1.03, but 2.03 is fine:
+ if ($pow > 0 && $result < 2) {
+ $result = $value / pow($base, --$pow);
+ }
+
+ return sprintf(
+ '%s%0.2f %s',
+ $sign,
+ $result,
+ $units[$pow]
+ );
+ }
+}
diff --git a/library/Icingadb/Util/PerfDataSet.php b/library/Icingadb/Util/PerfDataSet.php
new file mode 100644
index 0000000..df31393
--- /dev/null
+++ b/library/Icingadb/Util/PerfDataSet.php
@@ -0,0 +1,172 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use ArrayIterator;
+use IteratorAggregate;
+
+class PerfDataSet implements IteratorAggregate
+{
+ /**
+ * The performance data being parsed
+ *
+ * @var string
+ */
+ protected $perfdataStr;
+
+ /**
+ * The current parsing position
+ *
+ * @var int
+ */
+ protected $parserPos = 0;
+
+ /**
+ * A list of PerfData objects
+ *
+ * @var array
+ */
+ protected $perfdata = array();
+
+ /**
+ * Create a new set of performance data
+ *
+ * @param string $perfdataStr A space separated list of label/value pairs
+ */
+ protected function __construct(string $perfdataStr)
+ {
+ if (($perfdataStr = trim($perfdataStr)) !== '') {
+ $this->perfdataStr = $perfdataStr;
+ $this->parse();
+ }
+ }
+
+ /**
+ * Return a iterator for this set of performance data
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): ArrayIterator
+ {
+ return new ArrayIterator($this->asArray());
+ }
+
+ /**
+ * Return a new set of performance data
+ *
+ * @param string $perfdataStr A space separated list of label/value pairs
+ *
+ * @return PerfDataSet
+ */
+ public static function fromString(string $perfdataStr): self
+ {
+ return new static($perfdataStr);
+ }
+
+ /**
+ * Return this set of performance data as array
+ *
+ * @return array
+ */
+ public function asArray(): array
+ {
+ return $this->perfdata;
+ }
+
+ /**
+ * Parse the current performance data
+ */
+ protected function parse()
+ {
+ while ($this->parserPos < strlen($this->perfdataStr)) {
+ $label = trim($this->readLabel());
+ $value = trim($this->readUntil(' '));
+
+ if ($label) {
+ $this->perfdata[] = new PerfData($label, $value);
+ }
+ }
+
+ uasort(
+ $this->perfdata,
+ function ($a, $b) {
+ if ($a->isVisualizable() && ! $b->isVisualizable()) {
+ return -1;
+ } elseif (! $a->isVisualizable() && $b->isVisualizable()) {
+ return 1;
+ } elseif (! $a->isVisualizable() && ! $b->isVisualizable()) {
+ return 0;
+ }
+
+ return $a->worseThan($b) ? -1 : ($b->worseThan($a) ? 1 : 0);
+ }
+ );
+ }
+
+ /**
+ * Return the next label found in the performance data
+ *
+ * @return string The label found
+ */
+ protected function readLabel(): string
+ {
+ $this->skipSpaces();
+ if (in_array($this->perfdataStr[$this->parserPos], array('"', "'"))) {
+ $quoteChar = $this->perfdataStr[$this->parserPos++];
+ $label = $this->readUntil($quoteChar, '=');
+ $this->parserPos++;
+
+ if ($this->perfdataStr[$this->parserPos] === '=') {
+ $this->parserPos++;
+ }
+ } else {
+ $label = $this->readUntil('=');
+ $this->parserPos++;
+ }
+
+ $this->skipSpaces();
+ return $label;
+ }
+
+ /**
+ * Return all characters between the current parser position and the given character
+ *
+ * @param string $stopChar The character on which to stop
+ * @param string $backtrackOn The character on which to backtrack
+ *
+ * @return string
+ */
+ protected function readUntil(string $stopChar, string $backtrackOn = null): string
+ {
+ $start = $this->parserPos;
+ $breakCharEncounteredAt = null;
+ $stringExhaustedAt = strlen($this->perfdataStr);
+ while ($this->parserPos < $stringExhaustedAt) {
+ if ($this->perfdataStr[$this->parserPos] === $stopChar) {
+ break;
+ } elseif ($breakCharEncounteredAt === null && $this->perfdataStr[$this->parserPos] === $backtrackOn) {
+ $breakCharEncounteredAt = $this->parserPos;
+ }
+
+ $this->parserPos++;
+ }
+
+ if ($breakCharEncounteredAt !== null && $this->parserPos === $stringExhaustedAt) {
+ $this->parserPos = $breakCharEncounteredAt;
+ }
+
+ return substr($this->perfdataStr, $start, $this->parserPos - $start);
+ }
+
+ /**
+ * Advance the parser position to the next non-whitespace character
+ */
+ protected function skipSpaces()
+ {
+ while ($this->parserPos < strlen($this->perfdataStr) && $this->perfdataStr[$this->parserPos] === ' ') {
+ $this->parserPos++;
+ }
+ }
+}
diff --git a/library/Icingadb/Util/PluginOutput.php b/library/Icingadb/Util/PluginOutput.php
new file mode 100644
index 0000000..71d08b1
--- /dev/null
+++ b/library/Icingadb/Util/PluginOutput.php
@@ -0,0 +1,260 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+use DOMDocument;
+use DOMNode;
+use DOMText;
+use Icinga\Module\Icingadb\Hook\PluginOutputHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Web\Dom\DomNodeIterator;
+use Icinga\Web\Helper\HtmlPurifier;
+use InvalidArgumentException;
+use ipl\Html\HtmlString;
+use ipl\Orm\Model;
+use LogicException;
+use RecursiveIteratorIterator;
+
+class PluginOutput extends HtmlString
+{
+ /** @var string[] Patterns to be replaced in plain text plugin output */
+ const TEXT_PATTERNS = [
+ '~\\\t~',
+ '~\\\n~',
+ '~(\[|\()OK(\]|\))~',
+ '~(\[|\()WARNING(\]|\))~',
+ '~(\[|\()CRITICAL(\]|\))~',
+ '~(\[|\()UNKNOWN(\]|\))~',
+ '~(\[|\()UP(\]|\))~',
+ '~(\[|\()DOWN(\]|\))~',
+ '~\@{6,}~'
+ ];
+
+ /** @var string[] Replacements for {@see PluginOutput::TEXT_PATTERNS} */
+ const TEXT_REPLACEMENTS = [
+ "\t",
+ "\n",
+ '<span class="state-ball ball-size-m state-ok"></span>',
+ '<span class="state-ball ball-size-m state-warning"></span>',
+ '<span class="state-ball ball-size-m state-critical"></span>',
+ '<span class="state-ball ball-size-m state-unknown"></span>',
+ '<span class="state-ball ball-size-m state-up"></span>',
+ '<span class="state-ball ball-size-m state-down"></span>',
+ '@@@@@@'
+ ];
+
+ /** @var string[] Patterns to be replaced in html plugin output */
+ const HTML_PATTERNS = [
+ '~\\\t~',
+ '~\\\n~'
+ ];
+
+ /** @var string[] Replacements for {@see PluginOutput::HTML_PATTERNS} */
+ const HTML_REPLACEMENTS = [
+ "\t",
+ "\n"
+ ];
+
+ /** @var string Already rendered output */
+ protected $renderedOutput;
+
+ /** @var bool Whether the output contains HTML */
+ protected $isHtml;
+
+ /** @var bool Whether output will be enriched */
+ protected $enrichOutput = true;
+
+ /** @var string The name of the command that produced the output */
+ protected $commandName;
+
+ /**
+ * Get whether the output contains HTML
+ *
+ * Requires the output being already rendered.
+ *
+ * @return bool
+ *
+ * @throws LogicException In case the output hasn't been rendered yet
+ */
+ public function isHtml(): bool
+ {
+ if ($this->isHtml === null) {
+ if (empty($this->getContent())) {
+ // "Nothing" can't be HTML
+ return false;
+ }
+
+ throw new LogicException('Output not rendered yet');
+ }
+
+ return $this->isHtml;
+ }
+
+ /**
+ * Set whether the output should be enriched
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setEnrichOutput(bool $state = true): self
+ {
+ $this->enrichOutput = $state;
+
+ return $this;
+ }
+
+ /**
+ * Set name of the command that produced the output
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setCommandName(string $name): self
+ {
+ $this->commandName = $name;
+
+ return $this;
+ }
+
+ /**
+ * Render plugin output of the given object
+ *
+ * @param Host|Service $object
+ *
+ * @return static
+ *
+ * @throws InvalidArgumentException If $object is neither a host nor a service
+ */
+ public static function fromObject(Model $object): self
+ {
+ if (! $object instanceof Host && ! $object instanceof Service) {
+ throw new InvalidArgumentException(
+ sprintf('Object is not a host or service, got %s instead', get_class($object))
+ );
+ }
+
+ return (new static($object->state->output . "\n" . $object->state->long_output))
+ ->setCommandName($object->checkcommand_name);
+ }
+
+ public function render()
+ {
+ if ($this->renderedOutput !== null) {
+ return $this->renderedOutput;
+ }
+
+ $output = parent::render();
+ if (empty($output)) {
+ return '';
+ }
+
+ if ($this->commandName !== null) {
+ $output = PluginOutputHook::processOutput($output, $this->commandName, $this->enrichOutput);
+ }
+
+ if (preg_match('~<\w+(?>\s\w+=[^>]*)?>~', $output)) {
+ // HTML
+ $output = HtmlPurifier::process(preg_replace(
+ self::HTML_PATTERNS,
+ self::HTML_REPLACEMENTS,
+ $output
+ ));
+ $this->isHtml = true;
+ } else {
+ // Plaintext
+ $output = preg_replace(
+ self::TEXT_PATTERNS,
+ self::TEXT_REPLACEMENTS,
+ htmlspecialchars($output, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5, null, false)
+ );
+ $this->isHtml = false;
+ }
+
+ $output = trim($output);
+
+ // Add zero-width space after commas which are not followed by a whitespace character
+ // in oder to help browsers to break words in plugin output
+ $output = preg_replace('/,(?=[^\s])/', ',&#8203;', $output);
+
+ if ($this->enrichOutput && $this->isHtml) {
+ $output = $this->processHtml($output);
+ }
+
+ $this->renderedOutput = $output;
+
+ return $output;
+ }
+
+ /**
+ * Replace color state information, if any
+ *
+ * @param string $html
+ *
+ * @todo Do we really need to create a DOM here? Or is a preg_replace like we do it for text also feasible?
+ * @return string
+ */
+ protected function processHtml(string $html): string
+ {
+ $pattern = '/[([](OK|WARNING|CRITICAL|UNKNOWN|UP|DOWN)[)\]]/';
+ $doc = new DOMDocument();
+ $doc->loadXML('<div>' . $html . '</div>', LIBXML_NOERROR | LIBXML_NOWARNING);
+ $dom = new RecursiveIteratorIterator(new DomNodeIterator($doc), RecursiveIteratorIterator::SELF_FIRST);
+
+ $nodesToRemove = [];
+ foreach ($dom as $node) {
+ /** @var DOMNode $node */
+ if ($node->nodeType !== XML_TEXT_NODE) {
+ continue;
+ }
+
+ $start = 0;
+ while (preg_match($pattern, $node->nodeValue, $match, PREG_OFFSET_CAPTURE, $start)) {
+ $offsetLeft = $match[0][1];
+ $matchLength = strlen($match[0][0]);
+ $leftLength = $offsetLeft - $start;
+
+ // if there is text before the match
+ if ($leftLength) {
+ // create node for leading text
+ $text = new DOMText(substr($node->nodeValue, $start, $leftLength));
+ $node->parentNode->insertBefore($text, $node);
+ }
+
+ // create the state ball for the match
+ $span = $doc->createElement('span');
+ $span->setAttribute(
+ 'class',
+ 'state-ball ball-size-m state-' . strtolower($match[1][0])
+ );
+ $node->parentNode->insertBefore($span, $node);
+
+ // start for next match
+ $start = $offsetLeft + $matchLength;
+ }
+
+ if ($start) {
+ // is there text left?
+ if (strlen($node->nodeValue) > $start) {
+ // create node for trailing text
+ $text = new DOMText(substr($node->nodeValue, $start));
+ $node->parentNode->insertBefore($text, $node);
+ }
+
+ // delete the old node later
+ $nodesToRemove[] = $node;
+ }
+ }
+
+ foreach ($nodesToRemove as $node) {
+ /** @var DOMNode $node */
+ $node->parentNode->removeChild($node);
+ }
+
+ return substr($doc->saveHTML(), 5, -7);
+ }
+}
diff --git a/library/Icingadb/Util/ThresholdRange.php b/library/Icingadb/Util/ThresholdRange.php
new file mode 100644
index 0000000..675697a
--- /dev/null
+++ b/library/Icingadb/Util/ThresholdRange.php
@@ -0,0 +1,213 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Util;
+
+/**
+ * The warning/critical threshold of a measured value
+ */
+class ThresholdRange
+{
+ /**
+ * The smallest value inside the range (null stands for -∞)
+ *
+ * @var float|null
+ */
+ protected $min;
+
+ /**
+ * The biggest value inside the range (null stands for ∞)
+ *
+ * @var float|null
+ */
+ protected $max;
+
+ /**
+ * Whether to invert the result of contains()
+ *
+ * @var bool
+ */
+ protected $inverted = false;
+
+ /**
+ * The unmodified range as passed to fromString()
+ *
+ * @var string
+ */
+ protected $raw;
+
+ /**
+ * Whether the threshold range is valid
+ *
+ * @var bool
+ */
+ protected $isValid = true;
+
+ /**
+ * Create a new instance based on a threshold range conforming to <https://nagios-plugins.org/doc/guidelines.html>
+ *
+ * @param string $rawRange
+ *
+ * @return ThresholdRange
+ */
+ public static function fromString(string $rawRange): self
+ {
+ $range = new static();
+ $range->raw = $rawRange;
+
+ if ($rawRange == '') {
+ return $range;
+ }
+
+ $rawRange = ltrim($rawRange);
+ if (substr($rawRange, 0, 1) === '@') {
+ $range->setInverted();
+ $rawRange = substr($rawRange, 1);
+ }
+
+ if (strpos($rawRange, ':') === false) {
+ $min = 0.0;
+ $max = trim($rawRange);
+ if (! is_numeric($max)) {
+ $range->isValid = false;
+ return $range;
+ }
+
+ $max = floatval(trim($rawRange));
+ } else {
+ list($min, $max) = explode(':', $rawRange, 2);
+ $min = trim($min);
+ $max = trim($max);
+
+ switch ($min) {
+ case '':
+ $min = 0.0;
+ break;
+ case '~':
+ $min = null;
+ break;
+ default:
+ if (! is_numeric($min)) {
+ $range->isValid = false;
+ return $range;
+ }
+
+ $min = floatval($min);
+ }
+
+ if (! empty($max) && ! is_numeric($max)) {
+ $range->isValid = false;
+ return $range;
+ }
+
+ $max = empty($max) ? null : floatval($max);
+ }
+
+ return $range->setMin($min)
+ ->setMax($max);
+ }
+
+ /**
+ * Set the smallest value inside the range (null stands for -∞)
+ *
+ * @param float|null $min
+ *
+ * @return $this
+ */
+ public function setMin(?float $min): self
+ {
+ $this->min = $min;
+ return $this;
+ }
+
+ /**
+ * Get the smallest value inside the range (null stands for -∞)
+ *
+ * @return float|null
+ */
+ public function getMin()
+ {
+ return $this->min;
+ }
+
+ /**
+ * Set the biggest value inside the range (null stands for ∞)
+ *
+ * @param float|null $max
+ *
+ * @return $this
+ */
+ public function setMax(?float $max): self
+ {
+ $this->max = $max;
+ return $this;
+ }
+
+ /**
+ * Get the biggest value inside the range (null stands for ∞)
+ *
+ * @return float|null
+ */
+ public function getMax()
+ {
+ return $this->max;
+ }
+
+ /**
+ * Set whether to invert the result of contains()
+ *
+ * @param bool $inverted
+ *
+ * @return $this
+ */
+ public function setInverted(bool $inverted = true): self
+ {
+ $this->inverted = $inverted;
+ return $this;
+ }
+
+ /**
+ * Get whether to invert the result of contains()
+ *
+ * @return bool
+ */
+ public function isInverted(): bool
+ {
+ return $this->inverted;
+ }
+
+ /**
+ * Return whether $value is inside $this
+ *
+ * @param float $value
+ *
+ * @return bool
+ */
+ public function contains(float $value): bool
+ {
+ return (bool) ($this->inverted ^ (
+ ($this->min === null || $this->min <= $value) && ($this->max === null || $this->max >= $value)
+ ));
+ }
+
+ /**
+ * Return whether the threshold range is valid
+ *
+ * @return bool
+ */
+ public function isValid()
+ {
+ return $this->isValid;
+ }
+
+ /**
+ * Return the textual representation of $this, suitable for fromString()
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return (string) $this->raw;
+ }
+}
diff --git a/library/Icingadb/Web/Control/GridViewModeSwitcher.php b/library/Icingadb/Web/Control/GridViewModeSwitcher.php
new file mode 100644
index 0000000..df5524b
--- /dev/null
+++ b/library/Icingadb/Web/Control/GridViewModeSwitcher.php
@@ -0,0 +1,38 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control;
+
+/**
+ * View mode switcher to toggle between grid and list view
+ */
+class GridViewModeSwitcher extends ViewModeSwitcher
+{
+ /** @var string Default view mode */
+ public const DEFAULT_VIEW_MODE = 'list';
+
+ /** @var array View mode-icon pairs */
+ public static $viewModes = [
+ 'list' => 'default',
+ 'grid' => 'grid'
+ ];
+
+ protected function getTitle(string $viewMode): string
+ {
+ $active = null;
+ $inactive = null;
+ switch ($viewMode) {
+ case 'list':
+ $active = t('List view active');
+ $inactive = t('Switch to list view');
+ break;
+ case 'grid':
+ $active = t('Grid view active');
+ $inactive = t('Switch to grid view');
+ break;
+ }
+
+ return $viewMode === $this->getViewMode() ? $active : $inactive;
+ }
+}
diff --git a/library/Icingadb/Web/Control/ProblemToggle.php b/library/Icingadb/Web/Control/ProblemToggle.php
new file mode 100644
index 0000000..c5aed82
--- /dev/null
+++ b/library/Icingadb/Web/Control/ProblemToggle.php
@@ -0,0 +1,74 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control;
+
+use ipl\Web\Common\FormUid;
+use ipl\Web\Compat\CompatForm;
+
+class ProblemToggle extends CompatForm
+{
+ use FormUid;
+
+ protected $filter;
+
+ protected $protector;
+
+ protected $defaultAttributes = [
+ 'name' => 'problem-toggle',
+ 'class' => 'icinga-form icinga-controls inline'
+ ];
+
+ public function __construct($filter)
+ {
+ $this->filter = $filter;
+ }
+
+ /**
+ * Set callback to protect ids with
+ *
+ * @param callable $protector
+ *
+ * @return $this
+ */
+ public function setIdProtector(callable $protector): self
+ {
+ $this->protector = $protector;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the toggle is checked
+ *
+ * @return bool
+ */
+ public function isChecked(): bool
+ {
+ $this->ensureAssembled();
+
+ return $this->getElement('problems')->isChecked();
+ }
+
+ protected function assemble()
+ {
+ $this->addElement('checkbox', 'problems', [
+ 'class' => 'autosubmit',
+ 'id' => $this->protectId('problems'),
+ 'label' => t('Problems Only'),
+ 'value' => $this->filter !== null
+ ]);
+
+ $this->add($this->createUidElement());
+ }
+
+ private function protectId($id)
+ {
+ if (is_callable($this->protector)) {
+ return call_user_func($this->protector, $id);
+ }
+
+ return $id;
+ }
+}
diff --git a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
new file mode 100644
index 0000000..b89e729
--- /dev/null
+++ b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
@@ -0,0 +1,406 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control\SearchBar;
+
+use Generator;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Util\ObjectSuggestionsCursor;
+use ipl\Html\HtmlElement;
+use ipl\Orm\Exception\InvalidColumnException;
+use ipl\Orm\Exception\InvalidRelationException;
+use ipl\Orm\Model;
+use ipl\Orm\Relation;
+use ipl\Orm\Relation\BelongsToMany;
+use ipl\Orm\Relation\HasOne;
+use ipl\Orm\Resolver;
+use ipl\Orm\UnionModel;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Seq;
+use ipl\Web\Control\SearchBar\SearchException;
+use ipl\Web\Control\SearchBar\Suggestions;
+use PDO;
+
+class ObjectSuggestions extends Suggestions
+{
+ use Auth;
+ use Database;
+
+ /** @var Model */
+ protected $model;
+
+ /** @var array */
+ protected $customVarSources;
+
+ public function __construct()
+ {
+ $this->customVarSources = [
+ 'checkcommand' => t('Checkcommand %s', '..<customvar-name>'),
+ 'eventcommand' => t('Eventcommand %s', '..<customvar-name>'),
+ 'host' => t('Host %s', '..<customvar-name>'),
+ 'hostgroup' => t('Hostgroup %s', '..<customvar-name>'),
+ 'notification' => t('Notification %s', '..<customvar-name>'),
+ 'notificationcommand' => t('Notificationcommand %s', '..<customvar-name>'),
+ 'service' => t('Service %s', '..<customvar-name>'),
+ 'servicegroup' => t('Servicegroup %s', '..<customvar-name>'),
+ 'timeperiod' => t('Timeperiod %s', '..<customvar-name>'),
+ 'user' => t('User %s', '..<customvar-name>'),
+ 'usergroup' => t('Usergroup %s', '..<customvar-name>')
+ ];
+ }
+
+ /**
+ * Set the model to show suggestions for
+ *
+ * @param string|Model $model
+ *
+ * @return $this
+ */
+ public function setModel($model): self
+ {
+ if (is_string($model)) {
+ $model = new $model();
+ }
+
+ $this->model = $model;
+
+ return $this;
+ }
+
+ /**
+ * Get the model to show suggestions for
+ *
+ * @return Model
+ */
+ public function getModel(): Model
+ {
+ if ($this->model === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->model;
+ }
+
+ protected function shouldShowRelationFor(string $column): bool
+ {
+ if (strpos($column, '.vars.') !== false) {
+ return false;
+ }
+
+ $tableName = $this->getModel()->getTableName();
+ $columnPath = explode('.', $column);
+
+ switch (count($columnPath)) {
+ case 3:
+ if ($columnPath[1] !== 'state' || ! in_array($tableName, ['host', 'service'])) {
+ return true;
+ }
+
+ // For host/service state relation columns apply the same rules
+ case 2:
+ return $columnPath[0] !== $tableName;
+ default:
+ return true;
+ }
+ }
+
+ protected function createQuickSearchFilter($searchTerm)
+ {
+ $model = $this->getModel();
+ $resolver = $model::on($this->getDb())->getResolver();
+
+ $quickFilter = Filter::any();
+ foreach ($model->getSearchColumns() as $column) {
+ $where = Filter::like($resolver->qualifyColumn($column, $model->getTableName()), $searchTerm);
+ $where->metaData()->set('columnLabel', $resolver->getColumnDefinition($where->getColumn())->getLabel());
+ $quickFilter->add($where);
+ }
+
+ return $quickFilter;
+ }
+
+ protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter)
+ {
+ $model = $this->getModel();
+ $query = $model::on($this->getDb());
+ $query->limit(static::DEFAULT_LIMIT);
+
+ if (strpos($column, ' ') !== false) {
+ // $column may be a label
+ list($path, $_) = Seq::find(
+ self::collectFilterColumns($query->getModel(), $query->getResolver()),
+ $column,
+ false
+ );
+ if ($path !== null) {
+ $column = $path;
+ }
+ }
+
+ $columnPath = $query->getResolver()->qualifyPath($column, $model->getTableName());
+ list($targetPath, $columnName) = preg_split('/(?<=vars)\.|\.(?=[^.]+$)/', $columnPath, 2);
+
+ $isCustomVar = false;
+ if (substr($targetPath, -5) === '.vars') {
+ $isCustomVar = true;
+ $targetPath = substr($targetPath, 0, -4) . 'customvar_flat';
+ }
+
+ if (strpos($targetPath, '.') !== false) {
+ try {
+ $query->with($targetPath); // TODO: Remove this, once ipl/orm does it as early
+ } catch (InvalidRelationException $e) {
+ throw new SearchException(sprintf(t('"%s" is not a valid relation'), $e->getRelation()));
+ }
+ }
+
+ if ($isCustomVar) {
+ $columnPath = $targetPath . '.flatvalue';
+ $query->filter(Filter::like($targetPath . '.flatname', $columnName));
+ }
+
+ $inputFilter = Filter::like($columnPath, $searchTerm);
+ $query->columns($columnPath);
+ $query->orderBy($columnPath);
+
+ // This had so many iterations, if it still doesn't work, consider removing it entirely :(
+ if ($searchFilter instanceof Filter\None) {
+ $query->filter($inputFilter);
+ } elseif ($searchFilter instanceof Filter\All) {
+ $searchFilter->add($inputFilter);
+
+ // There may be columns part of $searchFilter which target the base table. These must be
+ // optimized, otherwise they influence what we'll suggest to the user. (i.e. less)
+ // The $inputFilter on the other hand must not be optimized, which it wouldn't, but since
+ // we force optimization on its parent chain, we have to negate that.
+ $searchFilter->metaData()->set('forceOptimization', true);
+ $inputFilter->metaData()->set('forceOptimization', false);
+ } else {
+ $searchFilter = $inputFilter;
+ }
+
+ $query->filter($searchFilter);
+ $this->applyRestrictions($query);
+
+ try {
+ return (new ObjectSuggestionsCursor($query->getDb(), $query->assembleSelect()->distinct()))
+ ->setFetchMode(PDO::FETCH_COLUMN);
+ } catch (InvalidColumnException $e) {
+ throw new SearchException(sprintf(t('"%s" is not a valid column'), $e->getColumn()));
+ }
+ }
+
+ protected function fetchColumnSuggestions($searchTerm)
+ {
+ $model = $this->getModel();
+ $query = $model::on($this->getDb());
+
+ // Ordinary columns first
+ foreach (self::collectFilterColumns($model, $query->getResolver()) as $columnName => $columnMeta) {
+ yield $columnName => $columnMeta;
+ }
+
+ // Custom variables only after the columns are exhausted and there's actually a chance the user sees them
+ $titleAdded = false;
+ $parsedArrayVars = [];
+ foreach ($this->getDb()->select($this->queryCustomvarConfig($searchTerm)) as $customVar) {
+ $search = $name = $customVar->flatname;
+ if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
+ $name = substr($search, 0, -(strlen($matches[1]) + 2));
+ if (isset($parsedArrayVars[$name])) {
+ continue;
+ }
+
+ $parsedArrayVars[$name] = true;
+ $search = $name . '[*]';
+ }
+
+ foreach ($this->customVarSources as $relation => $label) {
+ if (isset($customVar->$relation)) {
+ if (! $titleAdded) {
+ $titleAdded = true;
+ $this->addHtml(HtmlElement::create(
+ 'li',
+ ['class' => static::SUGGESTION_TITLE_CLASS],
+ t('Custom Variables')
+ ));
+ }
+
+ yield $relation . '.vars.' . $search => sprintf($label, $name);
+ }
+ }
+ }
+ }
+
+ protected function matchSuggestion($path, $label, $searchTerm)
+ {
+ if (preg_match('/[_.](id|bin|checksum)$/', $path)) {
+ // Only suggest exotic columns if the user knows about them
+ $trimmedSearch = trim($searchTerm, ' *');
+ return substr($path, -strlen($trimmedSearch)) === $trimmedSearch;
+ }
+
+ return parent::matchSuggestion($path, $label, $searchTerm);
+ }
+
+ /**
+ * Create a query to fetch all available custom variables matching the given term
+ *
+ * @param string $searchTerm
+ *
+ * @return Select
+ */
+ protected function queryCustomvarConfig(string $searchTerm): Select
+ {
+ $customVars = CustomvarFlat::on($this->getDb());
+ $tableName = $customVars->getModel()->getTableName();
+ $resolver = $customVars->getResolver();
+
+ $scalarQueries = [];
+ $aggregates = ['flatname'];
+ foreach ($resolver->getRelations($customVars->getModel()) as $name => $relation) {
+ if (isset($this->customVarSources[$name]) && $relation instanceof BelongsToMany) {
+ $query = $customVars->createSubQuery(
+ $relation->getTarget(),
+ $resolver->qualifyPath($name, $tableName)
+ );
+
+ $this->applyRestrictions($query);
+
+ $aggregates[$name] = new Expression("MAX($name)");
+ $scalarQueries[$name] = $query->assembleSelect()
+ ->resetColumns()->columns(new Expression('1'))
+ ->limit(1);
+ }
+ }
+
+ $customVars->columns('flatname');
+ $this->applyRestrictions($customVars);
+ $customVars->filter(Filter::like('flatname', $searchTerm));
+ $idColumn = $resolver->qualifyColumn('id', $resolver->getAlias($customVars->getModel()));
+ $customVars = $customVars->assembleSelect();
+
+ $customVars->columns($scalarQueries);
+ $customVars->groupBy($idColumn);
+ $customVars->limit(static::DEFAULT_LIMIT);
+
+ // This outer query exists only because there's no way to combine aggregates and sub queries (yet)
+ return (new Select())->columns($aggregates)->from(['results' => $customVars])->groupBy('flatname');
+ }
+
+ /**
+ * Collect all columns of this model and its relations that can be used for filtering
+ *
+ * @param Model $model
+ * @param Resolver $resolver
+ *
+ * @return Generator
+ */
+ public static function collectFilterColumns(Model $model, Resolver $resolver): Generator
+ {
+ if ($model instanceof UnionModel) {
+ $models = [];
+ foreach ($model->getUnions() as $union) {
+ /** @var Model $unionModel */
+ $unionModel = new $union[0]();
+ $models[$unionModel->getTableName()] = $unionModel;
+ self::collectRelations($resolver, $unionModel, $models, []);
+ }
+ } else {
+ $models = [$model->getTableName() => $model];
+ self::collectRelations($resolver, $model, $models, []);
+ }
+
+ /** @var Model $targetModel */
+ foreach ($models as $path => $targetModel) {
+ foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) {
+ yield $path . '.' . $columnName => $definition->getLabel();
+ }
+ }
+
+ foreach ($resolver->getBehaviors($model) as $behavior) {
+ if ($behavior instanceof ReRoute) {
+ foreach ($behavior->getRoutes() as $name => $route) {
+ $relation = $resolver->resolveRelation(
+ $resolver->qualifyPath($route, $model->getTableName()),
+ $model
+ );
+ foreach ($resolver->getColumnDefinitions($relation->getTarget()) as $columnName => $definition) {
+ yield $name . '.' . $columnName => $definition->getLabel();
+ }
+ }
+ }
+ }
+
+ if ($model instanceof UnionModel) {
+ $queries = $model->getUnions();
+ $baseModelClass = end($queries)[0];
+ $model = new $baseModelClass();
+ }
+
+ $foreignMetaDataSources = [];
+ if (! $model instanceof Host) {
+ $foreignMetaDataSources[] = 'host.user';
+ $foreignMetaDataSources[] = 'host.usergroup';
+ }
+
+ if (! $model instanceof Service) {
+ $foreignMetaDataSources[] = 'service.user';
+ $foreignMetaDataSources[] = 'service.usergroup';
+ }
+
+ foreach ($foreignMetaDataSources as $path) {
+ $foreignColumnDefinitions = $resolver->getColumnDefinitions($resolver->resolveRelation(
+ $resolver->qualifyPath($path, $model->getTableName()),
+ $model
+ )->getTarget());
+ foreach ($foreignColumnDefinitions as $columnName => $columnDefinition) {
+ yield "$path.$columnName" => $columnDefinition->getLabel();
+ }
+ }
+ }
+
+ /**
+ * Collect all direct relations of the given model
+ *
+ * A direct relation is either a direct descendant of the model
+ * or a descendant of such related in a to-one cardinality.
+ *
+ * @param Resolver $resolver
+ * @param Model $subject
+ * @param array $models
+ * @param array $path
+ */
+ protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path)
+ {
+ foreach ($resolver->getRelations($subject) as $name => $relation) {
+ /** @var Relation $relation */
+ if (
+ empty($path) || (
+ ($name === 'state' && $path[count($path) - 1] !== 'last_comment')
+ || $name === 'last_comment'
+ || $name === 'notificationcommand' && $path[0] === 'notification'
+ )
+ ) {
+ $relationPath = [$name];
+ if ($relation instanceof HasOne && empty($path)) {
+ array_unshift($relationPath, $subject->getTableName());
+ }
+
+ $relationPath = array_merge($path, $relationPath);
+ $models[join('.', $relationPath)] = $relation->getTarget();
+ self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath);
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Web/Control/ViewModeSwitcher.php b/library/Icingadb/Web/Control/ViewModeSwitcher.php
new file mode 100644
index 0000000..8068aee
--- /dev/null
+++ b/library/Icingadb/Web/Control/ViewModeSwitcher.php
@@ -0,0 +1,219 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control;
+
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\FormElement\HiddenElement;
+use ipl\Html\FormElement\InputElement;
+use ipl\Html\HtmlElement;
+use ipl\Web\Common\FormUid;
+use ipl\Web\Widget\IcingaIcon;
+
+class ViewModeSwitcher extends Form
+{
+ use FormUid;
+
+ protected $defaultAttributes = [
+ 'class' => 'view-mode-switcher',
+ 'name' => 'view-mode-switcher'
+ ];
+
+ /** @var string Default view mode */
+ const DEFAULT_VIEW_MODE = 'common';
+
+ /** @var string Default view mode param */
+ const DEFAULT_VIEW_MODE_PARAM = 'view';
+
+ /** @var array View mode-icon pairs */
+ public static $viewModes = [
+ 'minimal' => 'minimal',
+ 'common' => 'default',
+ 'detailed' => 'detailed',
+ 'tabular' => 'tabular'
+ ];
+
+ /** @var string */
+ protected $defaultViewMode;
+
+ /** @var string */
+ protected $method = 'POST';
+
+ /** @var callable */
+ protected $protector;
+
+ /** @var string */
+ protected $viewModeParam = self::DEFAULT_VIEW_MODE_PARAM;
+
+ /**
+ * Get the default mode
+ *
+ * @return string
+ */
+ public function getDefaultViewMode(): string
+ {
+ return $this->defaultViewMode ?: static::DEFAULT_VIEW_MODE;
+ }
+
+ /**
+ * Set the default view mode
+ *
+ * @param string $defaultViewMode
+ *
+ * @return $this
+ */
+ public function setDefaultViewMode(string $defaultViewMode): self
+ {
+ $this->defaultViewMode = $defaultViewMode;
+
+ return $this;
+ }
+
+ /**
+ * Get the view mode URL parameter
+ *
+ * @return string
+ */
+ public function getViewModeParam(): string
+ {
+ return $this->viewModeParam;
+ }
+
+ /**
+ * Set the view mode URL parameter
+ *
+ * @param string $viewModeParam
+ *
+ * @return $this
+ */
+ public function setViewModeParam(string $viewModeParam): self
+ {
+ $this->viewModeParam = $viewModeParam;
+
+ return $this;
+ }
+
+ /**
+ * Get the view mode
+ *
+ * @return string
+ */
+ public function getViewMode(): string
+ {
+ $viewMode = $this->getPopulatedValue($this->getViewModeParam(), $this->getDefaultViewMode());
+
+ if (array_key_exists($viewMode, static::$viewModes)) {
+ return $viewMode;
+ }
+
+ return $this->getDefaultViewMode();
+ }
+
+ /**
+ * Set the view mode
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setViewMode(string $name)
+ {
+ $this->populate([$this->getViewModeParam() => $name]);
+
+ return $this;
+ }
+
+ /**
+ * Set callback to protect ids with
+ *
+ * @param callable $protector
+ *
+ * @return $this
+ */
+ public function setIdProtector(callable $protector): self
+ {
+ $this->protector = $protector;
+
+ return $this;
+ }
+
+ private function protectId($id)
+ {
+ if (is_callable($this->protector)) {
+ return call_user_func($this->protector, $id);
+ }
+
+ return $id;
+ }
+
+ protected function assemble()
+ {
+ $viewModeParam = $this->getViewModeParam();
+
+ $this->addElement($this->createUidElement());
+ $this->addElement(new HiddenElement($viewModeParam));
+
+ foreach (static::$viewModes as $viewMode => $icon) {
+ if ($viewMode === 'tabular') {
+ continue;
+ }
+
+ $protectedId = $this->protectId('view-mode-switcher-' . $icon);
+ $input = new InputElement($viewModeParam, [
+ 'class' => 'autosubmit',
+ 'id' => $protectedId,
+ 'name' => $viewModeParam,
+ 'type' => 'radio',
+ 'value' => $viewMode
+ ]);
+ $input->getAttributes()->registerAttributeCallback('checked', function () use ($viewMode) {
+ return $viewMode === $this->getViewMode();
+ });
+
+ $label = new HtmlElement(
+ 'label',
+ Attributes::create([
+ 'for' => $protectedId
+ ]),
+ new IcingaIcon($icon)
+ );
+ $label->getAttributes()->registerAttributeCallback('title', function () use ($viewMode) {
+
+ return $this->getTitle($viewMode);
+ });
+
+ $this->addHtml($input, $label);
+ }
+ }
+
+ /**
+ * Return the title for the view mode when it is active and inactive
+ *
+ * @param string $viewMode
+ *
+ * @return string Title for the view mode when it is active and inactive
+ */
+ protected function getTitle(string $viewMode): string
+ {
+ $active = null;
+ $inactive = null;
+ switch ($viewMode) {
+ case 'minimal':
+ $active = t('Minimal view active');
+ $inactive = t('Switch to minimal view');
+ break;
+ case 'common':
+ $active = t('Common view active');
+ $inactive = t('Switch to common view');
+ break;
+ case 'detailed':
+ $active = t('Detailed view active');
+ $inactive = t('Switch to detailed view');
+ break;
+ }
+
+ return $viewMode === $this->getViewMode() ? $active : $inactive;
+ }
+}
diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php
new file mode 100644
index 0000000..ad9f07e
--- /dev/null
+++ b/library/Icingadb/Web/Controller.php
@@ -0,0 +1,542 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web;
+
+use Exception;
+use Generator;
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Version;
+use Icinga\Application\Web;
+use Icinga\Data\ConfigObject;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\Http\HttpBadRequestException;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\SearchControls;
+use Icinga\Module\Icingadb\Data\CsvResultSet;
+use Icinga\Module\Icingadb\Data\JsonResultSet;
+use Icinga\Module\Icingadb\Web\Control\GridViewModeSwitcher;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ItemTable\StateItemTable;
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use Icinga\Module\Pdfexport\ProvidedHook\Pdfexport;
+use Icinga\Security\SecurityException;
+use Icinga\User\Preferences;
+use Icinga\User\Preferences\PreferencesStore;
+use Icinga\Util\Environment;
+use Icinga\Util\Json;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+use ipl\Orm\UnionQuery;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\PaginationControl;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class Controller extends CompatController
+{
+ use Auth;
+ use Database;
+ use SearchControls;
+
+ /** @var Filter\Rule Filter from query string parameters */
+ private $filter;
+
+ /** @var string|null */
+ private $format;
+
+ /** @var bool */
+ private $formatProcessed = false;
+
+ /**
+ * Get the filter created from query string parameters
+ *
+ * @return Filter\Rule
+ */
+ public function getFilter(): Filter\Rule
+ {
+ if ($this->filter === null) {
+ $this->filter = QueryString::parse((string) $this->params);
+ }
+
+ return $this->filter;
+ }
+
+ /**
+ * Create column control
+ *
+ * @param Query $query
+ * @param ViewModeSwitcher $viewModeSwitcher
+ *
+ * @return array provided columns
+ *
+ * @throws HttpBadRequestException
+ */
+ public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwitcher): array
+ {
+ // All of that is essentially what `ColumnControl::apply()` should do
+ $viewMode = $this->getRequest()->getUrl()->getParam($viewModeSwitcher->getViewModeParam());
+ $columnsDef = $this->params->shift('columns');
+ if (! $columnsDef) {
+ if ($viewMode === 'tabular') {
+ $this->httpBadRequest('Missing parameter "columns"');
+ }
+
+ return [];
+ }
+
+ $columns = [];
+ foreach (explode(',', $columnsDef) as $column) {
+ if ($column = trim($column)) {
+ $columns[] = $column;
+ }
+ }
+
+ $query->withColumns($columns);
+
+ if (! $viewMode) {
+ $viewModeSwitcher->setViewMode('tabular');
+ }
+
+ // For now this also returns the columns, but they should be accessible
+ // by calling `ColumnControl::getColumns()` in the future
+ return $columns;
+ }
+
+ /**
+ * Create and return the ViewModeSwitcher
+ *
+ * This automatically shifts the view mode URL parameter from {@link $params}.
+ *
+ * @param PaginationControl $paginationControl
+ * @param LimitControl $limitControl
+ * @param bool $verticalPagination
+ *
+ * @return ViewModeSwitcher|GridViewModeSwitcher
+ */
+ public function createViewModeSwitcher(
+ PaginationControl $paginationControl,
+ LimitControl $limitControl,
+ bool $verticalPagination = false
+ ): ViewModeSwitcher {
+ $controllerName = $this->getRequest()->getControllerName();
+
+ // TODO: Make this configurable somehow. The route shouldn't be checked to choose the view modes!
+ if ($controllerName === 'hostgroups' || $controllerName === 'servicegroups') {
+ $viewModeSwitcher = new GridViewModeSwitcher();
+ } else {
+ $viewModeSwitcher = new ViewModeSwitcher();
+ }
+
+ $viewModeSwitcher->setIdProtector([$this->getRequest(), 'protectId']);
+
+ $user = $this->Auth()->getUser();
+ if (($preferredModes = $user->getAdditional('icingadb.view_modes')) === null) {
+ try {
+ $preferredModes = Json::decode(
+ $user->getPreferences()->getValue('icingadb', 'view_modes', '[]'),
+ true
+ );
+ } catch (JsonDecodeException $e) {
+ Logger::error('Failed to load preferred view modes for user "%s": %s', $user->getUsername(), $e);
+ $preferredModes = [];
+ }
+
+ $user->setAdditional('icingadb.view_modes', $preferredModes);
+ }
+
+ $requestRoute = $this->getRequest()->getUrl()->getPath();
+ if (isset($preferredModes[$requestRoute])) {
+ $viewModeSwitcher->setDefaultViewMode($preferredModes[$requestRoute]);
+ }
+
+ $viewModeSwitcher->populate([
+ $viewModeSwitcher->getViewModeParam() => $this->params->shift($viewModeSwitcher->getViewModeParam())
+ ]);
+
+ $session = $this->Window()->getSessionNamespace(
+ 'icingadb-viewmode-' . $this->Window()->getContainerId()
+ );
+
+ $viewModeSwitcher->on(
+ ViewModeSwitcher::ON_SUCCESS,
+ function (ViewModeSwitcher $viewModeSwitcher) use (
+ $user,
+ $preferredModes,
+ $paginationControl,
+ $verticalPagination,
+ &$session
+ ) {
+ $viewMode = $viewModeSwitcher->getValue($viewModeSwitcher->getViewModeParam());
+ $requestUrl = Url::fromRequest();
+
+ $preferredModes[$requestUrl->getPath()] = $viewMode;
+ $user->setAdditional('icingadb.view_modes', $preferredModes);
+
+ try {
+ $preferencesStore = PreferencesStore::create(new ConfigObject([
+ //TODO: Don't set store key as it will no longer be needed once we drop support for
+ // lower version of icingaweb2 then v2.11.
+ //https://github.com/Icinga/icingaweb2/pull/4765
+ 'store' => Config::app()->get('global', 'config_backend', 'db'),
+ 'resource' => Config::app()->get('global', 'config_resource')
+ ]), $user);
+ $preferencesStore->load();
+ $preferencesStore->save(
+ new Preferences(['icingadb' => ['view_modes' => Json::encode($preferredModes)]])
+ );
+ } catch (Exception $e) {
+ Logger::error('Failed to save preferred view mode for user "%s": %s', $user->getUsername(), $e);
+ }
+
+ $pageParam = $paginationControl->getPageParam();
+ $limitParam = LimitControl::DEFAULT_LIMIT_PARAM;
+ $currentPage = $paginationControl->getCurrentPageNumber();
+
+ $requestUrl->setParam($viewModeSwitcher->getViewModeParam(), $viewMode);
+ if (! $requestUrl->hasParam($limitParam)) {
+ if ($viewMode === 'minimal' || $viewMode === 'grid') {
+ $session->set('previous_page', $currentPage);
+ $session->set('request_path', $requestUrl->getPath());
+
+ $limit = $paginationControl->getLimit();
+ if (! $verticalPagination) {
+ // We are computing it based on the first element being rendered on this current page
+ $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit * 2)) + 1);
+ } else {
+ $currentPage = (int) (round($currentPage * $limit / ($limit * 2)));
+ }
+
+ $session->set('current_page', $currentPage);
+ } elseif (
+ $viewModeSwitcher->getDefaultViewMode() === 'minimal'
+ || $viewModeSwitcher->getDefaultViewMode() === 'grid'
+ ) {
+ $limit = $paginationControl->getLimit();
+ if ($currentPage === $session->get('current_page')) {
+ // No other page numbers have been selected, i.e the user only
+ // switches back and forth without changing the page numbers
+ $currentPage = $session->get('previous_page');
+ } elseif (! $verticalPagination) {
+ $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit / 2)) + 1);
+ } else {
+ $currentPage = (int) (floor($currentPage * $limit / ($limit / 2)));
+ }
+
+ $session->clear();
+ }
+
+ if (($requestUrl->hasParam($pageParam) && $currentPage > 1) || $currentPage > 1) {
+ $requestUrl->setParam($pageParam, $currentPage);
+ } else {
+ $requestUrl->remove($pageParam);
+ }
+ }
+
+ $this->redirectNow($requestUrl);
+ }
+ )->handleRequest(ServerRequest::fromGlobals());
+
+ $viewMode = $viewModeSwitcher->getViewMode();
+ if ($viewMode === 'minimal' || $viewMode === 'grid') {
+ $hasLimitParam = Url::fromRequest()->hasParam($limitControl->getLimitParam());
+
+ if ($paginationControl->getDefaultPageSize() <= LimitControl::DEFAULT_LIMIT && ! $hasLimitParam) {
+ $paginationControl->setDefaultPageSize($paginationControl->getDefaultPageSize() * 2);
+ $limitControl->setDefaultLimit($limitControl->getDefaultLimit() * 2);
+
+ $paginationControl->apply();
+ }
+ }
+
+ $requestPath = $session->get('request_path');
+ if ($requestPath && $requestPath !== $requestRoute) {
+ $session->clear();
+ }
+
+ return $viewModeSwitcher;
+ }
+
+ /**
+ * Process a search request
+ *
+ * @param Query $query
+ * @param array $additionalColumns
+ *
+ * @return void
+ */
+ public function handleSearchRequest(Query $query, array $additionalColumns = [])
+ {
+ $q = trim($this->params->shift('q', ''), ' *');
+ if (! $q) {
+ return;
+ }
+
+ $filter = Filter::any();
+ $this->prepareSearchFilter($query, $q, $filter, $additionalColumns);
+
+ $redirectUrl = Url::fromRequest();
+ $redirectUrl->setParams($this->params)->setFilter($filter);
+
+ $this->getResponse()->redirectAndExit($redirectUrl);
+ }
+
+ /**
+ * Prepare the given search filter
+ *
+ * @param Query $query
+ * @param string $search
+ * @param Filter\Any $filter
+ * @param array $additionalColumns
+ *
+ * @return void
+ */
+ protected function prepareSearchFilter(Query $query, string $search, Filter\Any $filter, array $additionalColumns)
+ {
+ $columns = array_merge($query->getModel()->getSearchColumns(), $additionalColumns);
+ foreach ($columns as $column) {
+ $filter->add(Filter::like(
+ $query->getResolver()->qualifyColumn($column, $query->getModel()->getTableName()),
+ "*$search*"
+ ));
+ }
+ }
+
+ /**
+ * Require permission to access the given route
+ *
+ * @param string $name If NULL, the current controller name is used
+ *
+ * @throws SecurityException
+ */
+ public function assertRouteAccess(string $name = null)
+ {
+ if (! $name) {
+ $name = $this->getRequest()->getControllerName();
+ }
+
+ if (! $this->isPermittedRoute($name)) {
+ throw new SecurityException('No permission to access this route');
+ }
+ }
+
+ public function export(Query ...$queries)
+ {
+ if ($this->format === 'sql') {
+ foreach ($queries as $query) {
+ list($sql, $values) = $query->getDb()->getQueryBuilder()->assembleSelect($query->assembleSelect());
+
+ $unused = [];
+ foreach ($values as $value) {
+ $pos = strpos($sql, '?');
+ if ($pos !== false) {
+ if (is_string($value)) {
+ $value = "'" . $value . "'";
+ }
+
+ $sql = substr_replace($sql, $value, $pos, 1);
+ } else {
+ $unused[] = $value;
+ }
+ }
+
+ if (!empty($unused)) {
+ $sql .= ' /* Unused values: "' . join('", "', $unused) . '" */';
+ }
+
+ $this->content->add(Html::tag('pre', $sql));
+ }
+
+ return true;
+ }
+
+ // It only makes sense to export a single result to CSV or JSON
+ $query = $queries[0];
+
+ // No matter the format, a limit should only apply if set
+ if ($this->format !== null) {
+ $query->limit(Url::fromRequest()->getParam('limit'));
+ }
+
+ if ($this->format === 'json' || $this->format === 'csv') {
+ $response = $this->getResponse();
+ $fileName = $this->view->title;
+
+ ob_end_clean();
+ Environment::raiseExecutionTime();
+
+ if ($this->format === 'json') {
+ $response
+ ->setHeader('Content-Type', 'application/json')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $fileName . '.json'
+ )
+ ->sendResponse();
+
+ JsonResultSet::stream($query);
+ } else {
+ $response
+ ->setHeader('Content-Type', 'text/csv')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $fileName . '.csv'
+ )
+ ->sendResponse();
+
+ CsvResultSet::stream($query);
+ }
+ }
+
+ $this->getTabs()->enableDataExports();
+ }
+
+ /**
+ * @todo Remove once support for Icinga Web 2 v2.9.x is dropped
+ */
+ protected function sendAsPdf()
+ {
+ if (! Icinga::app()->getModuleManager()->has('pdfexport')) {
+ throw new ConfigurationError('The pdfexport module is required for exports to PDF');
+ }
+
+ if (version_compare(Version::VERSION, '2.10.0', '>=')) {
+ parent::sendAsPdf();
+ return;
+ }
+
+ putenv('ICINGAWEB_EXPORT_FORMAT=pdf');
+ Environment::raiseMemoryLimit('512M');
+ Environment::raiseExecutionTime(300);
+
+ $time = DateFormatter::formatDateTime(time());
+
+ $doc = (new PrintableHtmlDocument())
+ ->setTitle($this->view->title ?? '')
+ ->setHeader(Html::wantHtml([
+ Html::tag('span', ['class' => 'title']),
+ Html::tag('time', null, $time)
+ ]))
+ ->setFooter(Html::wantHtml([
+ Html::tag('span', null, [
+ t('Page') . ' ',
+ Html::tag('span', ['class' => 'pageNumber']),
+ ' / ',
+ Html::tag('span', ['class' => 'totalPages'])
+ ]),
+ Html::tag('p', null, Url::fromRequest()->setParams($this->params))
+ ]))
+ ->addHtml($this->content);
+ $doc->getAttributes()->add('class', 'icinga-module module-icingadb');
+
+ Pdfexport::first()->streamPdfFromHtml($doc, sprintf(
+ '%s-%s',
+ $this->view->title ?: $this->getRequest()->getActionName(),
+ $time
+ ));
+ }
+
+ public function dispatch($action)
+ {
+ // Notify helpers of action preDispatch state
+ $this->_helper->notifyPreDispatch();
+
+ $this->preDispatch();
+
+ if ($this->getRequest()->isDispatched()) {
+ // If pre-dispatch hooks introduced a redirect then stop dispatch
+ // @see ZF-7496
+ if (! $this->getResponse()->isRedirect()) {
+ $interceptable = $this->$action();
+ if ($interceptable instanceof Generator) {
+ foreach ($interceptable as $stopSignal) {
+ if ($stopSignal === true) {
+ $this->formatProcessed = true;
+ break;
+ }
+ }
+ }
+ }
+ $this->postDispatch();
+ }
+
+ // whats actually important here is that this action controller is
+ // shutting down, regardless of dispatching; notify the helpers of this
+ // state
+ $this->_helper->notifyPostDispatch();
+ }
+
+ protected function addContent(ValidHtml $content)
+ {
+ if ($content instanceof BaseItemList || $content instanceof BaseItemTable) {
+ $this->content->getAttributes()->add('class', 'full-width');
+ } elseif ($content instanceof StateItemTable) {
+ $this->content->getAttributes()->add('class', 'full-height');
+ }
+
+ return parent::addContent($content);
+ }
+
+ public function filter(Query $query, Filter\Rule $filter = null): self
+ {
+ if ($this->format !== 'sql' || $this->hasPermission('config/authentication/roles/show')) {
+ $this->applyRestrictions($query);
+ }
+
+ if ($query instanceof UnionQuery) {
+ foreach ($query->getUnions() as $query) {
+ $query->filter($filter ?: $this->getFilter());
+ }
+ } else {
+ $query->filter($filter ?: $this->getFilter());
+ }
+
+ return $this;
+ }
+
+ public function preDispatch()
+ {
+ parent::preDispatch();
+
+ $this->format = $this->params->shift('format');
+ }
+
+ public function postDispatch()
+ {
+ if (! $this->formatProcessed && $this->format !== null && $this->format !== 'pdf') {
+ // The purpose of this is not only to show that a requested format isn't supported.
+ // It's main purpose is to not allow to bypass restrictions with `?format=sql` as
+ // it may be possible that an action applies restrictions, but doesn't support any
+ // output formats. Since the restrictions are bypassed in method `$this->filter()`
+ // for the SQL output format and the actual format processing is part of a different
+ // method (`$this->export()`) which needs to be called explicitly by an action,
+ // it's otherwise possible for bad individuals to access unrestricted data.
+ $this->httpBadRequest(t('This route does not support the requested output format'));
+ }
+
+ parent::postDispatch();
+ }
+
+ protected function moduleInit()
+ {
+ /** @var Web $app */
+ $app = Icinga::app();
+ $app->getFrontController()
+ ->getPlugin('Zend_Controller_Plugin_ErrorHandler')
+ ->setErrorHandlerModule('icingadb');
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Action.php b/library/Icingadb/Web/Navigation/Action.php
new file mode 100644
index 0000000..d02f933
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Action.php
@@ -0,0 +1,134 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Macros;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Web\Navigation\NavigationItem;
+use ipl\Web\Url;
+
+class Action extends NavigationItem
+{
+ use Auth;
+ use Macros;
+
+ /**
+ * Whether this action's macros were already resolved
+ *
+ * @var bool
+ */
+ protected $resolved = false;
+
+ /**
+ * This action's object
+ *
+ * @var Host|Service
+ */
+ protected $object;
+
+ /**
+ * The filter to use when being asked whether to render this action
+ *
+ * @var string
+ */
+ protected $filter;
+
+ /**
+ * This action's raw url attribute
+ *
+ * @var string
+ */
+ protected $rawUrl;
+
+ /**
+ * Set this action's object
+ *
+ * @param Host|Service $object
+ *
+ * @return $this
+ */
+ public function setObject($object): self
+ {
+ $this->object = $object;
+
+ return $this;
+ }
+
+ /**
+ * Get this action's object
+ *
+ * @return Host|Service
+ */
+ protected function getObject()
+ {
+ return $this->object;
+ }
+
+ /**
+ * Set the filter to use when being asked whether to render this action
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setFilter(string $filter): self
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Get the filter to use when being asked whether to render this action
+ *
+ * @return ?string
+ */
+ public function getFilter(): ?string
+ {
+ return $this->filter;
+ }
+
+ /**
+ * Set this item's url
+ *
+ * @param \Icinga\Web\Url|string $url
+ *
+ * @return $this
+ */
+ public function setUrl($url): self
+ {
+ if (is_string($url)) {
+ $this->rawUrl = $url;
+ } else {
+ parent::setUrl($url);
+ }
+
+ return $this;
+ }
+
+ public function getUrl(): ?\Icinga\Web\Url
+ {
+ $url = parent::getUrl();
+ if (! $this->resolved && $url === null && $this->rawUrl !== null) {
+ $this->setUrl(Url::fromPath($this->expandMacros($this->rawUrl, $this->getObject())));
+ $this->resolved = true;
+ return parent::getUrl();
+ } else {
+ return $url;
+ }
+ }
+
+ public function getRender(): bool
+ {
+ if ($this->render === null) {
+ $filter = $this->getFilter();
+ $this->render = ! $filter || $this->isMatchedOn($filter, $this->getObject());
+ }
+
+ return $this->render;
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/IcingadbHostAction.php b/library/Icingadb/Web/Navigation/IcingadbHostAction.php
new file mode 100644
index 0000000..a5fc256
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/IcingadbHostAction.php
@@ -0,0 +1,9 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation;
+
+class IcingadbHostAction extends Action
+{
+}
diff --git a/library/Icingadb/Web/Navigation/IcingadbServiceAction.php b/library/Icingadb/Web/Navigation/IcingadbServiceAction.php
new file mode 100644
index 0000000..d623951
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/IcingadbServiceAction.php
@@ -0,0 +1,9 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation;
+
+class IcingadbServiceAction extends Action
+{
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
new file mode 100644
index 0000000..fc64c7d
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use ipl\Web\Url;
+
+class HostProblemsBadge extends ProblemsBadge
+{
+ use Auth;
+
+ protected function fetchProblemsCount()
+ {
+ $summary = HoststateSummary::on($this->getDb());
+ $this->applyRestrictions($summary);
+ $count = (int) $summary->first()->hosts_down_unhandled;
+ if ($count) {
+ $this->setTitle(sprintf(
+ tp('One unhandled host down', '%d unhandled hosts down', $count),
+ $count
+ ));
+ }
+
+ return $count;
+ }
+
+ protected function getUrl(): Url
+ {
+ return Links::hosts()->setParams(['host.state.is_problem' => 'y', 'sort' => 'host.state.severity desc']);
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
new file mode 100644
index 0000000..658fa1c
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
@@ -0,0 +1,173 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\Navigation\Renderer\NavigationItemRenderer;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+abstract class ProblemsBadge extends NavigationItemRenderer
+{
+ use Database;
+
+ const STATE_CRITICAL = 'critical';
+ const STATE_UNKNOWN = 'unknown';
+
+ /** @var int Count cache */
+ protected $count;
+
+ /** @var string State text */
+ protected $state;
+
+ /** @var string Title */
+ protected $title;
+
+ protected $linkDisabled;
+
+ abstract protected function fetchProblemsCount();
+
+ abstract protected function getUrl();
+
+ public function getProblemsCount()
+ {
+ if ($this->count === null) {
+ try {
+ $count = $this->fetchProblemsCount();
+ } catch (Exception $e) {
+ Logger::debug($e);
+
+ $this->count = 1;
+
+ $this->setState(static::STATE_UNKNOWN);
+ $this->setTitle($e->getMessage());
+
+ return $this->count;
+ }
+
+ $this->count = $this->round($count);
+
+ $this->setState(static::STATE_CRITICAL);
+ }
+
+ return $this->count;
+ }
+
+ /**
+ * Set the state text
+ *
+ * @param string $state
+ *
+ * @return $this
+ */
+ public function setState(string $state): self
+ {
+ $this->state = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get the state text
+ *
+ * @return string
+ */
+ public function getState(): string
+ {
+ if ($this->state === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->state;
+ }
+
+ /**
+ * Set the title
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle(string $title): self
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ /**
+ * Get the title
+ *
+ * @return ?string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ public function render(NavigationItem $item = null): string
+ {
+ if ($item === null) {
+ $item = $this->getItem();
+ }
+
+ $item->setCssClass('badge-nav-item icinga-module module-icingadb');
+
+ $html = new HtmlDocument();
+
+ $badge = $this->createBadge();
+ if ($badge !== null) {
+ if ($this->linkDisabled) {
+ $badge->addAttributes(['class' => 'disabled']);
+ $this->setEscapeLabel(false);
+ $label = $this->view()->escape($item->getLabel());
+ $item->setLabel($badge . $label);
+ } else {
+ $html->add(new Link($badge, $this->getUrl(), ['title' => $this->getTitle()]));
+ }
+ }
+
+ return $html
+ ->prepend(new HtmlString(parent::render($item)))
+ ->render();
+ }
+
+ protected function createBadge()
+ {
+ $count = $this->getProblemsCount();
+
+ if ($count) {
+ return (new StateBadge($count, $this->getState()))
+ ->addAttributes(['class' => 'badge', 'title' => $this->getTitle()]);
+ }
+
+ return null;
+ }
+
+ protected function round($count)
+ {
+ if ($count > 1000000) {
+ $count = round($count, -6) / 1000000 . 'M';
+ } elseif ($count > 1000) {
+ $count = round($count, -3) / 1000 . 'k';
+ }
+
+ return $count;
+ }
+
+ public function disableLink()
+ {
+ $this->linkDisabled = true;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
new file mode 100644
index 0000000..b2f2cae
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
@@ -0,0 +1,36 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use ipl\Web\Url;
+
+class ServiceProblemsBadge extends ProblemsBadge
+{
+ use Auth;
+
+ protected function fetchProblemsCount()
+ {
+ $summary = ServicestateSummary::on($this->getDb());
+ $this->applyRestrictions($summary);
+ $count = (int) $summary->first()->services_critical_unhandled;
+ if ($count) {
+ $this->setTitle(sprintf(
+ tp('One unhandled service critical', '%d unhandled services critical', $count),
+ $count
+ ));
+ }
+
+ return $count;
+ }
+
+ protected function getUrl(): Url
+ {
+ return Links::services()
+ ->setParams(['service.state.is_problem' => 'y', 'sort' => 'service.state.severity desc']);
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php
new file mode 100644
index 0000000..703db65
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php
@@ -0,0 +1,66 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
+
+class TotalProblemsBadge extends BadgeNavigationItemRenderer
+{
+ /**
+ * Cached count
+ *
+ * @var int
+ */
+ protected $count;
+
+ /**
+ * State to severity map
+ *
+ * @var array
+ */
+ protected static $stateSeverityMap = [
+ self::STATE_OK => 0,
+ self::STATE_PENDING => 1,
+ self::STATE_UNKNOWN => 2,
+ self::STATE_WARNING => 3,
+ self::STATE_CRITICAL => 4,
+ ];
+
+ /**
+ * Severity to state map
+ *
+ * @var array
+ */
+ protected static $severityStateMap = [
+ self::STATE_OK,
+ self::STATE_PENDING,
+ self::STATE_UNKNOWN,
+ self::STATE_WARNING,
+ self::STATE_CRITICAL
+ ];
+
+ public function getCount()
+ {
+ if ($this->count === null) {
+ $countMap = array_fill(0, 5, 0);
+ $maxSeverity = 0;
+ foreach ($this->getItem()->getChildren() as $child) {
+ $renderer = $child->getRenderer();
+ if ($renderer instanceof ProblemsBadge) {
+ $count = $renderer->getProblemsCount();
+ if ($count) {
+ $severity = static::$stateSeverityMap[$renderer->getState()];
+ $countMap[$severity] += $count;
+ $maxSeverity = max($maxSeverity, $severity);
+ }
+ }
+ }
+ $this->count = $countMap[$maxSeverity];
+ $this->state = static::$severityStateMap[$maxSeverity];
+ }
+
+ return $this->count;
+ }
+}
diff --git a/library/Icingadb/Widget/AttemptBall.php b/library/Icingadb/Widget/AttemptBall.php
new file mode 100644
index 0000000..e57c59c
--- /dev/null
+++ b/library/Icingadb/Widget/AttemptBall.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+
+/**
+ * Visually represents one single check attempt.
+ */
+class AttemptBall extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'ball'];
+
+ /**
+ * Create a new attempt ball
+ *
+ * @param bool $taken Whether the attempt was taken
+ */
+ public function __construct(bool $taken = false)
+ {
+ if ($taken) {
+ $this->addAttributes(['class' => 'ball-size-s taken']);
+ } else {
+ $this->addAttributes(['class' => 'ball-size-xs']);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/CheckAttempt.php b/library/Icingadb/Widget/CheckAttempt.php
new file mode 100644
index 0000000..cf12de3
--- /dev/null
+++ b/library/Icingadb/Widget/CheckAttempt.php
@@ -0,0 +1,54 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+
+/**
+ * Visually represents the check attempts taken out of max check attempts.
+ */
+class CheckAttempt extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'check-attempt'];
+
+ /** @var int Current attempt */
+ protected $attempt;
+
+ /** @var int Max check attempts */
+ protected $maxAttempts;
+
+ /**
+ * Create a new check attempt widget
+ *
+ * @param int $attempt Current check attempt
+ * @param int $maxAttempts Max check attempts
+ */
+ public function __construct(int $attempt, int $maxAttempts)
+ {
+ $this->attempt = $attempt;
+ $this->maxAttempts = $maxAttempts;
+ }
+
+ protected function assemble()
+ {
+ if ($this->attempt == $this->maxAttempts) {
+ return;
+ }
+
+ if ($this->maxAttempts > 5) {
+ $this->add(FormattedString::create('%d/%d', $this->attempt, $this->maxAttempts));
+ } else {
+ for ($i = 0; $i < $this->attempt; ++$i) {
+ $this->add(new AttemptBall(true));
+ }
+ for ($i = $this->attempt; $i < $this->maxAttempts; ++$i) {
+ $this->add(new AttemptBall());
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/CheckStatistics.php b/library/Icingadb/Widget/Detail/CheckStatistics.php
new file mode 100644
index 0000000..8a826d5
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CheckStatistics.php
@@ -0,0 +1,373 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Widget\CheckAttempt;
+use Icinga\Util\Format;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Web\Common\Card;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Compat\StyleWithNonce;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Web\Widget\TimeSince;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Web\Widget\VerticalKeyValue;
+
+class CheckStatistics extends Card
+{
+ const TOP_LEFT_BUBBLE_FLAG = <<<'SVG'
+<svg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'>
+ <path class='bg' d='M0 0L13 13L3.15334e-06 13L0 0Z'/>
+ <path class='border' fill-rule='evenodd' clip-rule='evenodd'
+ d='M0 0L3.3959e-06 14L14 14L0 0ZM1 2.41421L1 13L11.5858 13L1 2.41421Z'/>
+</svg>
+SVG;
+
+ const TOP_RIGHT_BUBBLE_FLAG = <<<'SVG'
+<svg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'>
+ <path class='bg' d="M12 0L-1 13L12 13L12 0Z"/>
+ <path class='border' fill-rule="evenodd" clip-rule="evenodd"
+ d="M12 0L12 14L-2 14L12 0ZM11 2.41421L11 13L0.414213 13L11 2.41421Z"/>
+</svg>
+SVG;
+
+ protected $object;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => ['progress-bar', 'check-statistics']];
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ }
+
+ protected function assembleBody(BaseHtmlElement $body)
+ {
+ $styleElement = (new StyleWithNonce())
+ ->setModule('icingadb');
+
+ $hPadding = 10;
+ $durationScale = 80;
+ $checkInterval = $this->getCheckInterval();
+
+ $timeline = new HtmlElement('div', Attributes::create(['class' => ['check-timeline', 'timeline']]));
+ $above = new HtmlElement('ul', Attributes::create(['class' => 'above']));
+ $below = new HtmlElement('ul', Attributes::create(['class' => 'below']));
+ $progressBar = new HtmlElement('div', Attributes::create(['class' => 'bar']));
+ $overdueBar = null;
+
+ $now = time();
+ $executionTime = ($this->object->state->execution_time / 1000) + ($this->object->state->latency / 1000);
+
+ $nextCheckTime = $this->object->state->next_check !== null && ! $this->isChecksDisabled()
+ ? $this->object->state->next_check->getTimestamp()
+ : null;
+ if ($this->object->state->is_overdue) {
+ $nextCheckTime = $this->object->state->next_update->getTimestamp();
+
+ $durationScale = 60;
+
+ $overdueBar = new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'timeline-overlay']),
+ new HtmlElement('div', Attributes::create(['class' => 'now']))
+ );
+
+ $above->addHtml(new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'now']),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'bubble']),
+ new HtmlElement('strong', null, Text::create(t('Now')))
+ )
+ ));
+
+ $this->getAttributes()->add('class', 'check-overdue');
+ } else {
+ $progressBar->addHtml(new HtmlElement('div', Attributes::create(['class' => 'now'])));
+ }
+
+ if ($nextCheckTime !== null && ! $this->object->state->is_overdue && $nextCheckTime < $now) {
+ // If the next check is already in the past but not overdue, it means the check is probably running.
+ // Icinga only updates the state once the check reports a result, that's why we have to simulate the
+ // execution start and end time, as well as the next check time.
+ $lastUpdateTime = $nextCheckTime;
+ $nextCheckTime = $this->object->state->next_update->getTimestamp() - $executionTime;
+ $executionEndTime = $lastUpdateTime + $executionTime;
+ } else {
+ $lastUpdateTime = $this->object->state->last_update !== null
+ ? $this->object->state->last_update->getTimestamp() - $executionTime
+ : null;
+ $executionEndTime = $this->object->state->last_update !== null
+ ? $this->object->state->last_update->getTimestamp()
+ : null;
+ }
+
+ if ($this->object->state->is_overdue) {
+ $leftNow = 100;
+ } elseif ($nextCheckTime === null) {
+ $leftNow = 0;
+ } elseif (! $this->object->state->is_reachable && time() - $executionEndTime > $checkInterval * 2) {
+ // We have no way of knowing whether the dependency pauses check scheduling.
+ // The only way to detect this, is to measure how old the last update is.
+ $nextCheckTime = null;
+ $leftNow = 0;
+ } elseif ($nextCheckTime - $lastUpdateTime <= 0) {
+ $leftNow = 0;
+ } else {
+ $leftNow = 100 * (1 - ($nextCheckTime - time()) / ($nextCheckTime - $lastUpdateTime));
+ if ($leftNow > 100) {
+ $leftNow = 100;
+ } elseif ($leftNow < 0) {
+ $leftNow = 0;
+ }
+ }
+
+ $styleElement->addFor($progressBar, ['width' => sprintf('%F%%', $leftNow)]);
+
+ $leftExecutionEnd = $nextCheckTime !== null && $nextCheckTime - $lastUpdateTime > 0 ? $durationScale * (
+ 1 - ($nextCheckTime - $executionEndTime) / ($nextCheckTime - $lastUpdateTime)
+ ) : 0;
+
+ $markerLast = new HtmlElement('div', Attributes::create([
+ 'class' => ['highlighted', 'marker', 'left'],
+ 'title' => $lastUpdateTime !== null ? DateFormatter::formatDateTime($lastUpdateTime) : null
+ ]));
+ $markerNext = new HtmlElement('div', Attributes::create([
+ 'class' => ['highlighted', 'marker', 'right'],
+ 'title' => $nextCheckTime !== null ? DateFormatter::formatDateTime($nextCheckTime) : null
+ ]));
+ $markerExecutionEnd = new HtmlElement('div', Attributes::create(['class' => ['highlighted', 'marker']]));
+ $styleElement->addFor($markerExecutionEnd, [
+ 'left' => sprintf('%F%%', $hPadding + $leftExecutionEnd)
+ ]);
+
+ $progress = new HtmlElement('div', Attributes::create([
+ 'class' => ['progress', time() < $executionEndTime ? 'running' : null]
+ ]), $progressBar);
+ if ($nextCheckTime !== null) {
+ $progress->addAttributes([
+ 'data-animate-progress' => true,
+ 'data-start-time' => $lastUpdateTime,
+ 'data-end-time' => $nextCheckTime,
+ 'data-switch-after' => $executionTime,
+ 'data-switch-class' => 'running'
+ ]);
+ }
+
+ $timeline->addHtml(
+ $progress,
+ $markerLast,
+ $markerExecutionEnd,
+ $markerNext
+ )->add($overdueBar);
+
+ $executionStart = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'left']),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => ['bubble', 'upwards', 'top-right-aligned']]),
+ new VerticalKeyValue(
+ t('Execution Start'),
+ $lastUpdateTime ? new TimeAgo($lastUpdateTime) : t('PENDING')
+ ),
+ HtmlString::create(self::TOP_RIGHT_BUBBLE_FLAG)
+ )
+ );
+ $executionEnd = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'positioned']),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => ['bubble', 'upwards', 'top-left-aligned']]),
+ new VerticalKeyValue(
+ t('Execution End'),
+ $executionEndTime !== null
+ ? ($executionEndTime > $now
+ ? new TimeUntil($executionEndTime)
+ : new TimeAgo($executionEndTime))
+ : t('PENDING')
+ ),
+ HtmlString::create(self::TOP_LEFT_BUBBLE_FLAG)
+ )
+ );
+
+ $styleElement->addFor($executionEnd, ['left' => sprintf('%F%%', $hPadding + $leftExecutionEnd)]);
+
+ $intervalLine = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'interval-line']),
+ new VerticalKeyValue(t('Interval'), Format::seconds($checkInterval))
+ );
+
+ $styleElement->addFor($intervalLine, [
+ 'left' => sprintf('%F%%', $hPadding + $leftExecutionEnd),
+ 'width' => sprintf('%F%%', $durationScale - $leftExecutionEnd)
+ ]);
+
+ $executionLine = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => ['interval-line', 'execution-line']]),
+ new VerticalKeyValue(
+ sprintf('%s / %s', t('Execution Time'), t('Latency')),
+ FormattedString::create(
+ '%s / %s',
+ $this->object->state->execution_time !== null
+ ? Format::seconds($this->object->state->execution_time / 1000)
+ : (new EmptyState(t('n. a.')))->setTag('span'),
+ $this->object->state->latency !== null
+ ? Format::seconds($this->object->state->latency / 1000)
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ )
+ );
+
+ $styleElement->addFor($executionLine, [
+ 'left' => sprintf('%F%%', $hPadding),
+ 'width' => sprintf('%F%%', $leftExecutionEnd)
+ ]);
+
+ if ($executionEndTime !== null) {
+ $executionLine->addHtml(new HtmlElement('div', Attributes::create(['class' => 'start'])));
+ $executionLine->addHtml(new HtmlElement('div', Attributes::create(['class' => 'end'])));
+ }
+
+ if ($this->isChecksDisabled()) {
+ $nextCheckBubbleContent = new VerticalKeyValue(
+ t('Next Check'),
+ t('n.a')
+ );
+
+ $this->addAttributes(['class' => 'checks-disabled']);
+ } else {
+ $nextCheckBubbleContent = $this->object->state->is_overdue
+ ? new VerticalKeyValue(t('Overdue'), new TimeSince($nextCheckTime))
+ : new VerticalKeyValue(
+ t('Next Check'),
+ $nextCheckTime !== null
+ ? ($nextCheckTime > $now
+ ? new TimeUntil($nextCheckTime)
+ : new TimeAgo($nextCheckTime))
+ : t('PENDING')
+ );
+ }
+
+ $nextCheck = new HtmlElement(
+ 'li',
+ Attributes::create(['class' => 'right']),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => ['bubble', 'upwards']]),
+ $nextCheckBubbleContent
+ )
+ );
+
+ $above->addHtml($executionLine);
+
+ $below->addHtml(
+ $executionStart,
+ $executionEnd,
+ $intervalLine,
+ $nextCheck
+ );
+
+ $body->addHtml($above, $timeline, $below, $styleElement);
+ }
+
+ /**
+ * Checks if both active and passive checks are disabled
+ *
+ * @return bool
+ */
+ protected function isChecksDisabled(): bool
+ {
+ return ! ($this->object->active_checks_enabled || $this->object->passive_checks_enabled);
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $checkSource = (new EmptyState(t('n. a.')))->setTag('span');
+ if ($this->object->state->check_source) {
+ $checkSource = Text::create($this->object->state->check_source);
+ }
+
+ $header->addHtml(
+ new VerticalKeyValue(t('Command'), $this->object->checkcommand_name),
+ new VerticalKeyValue(
+ t('Scheduling Source'),
+ $this->object->state->scheduling_source ?? (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ );
+
+ if ($this->object->timeperiod->id) {
+ $header->addHtml(new VerticalKeyValue(
+ t('Timeperiod'),
+ $this->object->timeperiod->display_name ?? $this->object->timeperiod->name
+ ));
+ }
+
+ $header->addHtml(
+ new VerticalKeyValue(
+ t('Attempts'),
+ new CheckAttempt((int) $this->object->state->check_attempt, (int) $this->object->max_check_attempts)
+ ),
+ new VerticalKeyValue(t('Check Source'), $checkSource)
+ );
+ }
+
+ /**
+ * Get the active `check_interval` OR `check_retry_interval`
+ *
+ * @return int
+ */
+ protected function getCheckInterval(): int
+ {
+ if (! ($this->object->state->is_problem && $this->object->state->state_type === 'soft')) {
+ return $this->object->check_interval;
+ }
+
+ $delay = ($this->object->state->execution_time + $this->object->state->latency) / 1000 + 5;
+ $interval = $this->object->state->next_check->getTimestamp()
+ - $this->object->state->last_update->getTimestamp();
+
+ // In case passive check is used, the check_retry_interval has no effect.
+ // Since there is no flag in the database to check if the passive check was triggered.
+ // We have to manually check if the check_retry_interval matches the calculated interval.
+ if (
+ $this->object->check_retry_interval - $delay <= $interval
+ && $this->object->check_retry_interval + $delay >= $interval
+ ) {
+ return $this->object->check_retry_interval;
+ }
+
+ return $this->object->check_interval;
+ }
+
+ protected function assemble()
+ {
+ parent::assemble();
+
+ if ($this->isChecksDisabled()) {
+ $this->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'checks-disabled-overlay']),
+ new HtmlElement(
+ 'strong',
+ Attributes::create(['class' => 'notes']),
+ Text::create(t('active and passive checks are disabled'))
+ )
+ ));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/CommentDetail.php b/library/Icingadb/Widget/Detail/CommentDetail.php
new file mode 100644
index 0000000..5b0923e
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CommentDetail.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteCommentForm;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class CommentDetail extends BaseHtmlElement
+{
+ use Auth;
+ use TicketLinks;
+
+ protected $comment;
+
+ protected $defaultAttributes = ['class' => ['object-detail', 'comment-detail']];
+
+ protected $tag = 'div';
+
+ public function __construct(Comment $comment)
+ {
+ $this->comment = $comment;
+ }
+
+ protected function createComment(): array
+ {
+ return [
+ Html::tag('h2', t('Comment')),
+ new MarkdownText($this->createTicketLinks($this->comment->text))
+ ];
+ }
+
+ protected function createDetails(): array
+ {
+ $details = [];
+
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ if ($this->comment->object_type === 'host') {
+ $details[] = new HorizontalKeyValue(t('Host'), [
+ $this->comment->host->name,
+ ' ',
+ new StateBall($this->comment->host->state->getStateText())
+ ]);
+ } else {
+ $details[] = new HorizontalKeyValue(t('Service'), Html::sprintf(
+ t('%s on %s', '<service> on <host>'),
+ [$this->comment->service->name, ' ', new StateBall($this->comment->service->state->getStateText())],
+ $this->comment->host->name
+ ));
+ }
+
+ $details[] = new HorizontalKeyValue(t('Author'), $this->comment->author);
+ $details[] = new HorizontalKeyValue(
+ t('Acknowledgement'),
+ $this->comment->entry_type === 'ack' ? t('Yes') : t('No')
+ );
+ $details[] = new HorizontalKeyValue(
+ t('Persistent'),
+ $this->comment->is_persistent ? t('Yes') : t('No')
+ );
+ $details[] = new HorizontalKeyValue(
+ t('Created'),
+ DateFormatter::formatDateTime($this->comment->entry_time->getTimestamp())
+ );
+ $details[] = new HorizontalKeyValue(t('Expires'), $this->comment->expire_time !== null
+ ? DateFormatter::formatDateTime($this->comment->expire_time->getTimestamp())
+ : t('Never'));
+ } else {
+ if ($this->comment->expire_time !== null) {
+ $details[] = Html::tag(
+ 'p',
+ Html::sprintf(
+ $this->comment->entry_type === 'ack'
+ ? t('This acknowledgement expires %s.', '..<time-until>')
+ : t('This comment expires %s.', '..<time-until>'),
+ new TimeUntil($this->comment->expire_time->getTimestamp())
+ )
+ );
+ }
+
+ if ($this->comment->is_sticky) {
+ $details[] = Html::tag('p', t('This acknowledgement is sticky.'));
+ }
+ }
+
+ if (! empty($details)) {
+ array_unshift($details, Html::tag('h2', t('Details')));
+ }
+
+ return $details;
+ }
+
+ protected function createRemoveCommentForm()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ return null;
+ }
+
+ $action = Links::commentsDelete();
+ $action->setFilter(Filter::equal('name', $this->comment->name));
+
+ return (new DeleteCommentForm())
+ ->setObjects([$this->comment])
+ ->populate(['redirect' => '__BACK__'])
+ ->setAction($action->getAbsoluteUrl());
+ }
+
+ protected function assemble()
+ {
+ $this->add($this->createComment());
+
+ $details = $this->createDetails();
+
+ if (! empty($details)) {
+ $this->add($details);
+ }
+
+ if (
+ $this->isGrantedOn(
+ 'icingadb/command/comment/delete',
+ $this->comment->{$this->comment->object_type}
+ )
+ ) {
+ $this->add($this->createRemoveCommentForm());
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/CustomVarTable.php b/library/Icingadb/Widget/Detail/CustomVarTable.php
new file mode 100644
index 0000000..9d6916b
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CustomVarTable.php
@@ -0,0 +1,268 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\CustomVarRendererHook;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+use Closure;
+
+class CustomVarTable extends BaseHtmlElement
+{
+ /** @var array The variables */
+ protected $data;
+
+ /** @var ?Model The object the variables are bound to */
+ protected $object;
+
+ /** @var Closure Callback to apply hooks */
+ protected $hookApplier;
+
+ /** @var array The groups as identified by hooks */
+ protected $groups = [];
+
+ /** @var string Header title */
+ protected $headerTitle;
+
+ /** @var int The nesting level */
+ protected $level = 0;
+
+ protected $tag = 'table';
+
+ /** @var HtmlElement The table body */
+ protected $body;
+
+ protected $defaultAttributes = [
+ 'class' => ['custom-var-table', 'name-value-table']
+ ];
+
+ /**
+ * Create a new CustomVarTable
+ *
+ * @param iterable $data
+ * @param ?Model $object
+ */
+ public function __construct($data, Model $object = null)
+ {
+ $this->data = $data;
+ $this->object = $object;
+ $this->body = new HtmlElement('tbody');
+ }
+
+ /**
+ * Set the header to show
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ protected function setHeader(string $title): self
+ {
+ $this->headerTitle = $title;
+
+ return $this;
+ }
+
+ /**
+ * Add a new row to the body
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function addRow($name, $value)
+ {
+ $this->body->addHtml(new HtmlElement(
+ 'tr',
+ Attributes::create(['class' => "level-{$this->level}"]),
+ new HtmlElement('th', null, Html::wantHtml($name)),
+ new HtmlElement('td', null, Html::wantHtml($value))
+ ));
+ }
+
+ /**
+ * Render a variable
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function renderVar($name, $value)
+ {
+ if ($this->object !== null && $this->level === 0) {
+ list($name, $value, $group) = call_user_func($this->hookApplier, $name, $value);
+ if ($group !== null) {
+ $this->groups[$group][] = [$name, $value];
+ return;
+ }
+ }
+
+ $isArray = is_array($value);
+ switch (true) {
+ case $isArray && is_int(key($value)):
+ $this->renderArray($name, $value);
+ break;
+ case $isArray:
+ $this->renderObject($name, $value);
+ break;
+ default:
+ $this->renderScalar($name, $value);
+ }
+ }
+
+ /**
+ * Render an array
+ *
+ * @param string $name
+ * @param array $array
+ *
+ * @return void
+ */
+ protected function renderArray($name, array $array)
+ {
+ $numItems = count($array);
+ $name = (new HtmlDocument())->addHtml(
+ Html::wantHtml($name),
+ Text::create(' (Array)')
+ );
+
+ $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems));
+
+ ++$this->level;
+
+ ksort($array);
+ foreach ($array as $key => $value) {
+ $this->renderVar("[$key]", $value);
+ }
+
+ --$this->level;
+ }
+
+ /**
+ * Render an object (associative array)
+ *
+ * @param mixed $name
+ * @param array $object
+ *
+ * @return void
+ */
+ protected function renderObject($name, array $object)
+ {
+ $numItems = count($object);
+ $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems));
+
+ ++$this->level;
+
+ ksort($object);
+ foreach ($object as $key => $value) {
+ $this->renderVar($key, $value);
+ }
+
+ --$this->level;
+ }
+
+ /**
+ * Render a scalar
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function renderScalar($name, $value)
+ {
+ if ($value === '') {
+ $value = new EmptyState(t('empty string'));
+ }
+
+ $this->addRow($name, $value);
+ }
+
+ /**
+ * Render a group
+ *
+ * @param string $name
+ * @param iterable $entries
+ *
+ * @return void
+ */
+ protected function renderGroup(string $name, $entries)
+ {
+ $table = new self($entries);
+
+ $wrapper = $this->getWrapper();
+ if ($wrapper === null) {
+ $wrapper = new HtmlDocument();
+ $wrapper->addHtml($this);
+ $this->prependWrapper($wrapper);
+ }
+
+ $wrapper->addHtml($table->setHeader($name));
+ }
+
+ protected function assemble()
+ {
+ if ($this->object !== null) {
+ $this->hookApplier = CustomVarRendererHook::prepareForObject($this->object);
+ }
+
+ if ($this->headerTitle !== null) {
+ $this->getAttributes()
+ ->add('class', 'collapsible')
+ ->add('data-visible-height', 100)
+ ->add('data-toggle-element', 'thead')
+ ->add(
+ 'id',
+ preg_replace('/\s+/', '-', strtolower($this->headerTitle)) . '-customvars'
+ );
+
+ $this->addHtml(new HtmlElement('thead', null, new HtmlElement(
+ 'tr',
+ null,
+ new HtmlElement(
+ 'th',
+ Attributes::create(['colspan' => 2]),
+ new HtmlElement(
+ 'span',
+ null,
+ new Icon('angle-right'),
+ new Icon('angle-down')
+ ),
+ Text::create($this->headerTitle)
+ )
+ )));
+ }
+
+ if (is_array($this->data)) {
+ ksort($this->data);
+ }
+
+ foreach ($this->data as $name => $value) {
+ $this->renderVar($name, $value);
+ }
+
+ $this->addHtml($this->body);
+
+ // Hooks can return objects as replacement for keys, hence a generator is needed for group entries
+ $genGenerator = function ($entries) {
+ foreach ($entries as list($key, $value)) {
+ yield $key => $value;
+ }
+ };
+
+ foreach ($this->groups as $group => $entries) {
+ $this->renderGroup($group, $genGenerator($entries));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/DowntimeCard.php b/library/Icingadb/Widget/Detail/DowntimeCard.php
new file mode 100644
index 0000000..81f59da
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/DowntimeCard.php
@@ -0,0 +1,258 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Model\Downtime;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Web\Compat\StyleWithNonce;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class DowntimeCard extends BaseHtmlElement
+{
+ protected $downtime;
+
+ protected $duration;
+
+ protected $defaultAttributes = ['class' => 'progress-bar downtime-progress'];
+
+ protected $tag = 'div';
+
+ protected $start;
+
+ protected $end;
+
+ public function __construct(Downtime $downtime)
+ {
+ $this->downtime = $downtime;
+
+ $this->start = $this->downtime->scheduled_start_time->getTimestamp();
+ $this->end = $this->downtime->scheduled_end_time->getTimestamp();
+
+ if ($this->downtime->end_time > $this->downtime->scheduled_end_time) {
+ $this->duration = $this->downtime->end_time->getTimestamp() - $this->start;
+ } else {
+ $this->duration = $this->end - $this->start;
+ }
+ }
+
+ protected function assemble()
+ {
+ $styleElement = (new StyleWithNonce())
+ ->setModule('icingadb');
+
+ $timeline = Html::tag('div', ['class' => 'downtime-timeline timeline']);
+ $hPadding = 10;
+
+ $above = Html::tag('ul', ['class' => 'above']);
+ $below = Html::tag('ul', ['class' => 'below']);
+
+ $markerStart = new HtmlElement('div', Attributes::create(['class' => ['marker' , 'left']]));
+ $markerEnd = new HtmlElement('div', Attributes::create(['class' => ['marker', 'right']]));
+
+ $timelineProgress = null;
+ $flexProgress = null;
+ $markerFlexStart = null;
+ $markerFlexEnd = null;
+
+ if ($this->end < time()) {
+ $endTime = new TimeAgo($this->end);
+ } else {
+ $endTime = new TimeUntil($this->end);
+ }
+
+ if ($this->downtime->is_flexible && $this->downtime->is_in_effect) {
+ $this->addAttributes(['class' => 'flexible in-effect']);
+
+ $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->downtime->start_time->getTimestamp());
+ $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->downtime->end_time->getTimestamp());
+
+ $evade = false;
+ if ($flexEndLeft - $flexStartLeft < 2) {
+ $flexStartLeft -= 1;
+ $flexEndLeft += 1;
+
+ if ($flexEndLeft > $hPadding + $this->calcRelativeLeft($this->end)) {
+ $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->end) - .5;
+ $flexStartLeft = $flexEndLeft - 2;
+ }
+
+ if ($flexStartLeft < $hPadding + $this->calcRelativeLeft($this->start)) {
+ $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->start) + .5;
+ $flexEndLeft = $flexStartLeft + 2;
+ }
+
+ $evade = true;
+ }
+
+ $markerFlexStart = new HtmlElement('div', Attributes::create(['class' => ['highlighted', 'marker']]));
+ $markerFlexEnd = new HtmlElement('div', Attributes::create(['class' => ['highlighted', 'marker']]));
+
+ $styleElement
+ ->addFor($markerFlexStart, ['left' => sprintf('%F%%', $flexStartLeft)])
+ ->addFor($markerFlexEnd, ['left' => sprintf('%F%%', $flexEndLeft)]);
+
+ $scheduledEndBubble = new HtmlElement(
+ 'li',
+ null,
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => ['bubble', 'upwards']]),
+ new VerticalKeyValue(t('Scheduled End'), $endTime)
+ )
+ );
+
+ $timelineProgress = new HtmlElement('div', Attributes::create([
+ 'class' => ['progress', 'downtime-elapsed'],
+ 'data-animate-progress' => true,
+ 'data-start-time' => ((float) $this->downtime->start_time->format('U.u')),
+ 'data-end-time' => ((float) $this->downtime->end_time->format('U.u'))
+ ]), new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'bar']),
+ new HtmlElement('div', Attributes::create(['class' => 'now']))
+ ));
+
+ $styleElement->addFor($timelineProgress, [
+ 'left' => sprintf('%F%%', $flexStartLeft),
+ 'width' => sprintf('%F%%', $flexEndLeft - $flexStartLeft)
+ ]);
+
+ if (time() > $this->end) {
+ $styleElement
+ ->addFor($markerEnd, [
+ 'left' => sprintf('%F%%', $hPadding + $this->calcRelativeLeft($this->end))
+ ])
+ ->addFor($scheduledEndBubble, [
+ 'left' => sprintf('%F%%', $hPadding + $this->calcRelativeLeft($this->end))
+ ]);
+ } else {
+ $scheduledEndBubble->getAttributes()
+ ->add('class', 'right');
+ }
+
+ $below->add([
+ Html::tag(
+ 'li',
+ ['class' => 'left'],
+ Html::tag(
+ 'div',
+ ['class' => ['bubble', 'upwards']],
+ new VerticalKeyValue(t('Scheduled Start'), new TimeAgo($this->start))
+ )
+ ),
+ $scheduledEndBubble
+ ]);
+
+ $aboveStart = Html::tag('li', ['class' => 'positioned'], Html::tag(
+ 'div',
+ ['class' => ['bubble', ($evade ? 'left-aligned' : null)]],
+ new VerticalKeyValue(t('Start'), new TimeAgo($this->downtime->start_time->getTimestamp()))
+ ));
+
+ $aboveEnd = Html::tag('li', ['class' => 'positioned'], Html::tag(
+ 'div',
+ ['class' => ['bubble', ($evade ? 'right-aligned' : null)]],
+ new VerticalKeyValue(t('End'), new TimeUntil($this->downtime->end_time->getTimestamp()))
+ ));
+
+ $styleElement
+ ->addFor($aboveStart, ['left' => sprintf('%F%%', $flexStartLeft)])
+ ->addFor($aboveEnd, ['left' => sprintf('%F%%', $flexEndLeft)]);
+
+ $above->add([$aboveStart, $aboveEnd, $styleElement]);
+ } elseif ($this->downtime->is_flexible) {
+ $this->addAttributes(['class' => 'flexible']);
+
+ $below->add([
+ Html::tag(
+ 'li',
+ ['class' => 'left'],
+ Html::tag(
+ 'div',
+ ['class' => ['bubble', 'upwards']],
+ new VerticalKeyValue(
+ t('Scheduled Start'),
+ time() > $this->start
+ ? new TimeAgo($this->start)
+ : new TimeUntil($this->start)
+ )
+ )
+ ),
+ Html::tag(
+ 'li',
+ ['class' => 'right'],
+ Html::tag(
+ 'div',
+ ['class' => ['bubble', 'upwards']],
+ new VerticalKeyValue(t('Scheduled End'), $endTime)
+ )
+ )
+ ]);
+
+ $above = null;
+ } else {
+ if (time() >= $this->start) {
+ $timelineProgress = new HtmlElement('div', Attributes::create([
+ 'class' => ['progress', 'downtime-elapsed'],
+ 'data-animate-progress' => true,
+ 'data-start-time' => $this->start,
+ 'data-end-time' => $this->end
+ ]), new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'bar']),
+ new HtmlElement('div', Attributes::create(['class' => 'now']))
+ ));
+ }
+
+ $below->add([
+ Html::tag(
+ 'li',
+ ['class' => 'left'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('Start'), new TimeAgo($this->start))
+ )
+ ),
+ Html::tag(
+ 'li',
+ ['class' => 'right'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('End'), new TimeUntil($this->end))
+ )
+ )
+ ]);
+
+ $above = null;
+ }
+
+ $timeline->add([
+ $timelineProgress,
+ $flexProgress,
+ $markerStart,
+ $markerEnd,
+ $markerFlexStart,
+ $markerFlexEnd
+ ]);
+
+ $this->add([
+ $above,
+ $timeline,
+ $below
+ ]);
+ }
+
+ protected function calcRelativeLeft($value)
+ {
+ return round(($value - $this->start) / $this->duration * 80, 2);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/DowntimeDetail.php b/library/Icingadb/Widget/Detail/DowntimeDetail.php
new file mode 100644
index 0000000..9e50f7f
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/DowntimeDetail.php
@@ -0,0 +1,206 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Date\DateFormatter as WebDateFormatter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteDowntimeForm;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBall;
+
+class DowntimeDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+ use HostLink;
+ use ServiceLink;
+
+ /** @var BaseHtmlElement */
+ protected $control;
+
+ /** @var Downtime */
+ protected $downtime;
+
+ protected $defaultAttributes = ['class' => ['object-detail', 'downtime-detail']];
+
+ protected $tag = 'div';
+
+ public function __construct(Downtime $downtime)
+ {
+ $this->downtime = $downtime;
+ }
+
+ protected function createCancelDowntimeForm()
+ {
+ $action = Links::downtimesDelete();
+ $action->setFilter(Filter::equal('name', $this->downtime->name));
+
+ return (new DeleteDowntimeForm())
+ ->setObjects([$this->downtime])
+ ->populate(['redirect' => '__BACK__'])
+ ->setAction($action->getAbsoluteUrl());
+ }
+
+ protected function createTimeline(): DowntimeCard
+ {
+ return new DowntimeCard($this->downtime);
+ }
+
+ protected function assemble()
+ {
+ $this->add(Html::tag('h2', t('Comment')));
+ $this->add(Html::tag('div', [
+ new Icon('user'),
+ Html::sprintf(
+ t('%s commented: %s', '<username> ..: <comment>'),
+ $this->downtime->author,
+ new MarkdownText($this->downtime->comment)
+ )
+ ]));
+
+ $this->add(Html::tag('h2', t('Details')));
+
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->addHtml(new HorizontalKeyValue(
+ t('Type'),
+ $this->downtime->is_flexible ? t('Flexible') : t('Fixed')
+ ));
+ if ($this->downtime->object_type === 'host') {
+ $this->addHtml(new HorizontalKeyValue(t('Host'), [
+ $this->downtime->host->name,
+ ' ',
+ new StateBall($this->downtime->host->state->getStateText())
+ ]));
+ } else {
+ $this->addHtml(new HorizontalKeyValue(t('Service'), Html::sprintf(
+ t('%s on %s', '<service> on <host>'),
+ [
+ $this->downtime->service->name,
+ ' ',
+ new StateBall($this->downtime->service->state->getStateText())
+ ],
+ $this->downtime->host->name
+ )));
+ }
+ }
+
+ if ($this->downtime->triggered_by_id !== null || $this->downtime->parent_id !== null) {
+ if ($this->downtime->triggered_by_id !== null) {
+ $label = t('Triggered By');
+ $relatedDowntime = $this->downtime->triggered_by;
+ } else {
+ $label = t('Parent');
+ $relatedDowntime = $this->downtime->parent;
+ }
+
+ $this->addHtml(new HorizontalKeyValue(
+ $label,
+ HtmlElement::create('span', ['class' => 'accompanying-text'], TemplateString::create(
+ $relatedDowntime->is_flexible
+ ? t('{{#link}}Flexible Downtime{{/link}} for %s')
+ : t('{{#link}}Fixed Downtime{{/link}} for %s'),
+ ['link' => new Link(null, Links::downtime($relatedDowntime), ['class' => 'subject'])],
+ ($relatedDowntime->object_type === 'host'
+ ? $this->createHostLink($relatedDowntime->host, true)
+ : $this->createServiceLink($relatedDowntime->service, $relatedDowntime->host, true))
+ ))
+ ));
+ }
+
+ $this->add(new HorizontalKeyValue(
+ t('Created'),
+ WebDateFormatter::formatDateTime($this->downtime->entry_time->getTimestamp())
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Start time'),
+ $this->downtime->start_time
+ ? WebDateFormatter::formatDateTime($this->downtime->start_time->getTimestamp())
+ : new EmptyState(t('Not started yet'))
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('End time'),
+ $this->downtime->end_time
+ ? WebDateFormatter::formatDateTime($this->downtime->end_time->getTimestamp())
+ : new EmptyState(t('Not started yet'))
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled Start'),
+ WebDateFormatter::formatDateTime($this->downtime->scheduled_start_time->getTimestamp())
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled End'),
+ WebDateFormatter::formatDateTime($this->downtime->scheduled_end_time->getTimestamp())
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled Duration'),
+ DateFormatter::formatDuration($this->downtime->scheduled_duration / 1000)
+ ));
+ if ($this->downtime->is_flexible) {
+ $this->add(new HorizontalKeyValue(
+ t('Flexible Duration'),
+ DateFormatter::formatDuration($this->downtime->flexible_duration / 1000)
+ ));
+ }
+
+ $query = Downtime::on($this->getDb())->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ])
+ ->limit(3)
+ ->filter(Filter::equal('parent_id', $this->downtime->id))
+ ->orFilter(Filter::equal('triggered_by_id', $this->downtime->id));
+ $this->applyRestrictions($query);
+
+ $children = $query->peekAhead()->execute();
+ if ($children->hasResult()) {
+ $this->addHtml(
+ new HtmlElement('h2', null, Text::create(t('Children'))),
+ new DowntimeList($children),
+ (new ShowMore($children, Links::downtimes()->setQueryString(
+ QueryString::render(Filter::any(
+ Filter::equal('downtime.parent.name', $this->downtime->name),
+ Filter::equal('downtime.triggered_by.name', $this->downtime->name)
+ ))
+ )))->setBaseTarget('_next')
+ );
+ }
+
+ $this->add(Html::tag('h2', t('Progress')));
+ $this->add($this->createTimeline());
+
+ if (
+ getenv('ICINGAWEB_EXPORT_FORMAT') !== 'pdf'
+ && $this->isGrantedOn(
+ 'icingadb/command/downtime/delete',
+ $this->downtime->{$this->downtime->object_type}
+ )
+ ) {
+ $this->add($this->createCancelDowntimeForm());
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/EventDetail.php b/library/Icingadb/Widget/Detail/EventDetail.php
new file mode 100644
index 0000000..181c9ae
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/EventDetail.php
@@ -0,0 +1,651 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use DateTime;
+use DateTimeZone;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Model\AcknowledgementHistory;
+use Icinga\Module\Icingadb\Model\CommentHistory;
+use Icinga\Module\Icingadb\Model\DowntimeHistory;
+use Icinga\Module\Icingadb\Model\FlappingHistory;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\NotificationHistory;
+use Icinga\Module\Icingadb\Model\StateHistory;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Widget\CopyToClipboard;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemTable\UserTable;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Str;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBall;
+
+class EventDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+ use HostLink;
+ use ServiceLink;
+ use TicketLinks;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'object-detail'];
+
+ /** @var History */
+ protected $event;
+
+ public function __construct(History $event)
+ {
+ $this->event = $event;
+ }
+
+ protected function assembleNotificationEvent(NotificationHistory $notification)
+ {
+ $pluginOutput = [];
+
+ $commandName = $notification->object_type === 'host'
+ ? $this->event->host->checkcommand_name
+ : $this->event->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($notification->text)) {
+ $notificationText = new EmptyState(t('Output unavailable.'));
+ } else {
+ $notificationText = new PluginOutputContainer(
+ (new PluginOutput($notification->text))
+ ->setCommandName($notification->object_type === 'host'
+ ? $this->event->host->checkcommand_name
+ : $this->event->service->checkcommand_name)
+ );
+
+ CopyToClipboard::attachTo($notificationText);
+ }
+
+ $pluginOutput = [
+ HtmlElement::create('h2', null, $notification->author ? t('Comment') : t('Plugin Output')),
+ HtmlElement::create('div', [
+ 'id' => 'check-output-' . $commandName,
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100
+ ], $notificationText)
+ ];
+ } else {
+ $pluginOutput[] = new EmptyState(t('Waiting for Icinga DB to synchronize the config.'));
+ }
+
+ if ($notification->object_type === 'host') {
+ $objectKey = t('Host');
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(HostStates::text($notification->previous_hard_state), StateBall::SIZE_MEDIUM),
+ new StateBall(HostStates::text($notification->state), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ]);
+ } else {
+ $objectKey = t('Service');
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(ServiceStates::text($notification->previous_hard_state), StateBall::SIZE_MEDIUM),
+ new StateBall(ServiceStates::text($notification->state), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ]);
+ }
+
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ new HorizontalKeyValue(
+ t('Sent On'),
+ DateFormatter::formatDateTime($notification->send_time->getTimestamp())
+ )
+ ];
+
+ if ($notification->author) {
+ $eventInfo[] = (new HorizontalKeyValue(t('Sent by'), [
+ new Icon('user'),
+ $notification->author
+ ]));
+ }
+
+ $eventInfo[] = new HorizontalKeyValue(t('Type'), ucfirst(Str::camel($notification->type)));
+ $eventInfo[] = new HorizontalKeyValue(t('State'), $notification->object_type === 'host'
+ ? ucfirst(HostStates::text($notification->state))
+ : ucfirst(ServiceStates::text($notification->state)));
+ $eventInfo[] = new HorizontalKeyValue($objectKey, $objectInfo);
+
+
+ $notifiedUsers = [new HtmlElement('h2', null, Text::create(t('Notified Users')))];
+
+ if ($notification->users_notified === 0) {
+ $notifiedUsers[] = new EmptyState(t('None', 'notified users: none'));
+ } elseif (! $this->isPermittedRoute('users')) {
+ $notifiedUsers[] = Text::create(sprintf(tp(
+ 'This notification was sent to a single user',
+ 'This notification was sent to %d users',
+ $notification->users_notified
+ ), $notification->users_notified));
+ } elseif ($notification->users_notified > 0) {
+ $users = $notification->user
+ ->limit(5)
+ ->peekAhead();
+
+ $users = $users->execute();
+ /** @var ResultSet $users */
+
+ $notifiedUsers[] = new UserTable($users);
+ $notifiedUsers[] = (new ShowMore(
+ $users,
+ Links::users()->addParams(['notification_history.id' => bin2hex($notification->id)]),
+ sprintf(t('Show all %d recipients'), $notification->users_notified)
+ ))->setBaseTarget('_next');
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $pluginOutput,
+ 200 => $eventInfo,
+ 500 => $notifiedUsers
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleStateChangeEvent(StateHistory $stateChange)
+ {
+ $pluginOutput = [];
+
+ $commandName = $stateChange->object_type === 'host'
+ ? $this->event->host->checkcommand_name
+ : $this->event->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($stateChange->output) && empty($stateChange->long_output)) {
+ $commandOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $commandOutput = new PluginOutputContainer(
+ (new PluginOutput($stateChange->output . "\n" . $stateChange->long_output))
+ ->setCommandName($commandName)
+ );
+
+ CopyToClipboard::attachTo($commandOutput);
+ }
+
+ $pluginOutput = [
+ new HtmlElement('h2', null, Text::create(t('Plugin Output'))),
+ HtmlElement::create('div', [
+ 'id' => 'check-output-' . $commandName,
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100
+ ], $commandOutput)
+ ];
+ } else {
+ $pluginOutput[] = new EmptyState(t('Waiting for Icinga DB to synchronize the config.'));
+ }
+
+ if ($stateChange->object_type === 'host') {
+ $objectKey = t('Host');
+ $objectState = $stateChange->state_type === 'hard' ? $stateChange->hard_state : $stateChange->soft_state;
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(HostStates::text($stateChange->previous_soft_state), StateBall::SIZE_MEDIUM),
+ new StateBall(HostStates::text($objectState), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ]);
+ } else {
+ $objectKey = t('Service');
+ $objectState = $stateChange->state_type === 'hard' ? $stateChange->hard_state : $stateChange->soft_state;
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(ServiceStates::text($stateChange->previous_soft_state), StateBall::SIZE_MEDIUM),
+ new StateBall(ServiceStates::text($objectState), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ]);
+ }
+
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ new HorizontalKeyValue(
+ t('Occurred On'),
+ DateFormatter::formatDateTime($stateChange->event_time->getTimestamp())
+ ),
+ new HorizontalKeyValue(t('Scheduling Source'), $stateChange->scheduling_source),
+ new HorizontalKeyValue(t('Check Source'), $stateChange->check_source)
+ ];
+
+ if ($stateChange->state_type === 'soft') {
+ $eventInfo[] = new HorizontalKeyValue(t('Check Attempt'), sprintf(
+ t('%d of %d'),
+ $stateChange->check_attempt,
+ $stateChange->max_check_attempts
+ ));
+ }
+
+ $eventInfo[] = new HorizontalKeyValue(
+ t('State'),
+ $stateChange->object_type === 'host'
+ ? ucfirst(HostStates::text($objectState))
+ : ucfirst(ServiceStates::text($objectState))
+ );
+
+ $eventInfo[] = new HorizontalKeyValue(
+ t('State Type'),
+ $stateChange->state_type === 'hard' ? t('Hard', 'state') : t('Soft', 'state')
+ );
+
+ $eventInfo[] = new HorizontalKeyValue($objectKey, $objectInfo);
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $pluginOutput,
+ 200 => $eventInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleDowntimeEvent(DowntimeHistory $downtime)
+ {
+ $commentInfo = [
+ new HtmlElement('h2', null, Text::create(t('Comment'))),
+ new MarkdownText($this->createTicketLinks($downtime->comment))
+ ];
+
+ $eventInfo = [new HtmlElement('h2', null, Text::create(t('Event Info')))];
+
+ if ($downtime->triggered_by_id !== null || $downtime->parent_id !== null) {
+ if ($downtime->triggered_by_id !== null) {
+ $label = t('Triggered By');
+ $relatedDowntime = $downtime->triggered_by;
+ } else {
+ $label = t('Parent');
+ $relatedDowntime = $downtime->parent;
+ }
+
+ $query = History::on($this->getDb())
+ ->columns('id')
+ ->filter(Filter::equal('event_type', 'downtime_start'))
+ ->filter(Filter::equal('history.downtime_history_id', $relatedDowntime->downtime_id));
+ $this->applyRestrictions($query);
+ if (($relatedEvent = $query->first()) !== null) {
+ /** @var History $relatedEvent */
+ $eventInfo[] = new HorizontalKeyValue(
+ $label,
+ HtmlElement::create('span', ['class' => 'accompanying-text'], TemplateString::create(
+ $relatedDowntime->is_flexible
+ ? t('{{#link}}Flexible Downtime{{/link}} for %s')
+ : t('{{#link}}Fixed Downtime{{/link}} for %s'),
+ ['link' => new Link(null, Links::event($relatedEvent), ['class' => 'subject'])],
+ ($relatedDowntime->object_type === 'host'
+ ? $this->createHostLink($relatedDowntime->host, true)
+ : $this->createServiceLink($relatedDowntime->service, $relatedDowntime->host, true))
+ ->addAttributes(['class' => 'subject'])
+ ))
+ );
+ }
+ }
+
+ $eventInfo[] = $downtime->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Entered On'),
+ DateFormatter::formatDateTime($downtime->entry_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Author'), [new Icon('user'), $downtime->author]);
+ // TODO: The following should be presented in a specific widget (maybe just like the downtime card)
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Triggered On'),
+ DateFormatter::formatDateTime($downtime->trigger_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Scheduled Start'),
+ DateFormatter::formatDateTime($downtime->scheduled_start_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Actual Start'),
+ DateFormatter::formatDateTime($downtime->start_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Scheduled End'),
+ DateFormatter::formatDateTime($downtime->scheduled_end_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Actual End'),
+ DateFormatter::formatDateTime($downtime->end_time->getTimestamp())
+ );
+
+ if ($downtime->is_flexible) {
+ $eventInfo[] = new HorizontalKeyValue(t('Flexible'), t('Yes'));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Duration'),
+ DateFormatter::formatDuration($downtime->flexible_duration / 1000)
+ );
+ }
+
+ $cancelInfo = [];
+ if ($downtime->has_been_cancelled) {
+ $cancelInfo = [
+ new HtmlElement('h2', null, Text::create(t('This downtime has been cancelled'))),
+ new HorizontalKeyValue(
+ t('Cancelled On'),
+ DateFormatter::formatDateTime($downtime->cancel_time->getTimestamp())
+ ),
+ new HorizontalKeyValue(t('Cancelled by'), [new Icon('user'), $downtime->cancelled_by])
+ ];
+ }
+
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $commentInfo,
+ 201 => $eventInfo,
+ 600 => $cancelInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleCommentEvent(CommentHistory $comment)
+ {
+ $commentInfo = [
+ new HtmlElement('h2', null, Text::create(t('Comment'))),
+ new MarkdownText($this->createTicketLinks($comment->comment))
+ ];
+
+ $eventInfo = [new HtmlElement('h2', null, Text::create(t('Event Info')))];
+ $eventInfo[] = $comment->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Entered On'),
+ DateFormatter::formatDateTime($comment->entry_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Author'), [new Icon('user'), $comment->author]);
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Expires On'),
+ $comment->expire_time
+ ? DateFormatter::formatDateTime($comment->expire_time->getTimestamp())
+ : new EmptyState(t('Never'))
+ );
+
+ $tiedToAckInfo = [];
+ if ($comment->entry_type === 'ack') {
+ $tiedToAckInfo = [
+ new HtmlElement('h2', null, Text::create(t('This comment is tied to an acknowledgement'))),
+ new HorizontalKeyValue(t('Sticky'), $comment->is_sticky ? t('Yes') : t('No')),
+ new HorizontalKeyValue(t('Persistent'), $comment->is_persistent ? t('Yes') : t('No'))
+ ];
+ }
+
+ $removedInfo = [];
+ if ($comment->has_been_removed) {
+ $removedInfo[] = new HtmlElement('h2', null, Text::create(t('This comment has been removed')));
+ if ($comment->removed_by) {
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Removed On'),
+ DateFormatter::formatDateTime($comment->remove_time->getTimestamp())
+ );
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Removed by'),
+ [new Icon('user'), $comment->removed_by]
+ );
+ } else {
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Expired On'),
+ DateFormatter::formatDateTime($comment->remove_time->getTimestamp())
+ );
+ }
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $commentInfo,
+ 201 => $eventInfo,
+ 500 => $tiedToAckInfo,
+ 600 => $removedInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleFlappingEvent(FlappingHistory $flapping)
+ {
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ $flapping->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ )),
+ new HorizontalKeyValue(
+ t('Started on'),
+ DateFormatter::formatDateTime($flapping->start_time->getTimestamp())
+ )
+ ];
+ if ($this->event->event_type === 'flapping_start') {
+ $eventInfo[] = new HorizontalKeyValue(t('Reason'), sprintf(
+ t('State change rate of %.2f%% exceeded the threshold (%.2f%%)'),
+ $flapping->percent_state_change_start,
+ $flapping->flapping_threshold_high
+ ));
+ } else {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Ended on'),
+ DateFormatter::formatDateTime($flapping->end_time->getTimestamp())
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Reason'), sprintf(
+ t('State change rate of %.2f%% undercut the threshold (%.2f%%)'),
+ $flapping->percent_state_change_end,
+ $flapping->flapping_threshold_low
+ ));
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $eventInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleAcknowledgeEvent(AcknowledgementHistory $acknowledgement)
+ {
+ $commentInfo = [];
+ if ($acknowledgement->comment) {
+ $commentInfo = [
+ new HtmlElement('h2', null, Text::create(t('Comment'))),
+ new MarkdownText($this->createTicketLinks($acknowledgement->comment))
+ ];
+ } elseif (! isset($acknowledgement->author)) {
+ $commentInfo[] = new EmptyState(t('This acknowledgement was set before Icinga DB history recording'));
+ }
+
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ new HorizontalKeyValue(
+ t('Set on'),
+ DateFormatter::formatDateTime($acknowledgement->set_time->getTimestamp())
+ ),
+ new HorizontalKeyValue(t('Author'), $acknowledgement->author
+ ? [new Icon('user'), $acknowledgement->author]
+ : new EmptyState(t('n. a.'))),
+ $acknowledgement->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ))
+ ];
+
+ if ($this->event->event_type === 'ack_set') {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Expires On'),
+ $acknowledgement->expire_time
+ ? DateFormatter::formatDateTime($acknowledgement->expire_time->getTimestamp())
+ : new EmptyState(t('Never'))
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Sticky'), isset($acknowledgement->is_sticky)
+ ? ($acknowledgement->is_sticky ? t('Yes') : t('No'))
+ : new EmptyState(t('n. a.')));
+ $eventInfo[] = new HorizontalKeyValue(t('Persistent'), isset($acknowledgement->is_persistent)
+ ? ($acknowledgement->is_persistent ? t('Yes') : t('No'))
+ : new EmptyState(t('n. a.')));
+ } else {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Cleared on'),
+ DateFormatter::formatDateTime(
+ $acknowledgement->clear_time
+ ? $acknowledgement->clear_time->getTimestamp()
+ : $this->event->event_time->getTimestamp()
+ )
+ );
+ if ($acknowledgement->cleared_by) {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Cleared by'),
+ [new Icon('user', $acknowledgement->cleared_by)]
+ );
+ } else {
+ $expired = false;
+ if ($acknowledgement->expire_time) {
+ $now = (new DateTime())->setTimezone(new DateTimeZone('UTC'));
+ $expiresOn = clone $now;
+ $expiresOn->setTimestamp($acknowledgement->expire_time->getTimestamp());
+ if ($now <= $expiresOn) {
+ $expired = true;
+ $eventInfo[] = new HorizontalKeyValue(t('Removal Reason'), t(
+ 'The acknowledgement expired on %s',
+ DateFormatter::formatDateTime($acknowledgement->expire_time->getTimestamp())
+ ));
+ }
+ }
+
+ if (! $expired) {
+ if ($acknowledgement->is_sticky) {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Reason'),
+ $acknowledgement->object_type === 'host'
+ ? t('Host recovered')
+ : t('Service recovered')
+ );
+ } else {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Reason'),
+ $acknowledgement->object_type === 'host'
+ ? t('Host recovered') // Hosts have no other state between UP and DOWN
+ : t('Service changed its state')
+ );
+ }
+ }
+ }
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $commentInfo,
+ 201 => $eventInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectDetailExtensionHook::loadExtensions($this->event);
+ }
+
+ protected function assemble()
+ {
+ switch ($this->event->event_type) {
+ case 'notification':
+ $this->assembleNotificationEvent($this->event->notification);
+
+ break;
+ case 'state_change':
+ $this->assembleStateChangeEvent($this->event->state);
+
+ break;
+ case 'downtime_start':
+ case 'downtime_end':
+ $this->assembleDowntimeEvent($this->event->downtime);
+
+ break;
+ case 'comment_add':
+ case 'comment_remove':
+ $this->assembleCommentEvent($this->event->comment);
+
+ break;
+ case 'flapping_start':
+ case 'flapping_end':
+ $this->assembleFlappingEvent($this->event->flapping);
+
+ break;
+ case 'ack_set':
+ case 'ack_clear':
+ $this->assembleAcknowledgeEvent($this->event->acknowledgement);
+
+ break;
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostDetail.php b/library/Icingadb/Widget/Detail/HostDetail.php
new file mode 100644
index 0000000..8b80480
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostDetail.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\EmptyState;
+
+class HostDetail extends ObjectDetail
+{
+ protected $serviceSummary;
+
+ public function __construct(Host $object, ServicestateSummary $serviceSummary)
+ {
+ parent::__construct($object);
+
+ $this->serviceSummary = $serviceSummary;
+ }
+
+ protected function createServiceStatistics(): array
+ {
+ if ($this->serviceSummary->services_total > 0) {
+ $services = new ServiceStatistics($this->serviceSummary);
+ $services->setBaseFilter(Filter::equal('host.name', $this->object->name));
+ } else {
+ $services = new EmptyState(t('This host has no services'));
+ }
+
+ return [Html::tag('h2', t('Services')), $services];
+ }
+
+ protected function assemble()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->add($this->createPrintHeader());
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $this->createPluginOutput(),
+ 190 => $this->createServiceStatistics(),
+ 300 => $this->createActions(),
+ 301 => $this->createNotes(),
+ 400 => $this->createComments(),
+ 401 => $this->createDowntimes(),
+ 500 => $this->createGroups(),
+ 501 => $this->createNotifications(),
+ 600 => $this->createCheckStatistics(),
+ 601 => $this->createPerformanceData(),
+ 700 => $this->createCustomVars(),
+ 701 => $this->createFeatureToggles()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostInspectionDetail.php b/library/Icingadb/Widget/Detail/HostInspectionDetail.php
new file mode 100644
index 0000000..93b35b8
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostInspectionDetail.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\ObjectInspectionDetail;
+
+class HostInspectionDetail extends ObjectInspectionDetail
+{
+ protected function assemble()
+ {
+ $this->add([
+ $this->createSourceLocation(),
+ $this->createLastCheckResult(),
+ $this->createAttributes(),
+ $this->createCustomVariables(),
+ $this->createRedisInfo()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostMetaInfo.php b/library/Icingadb/Widget/Detail/HostMetaInfo.php
new file mode 100644
index 0000000..78209aa
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostMetaInfo.php
@@ -0,0 +1,77 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Web\Widget\Icon;
+
+class HostMetaInfo extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'object-meta-info'];
+
+ /** @var Host */
+ protected $host;
+
+ public function __construct(Host $host)
+ {
+ $this->host = $host;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml(
+ new VerticalKeyValue('host.name', $this->host->name),
+ new HtmlElement(
+ 'div',
+ null,
+ new HorizontalKeyValue(
+ 'host.address',
+ $this->host->address ?: new EmptyState(t('None', 'address'))
+ ),
+ new HorizontalKeyValue(
+ 'host.address6',
+ $this->host->address6 ?: new EmptyState(t('None', 'address'))
+ )
+ ),
+ new VerticalKeyValue(
+ 'last_state_change',
+ $this->host->state->last_state_change !== null
+ ? DateFormatter::formatDateTime($this->host->state->last_state_change->getTimestamp())
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ );
+
+ $collapsible = new HtmlElement('div', Attributes::create([
+ 'class' => 'collapsible',
+ 'id' => 'object-meta-info',
+ 'data-toggle-element' => '.object-meta-info-control',
+ 'data-visible-height' => 0
+ ]));
+
+ $renderHelper = new HtmlDocument();
+ $renderHelper->addHtml(
+ $this,
+ new HtmlElement(
+ 'button',
+ Attributes::create(['class' => 'object-meta-info-control']),
+ new Icon('angle-double-up', ['class' => 'collapse-icon']),
+ new Icon('angle-double-down', ['class' => 'expand-icon'])
+ )
+ );
+
+ $this->addWrapper($collapsible);
+ $this->addWrapper($renderHelper);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostStatistics.php b/library/Icingadb/Widget/Detail/HostStatistics.php
new file mode 100644
index 0000000..bcfc3f8
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostStatistics.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\HostStateBadges;
+use ipl\Html\ValidHtml;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\HtmlString;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Link;
+
+class HostStatistics extends ObjectStatistics
+{
+ protected $summary;
+
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function createDonut(): ValidHtml
+ {
+ $donut = (new Donut())
+ ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending']);
+
+ return HtmlString::create($donut->render());
+ }
+
+ protected function createTotal(): ValidHtml
+ {
+ $url = Links::hosts();
+ if ($this->hasBaseFilter()) {
+ $url->setFilter($this->getBaseFilter());
+ }
+
+ return new Link(
+ (new VerticalKeyValue(
+ tp('Host', 'Hosts', $this->summary->hosts_total),
+ $this->shortenAmount($this->summary->hosts_total)
+ ))->setAttribute('title', $this->summary->hosts_total),
+ $url
+ );
+ }
+
+ protected function createBadges(): ValidHtml
+ {
+ $badges = new HostStateBadges($this->summary);
+ if ($this->hasBaseFilter()) {
+ $badges->setBaseFilter($this->getBaseFilter());
+ }
+
+ return $badges;
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/MultiselectQuickActions.php b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php
new file mode 100644
index 0000000..f398d80
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php
@@ -0,0 +1,194 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Stdlib\BaseFilter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+
+class MultiselectQuickActions extends BaseHtmlElement
+{
+ use BaseFilter;
+ use Auth;
+
+ protected $summary;
+
+ protected $type;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'quick-actions'];
+
+ public function __construct($type, $summary)
+ {
+ $this->summary = $summary;
+ $this->type = $type;
+ }
+
+ protected function assemble()
+ {
+ $unacknowledged = "{$this->type}s_problems_unacknowledged";
+ $acks = "{$this->type}s_acknowledged";
+ $activeChecks = "{$this->type}s_active_checks_enabled";
+
+ if (
+ $this->summary->$unacknowledged > $this->summary->$acks
+ && $this->isGrantedOnType(
+ 'icingadb/command/acknowledge-problem',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'acknowledge',
+ t('Acknowledge'),
+ 'check-circle',
+ t('Acknowledge this problem, suppress all future notifications for it and tag it as being handled')
+ );
+ }
+
+ if (
+ $this->summary->$acks > 0
+ && $this->isGrantedOnType(
+ 'icingadb/command/remove-acknowledgement',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $removeAckForm = (new RemoveAcknowledgementForm())
+ ->setAction($this->getLink('removeAcknowledgement'))
+ ->setObjects(array_fill(0, $this->summary->$acks, null));
+
+ $this->add(Html::tag('li', $removeAckForm));
+ }
+
+ if (
+ $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false)
+ || (
+ $this->summary->$activeChecks > 0
+ && $this->isGrantedOnType(
+ 'icingadb/command/schedule-check/active-only',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ )
+ ) {
+ $this->add(Html::tag('li', (new CheckNowForm())->setAction($this->getLink('checkNow'))));
+ }
+
+ if ($this->isGrantedOnType('icingadb/command/comment/add', $this->type, $this->getBaseFilter(), false)) {
+ $this->assembleAction(
+ 'addComment',
+ t('Comment'),
+ 'comment',
+ t('Add a new comment')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType(
+ 'icingadb/command/send-custom-notification',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'sendCustomNotification',
+ t('Notification'),
+ 'bell',
+ t('Send a custom notification')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType(
+ 'icingadb/command/downtime/schedule',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'scheduleDowntime',
+ t('Downtime'),
+ 'plug',
+ t('Schedule a downtime to suppress all problem notifications within a specific period of time')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false)
+ || (
+ $this->summary->$activeChecks > 0
+ && $this->isGrantedOnType(
+ 'icingadb/command/schedule-check/active-only',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ )
+ ) {
+ $this->assembleAction(
+ 'scheduleCheck',
+ t('Reschedule'),
+ 'calendar',
+ t('Schedule the next active check at a different time than the current one')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType(
+ 'icingadb/command/process-check-result',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'processCheckresult',
+ t('Process check result'),
+ 'edit',
+ t('Submit passive check result')
+ );
+ }
+ }
+
+ protected function assembleAction(string $action, string $label, string $icon, string $title)
+ {
+ $link = Html::tag(
+ 'a',
+ [
+ 'href' => $this->getLink($action),
+ 'class' => 'action-link',
+ 'title' => $title,
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ],
+ [
+ new Icon($icon),
+ $label
+ ]
+ );
+
+ $this->add(Html::tag('li', $link));
+ }
+
+ protected function getLink(string $action): string
+ {
+ return Url::fromPath("icingadb/{$this->type}s/$action")
+ ->setFilter($this->getBaseFilter())
+ ->getAbsoluteUrl();
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ObjectDetail.php b/library/Icingadb/Widget/Detail/ObjectDetail.php
new file mode 100644
index 0000000..a688173
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectDetail.php
@@ -0,0 +1,596 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Exception;
+use Icinga\Application\ClassLoader;
+use Icinga\Application\Hook;
+use Icinga\Application\Hook\GrapherHook;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Web;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\HostLinks;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\Macros;
+use Icinga\Module\Icingadb\Compat\CompatHost;
+use Icinga\Module\Icingadb\Compat\CompatService;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use Icinga\Module\Icingadb\Web\Navigation\Action;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Common\ServiceLinks;
+use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
+use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Web\Widget\CopyToClipboard;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\TagList;
+use Icinga\Module\Monitoring\Hook\DetailviewExtensionHook;
+use Icinga\Web\Navigation\Navigation;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class ObjectDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+ use Macros;
+
+ protected $object;
+
+ protected $compatObject;
+
+ protected $objectType;
+
+ protected $defaultAttributes = [
+ // Class host-detail is kept as the grafana module's iframe.js depends on it
+ 'class' => ['object-detail', 'host-detail'],
+ 'data-pdfexport-page-breaks-at' => 'h2'
+ ];
+
+ protected $tag = 'div';
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ $this->objectType = $object instanceof Host ? 'host' : 'service';
+ }
+
+ protected function compatObject()
+ {
+ if ($this->compatObject === null) {
+ $this->compatObject = CompatHost::fromModel($this->object);
+ }
+
+ return $this->compatObject;
+ }
+
+ protected function createPrintHeader()
+ {
+ $info = [new HorizontalKeyValue(t('Name'), $this->object->name)];
+
+ if ($this->objectType === 'host') {
+ $info[] = new HorizontalKeyValue(
+ t('IPv4 Address'),
+ $this->object->address ?: new EmptyState(t('None', 'address'))
+ );
+ $info[] = new HorizontalKeyValue(
+ t('IPv6 Address'),
+ $this->object->address6 ?: new EmptyState(t('None', 'address'))
+ );
+ }
+
+ $info[] = new HorizontalKeyValue(t('State'), [
+ $this->object->state->getStateTextTranslated(),
+ ' ',
+ new StateBall($this->object->state->getStateText())
+ ]);
+
+ $info[] = new HorizontalKeyValue(
+ t('Last State Change'),
+ DateFormatter::formatDateTime($this->object->state->last_state_change->getTimestamp())
+ );
+
+ return [
+ new HtmlElement('h2', null, Text::create(
+ $this->objectType === 'host' ? t('Host') : t('Service')
+ )),
+ $info
+ ];
+ }
+
+ protected function createActions()
+ {
+ $this->fetchCustomVars();
+
+ $navigation = new Navigation();
+ $navigation->load('icingadb-' . $this->objectType . '-action');
+ /** @var Action $item */
+ foreach ($navigation as $item) {
+ $item->setObject($this->object);
+ }
+
+ $monitoringInstalled = Icinga::app()->getModuleManager()->hasInstalled('monitoring');
+ $obj = $monitoringInstalled ? $this->compatObject() : $this->object;
+ foreach ($this->object->action_url->first()->action_url ?? [] as $url) {
+ $url = $this->expandMacros($url, $obj);
+ $navigation->addItem(
+ Html::wantHtml([
+ // Add warning to links that open in new tabs, as recommended by WCAG20 G201
+ new Icon('external-link-alt', ['title' => t('Link opens in a new window')]),
+ $url
+ ])->render(),
+ [
+ 'target' => '_blank',
+ 'url' => $url,
+ 'renderer' => [
+ 'NavigationItemRenderer',
+ 'escape_label' => false
+ ]
+ ]
+ );
+ }
+
+ $moduleActions = ObjectActionsHook::loadActions($this->object);
+
+ $nativeExtensionProviders = [];
+ foreach ($moduleActions->getContent() as $item) {
+ if ($item->getAttributes()->has('data-icinga-module')) {
+ $nativeExtensionProviders[$item->getAttributes()->get('data-icinga-module')->getValue()] = true;
+ }
+ }
+
+ if (Icinga::app()->getModuleManager()->hasInstalled('monitoring')) {
+ foreach (Hook::all('Monitoring\\' . ucfirst($this->objectType) . 'Actions') as $hook) {
+ $moduleName = ClassLoader::extractModuleName(get_class($hook));
+ if (! isset($nativeExtensionProviders[$moduleName])) {
+ try {
+ $navigation->merge($hook->getNavigation($this->compatObject()));
+ } catch (Exception $e) {
+ Logger::error("Failed to load legacy action hook: %s\n%s", $e, $e->getTraceAsString());
+ $navigation->addItem($moduleName, ['label' => IcingaException::describe($e), 'url' => '#']);
+ }
+ }
+ }
+ }
+
+ if ($moduleActions->isEmpty() && ($navigation->isEmpty() || ! $navigation->hasRenderableItems())) {
+ return null;
+ }
+
+ return [
+ Html::tag('h2', t('Actions')),
+ new HtmlString($navigation->getRenderer()->setCssClass('object-detail-actions')->render()),
+ $moduleActions->isEmpty() ? null : $moduleActions
+ ];
+ }
+
+ protected function createCheckStatistics(): array
+ {
+ return [
+ Html::tag('h2', t('Check Statistics')),
+ new CheckStatistics($this->object)
+ ];
+ }
+
+ protected function createComments(): array
+ {
+ if ($this->objectType === 'host') {
+ $link = HostLinks::comments($this->object);
+ $relations = ['host', 'host.state'];
+ } else {
+ $link = ServiceLinks::comments($this->object, $this->object->host);
+ $relations = ['service', 'service.state', 'service.host', 'service.host.state'];
+ }
+
+ $comments = $this->object->comment
+ ->with($relations)
+ ->limit(3)
+ ->peekAhead();
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $comments->filter(Filter::equal('object_type', $this->objectType));
+
+ $comments = $comments->execute();
+ /** @var ResultSet $comments */
+
+ $content = [Html::tag('h2', t('Comments'))];
+
+ if ($comments->hasResult()) {
+ $content[] = (new CommentList($comments))->setObjectLinkDisabled()->setTicketLinkEnabled();
+ $content[] = (new ShowMore($comments, $link))->setBaseTarget('_next');
+ } else {
+ $content[] = new EmptyState(t('No comments created.'));
+ }
+
+ return $content;
+ }
+
+ protected function createCustomVars(): array
+ {
+ $content = [Html::tag('h2', t('Custom Variables'))];
+
+ $this->fetchCustomVars();
+ $vars = (new CustomvarFlat())->unFlattenVars($this->object->customvar_flat);
+ if (! empty($vars)) {
+ $content[] = new HtmlElement('div', Attributes::create([
+ 'id' => $this->objectType . '-customvars',
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200
+ ]), new CustomVarTable($vars, $this->object));
+ } else {
+ $content[] = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return $content;
+ }
+
+ protected function createDowntimes(): array
+ {
+ if ($this->objectType === 'host') {
+ $link = HostLinks::downtimes($this->object);
+ $relations = ['host', 'host.state'];
+ } else {
+ $link = ServiceLinks::downtimes($this->object, $this->object->host);
+ $relations = ['service', 'service.state', 'service.host', 'service.host.state'];
+ }
+
+ $downtimes = $this->object->downtime
+ ->with($relations)
+ ->limit(3)
+ ->peekAhead();
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $downtimes->filter(Filter::equal('object_type', $this->objectType));
+
+ $downtimes = $downtimes->execute();
+ /** @var ResultSet $downtimes */
+
+ $content = [Html::tag('h2', t('Downtimes'))];
+
+ if ($downtimes->hasResult()) {
+ $content[] = (new DowntimeList($downtimes))->setObjectLinkDisabled()->setTicketLinkEnabled();
+ $content[] = (new ShowMore($downtimes, $link))->setBaseTarget('_next');
+ } else {
+ $content[] = new EmptyState(t('No downtimes scheduled.'));
+ }
+
+ return $content;
+ }
+
+ protected function createGroups(): array
+ {
+ $groups = [Html::tag('h2', t('Groups'))];
+
+ if ($this->objectType === 'host') {
+ $hostgroups = [];
+ if ($this->isPermittedRoute('hostgroups')) {
+ $hostgroups = $this->object->hostgroup;
+ $this->applyRestrictions($hostgroups);
+ }
+
+ $hostgroupList = new TagList();
+ foreach ($hostgroups as $hostgroup) {
+ $hostgroupList->addLink($hostgroup->display_name, Links::hostgroup($hostgroup));
+ }
+
+ $groups[] = $hostgroupList->hasContent()
+ ? $hostgroupList
+ : new EmptyState(t('Not a member of any host group.'));
+ } else {
+ $servicegroups = [];
+ if ($this->isPermittedRoute('servicegroups')) {
+ $servicegroups = $this->object->servicegroup;
+ $this->applyRestrictions($servicegroups);
+ }
+
+ $servicegroupList = new TagList();
+ foreach ($servicegroups as $servicegroup) {
+ $servicegroupList->addLink($servicegroup->display_name, Links::servicegroup($servicegroup));
+ }
+
+ $groups[] = $servicegroupList->hasContent()
+ ? $servicegroupList
+ : new EmptyState(t('Not a member of any service group.'));
+ }
+
+ return $groups;
+ }
+
+ protected function createNotes()
+ {
+ $navigation = new Navigation();
+ $notes = trim($this->object->notes);
+
+ $monitoringInstalled = Icinga::app()->getModuleManager()->hasInstalled('monitoring');
+ $obj = $monitoringInstalled ? $this->compatObject() : $this->object;
+ foreach ($this->object->notes_url->first()->notes_url ?? [] as $url) {
+ $url = $this->expandMacros($url, $obj);
+ $navigation->addItem(
+ Html::wantHtml([
+ // Add warning to links that open in new tabs, as recommended by WCAG20 G201
+ new Icon('external-link-alt', ['title' => t('Link opens in a new window')]),
+ $url
+ ])->render(),
+ [
+ 'target' => '_blank',
+ 'url' => $url,
+ 'renderer' => [
+ 'NavigationItemRenderer',
+ 'escape_label' => false
+ ]
+ ]
+ );
+ }
+
+ $content = [];
+
+ if (! $navigation->isEmpty() && $navigation->hasRenderableItems()) {
+ $content[] = new HtmlString($navigation->getRenderer()->setCssClass('object-detail-actions')->render());
+ }
+
+ if ($notes !== '') {
+ $content[] = (new MarkdownText($notes))
+ ->addAttributes([
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200,
+ 'id' => $this->objectType . '-notes'
+ ]);
+ }
+
+ if (empty($content)) {
+ return null;
+ }
+
+ array_unshift($content, Html::tag('h2', t('Notes')));
+
+ return $content;
+ }
+
+ protected function createNotifications(): array
+ {
+ list($users, $usergroups) = $this->getUsersAndUsergroups();
+
+ $userList = new TagList();
+ $usergroupList = new TagList();
+
+ foreach ($users as $user) {
+ $userList->addLink([new Icon(Icons::USER), $user->display_name], Links::user($user));
+ }
+
+ foreach ($usergroups as $usergroup) {
+ $usergroupList->addLink(
+ [new Icon(Icons::USERGROUP), $usergroup->display_name],
+ Links::usergroup($usergroup)
+ );
+ }
+
+ return [
+ Html::tag('h2', t('Notifications')),
+ new HorizontalKeyValue(
+ t('Users'),
+ $userList->hasContent() ? $userList : new EmptyState(t('No users configured.'))
+ ),
+ new HorizontalKeyValue(
+ t('User Groups'),
+ $usergroupList->hasContent()
+ ? $usergroupList
+ : new EmptyState(t('No user groups configured.'))
+ )
+ ];
+ }
+
+ protected function createPerformanceData(): array
+ {
+ $content[] = Html::tag('h2', t('Performance Data'));
+
+ if (empty($this->object->state->performance_data)) {
+ $content[] = new EmptyState(t('No performance data available.'));
+ } else {
+ $content[] = new HtmlElement(
+ 'div',
+ Attributes::create(['id' => 'check-perfdata-' . $this->object->checkcommand_name]),
+ new PerfDataTable($this->object->state->normalized_performance_data)
+ );
+ }
+
+ return $content;
+ }
+
+ protected function createPluginOutput(): array
+ {
+ if (empty($this->object->state->output) && empty($this->object->state->long_output)) {
+ $pluginOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->object));
+ CopyToClipboard::attachTo($pluginOutput);
+ }
+
+ return [
+ Html::tag('h2', t('Plugin Output')),
+ Html::tag(
+ 'div',
+ [
+ 'id' => 'check-output-' . $this->object->checkcommand_name,
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100
+ ],
+ $pluginOutput
+ )
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ $extensions = ObjectDetailExtensionHook::loadExtensions($this->object);
+
+ $nativeExtensionProviders = [];
+ foreach ($extensions as $extension) {
+ if ($extension instanceof BaseHtmlElement && $extension->getAttributes()->has('data-icinga-module')) {
+ $nativeExtensionProviders[$extension->getAttributes()->get('data-icinga-module')->getValue()] = true;
+ }
+ }
+
+ if (! Icinga::app()->getModuleManager()->hasInstalled('monitoring')) {
+ return $extensions;
+ }
+
+ foreach (Hook::all('Grapher') as $grapher) {
+ /** @var GrapherHook $grapher */
+ $moduleName = ClassLoader::extractModuleName(get_class($grapher));
+
+ if (isset($nativeExtensionProviders[$moduleName])) {
+ continue;
+ }
+
+ try {
+ $graph = HtmlString::create($grapher->getPreviewHtml($this->compatObject()));
+ } catch (Exception $e) {
+ Logger::error("Failed to load legacy grapher: %s\n%s", $e, $e->getTraceAsString());
+ $graph = Text::create(IcingaException::describe($e));
+ }
+
+ $location = ObjectDetailExtensionHook::BASE_LOCATIONS[ObjectDetailExtensionHook::GRAPH_SECTION];
+ while (isset($extensions[$location])) {
+ $location++;
+ }
+
+ $extensions[$location] = $graph;
+ }
+
+ foreach (Hook::all('Monitoring\DetailviewExtension') as $extension) {
+ /** @var DetailviewExtensionHook $extension */
+ $moduleName = $extension->getModule()->getName();
+
+ if (isset($nativeExtensionProviders[$moduleName])) {
+ continue;
+ }
+
+ try {
+ /** @var Web $app */
+ $app = Icinga::app();
+ $renderedExtension = $extension
+ ->setView($app->getViewRenderer()->view)
+ ->getHtmlForObject($this->compatObject());
+
+ $extensionHtml = new HtmlElement(
+ 'div',
+ Attributes::create([
+ 'class' => 'icinga-module module-' . $moduleName,
+ 'data-icinga-module' => $moduleName
+ ]),
+ HtmlString::create($renderedExtension)
+ );
+ } catch (Exception $e) {
+ Logger::error("Failed to load legacy detail extension: %s\n%s", $e, $e->getTraceAsString());
+ $extensionHtml = Text::create(IcingaException::describe($e));
+ }
+
+ $location = ObjectDetailExtensionHook::BASE_LOCATIONS[ObjectDetailExtensionHook::DETAIL_SECTION];
+ while (isset($extensions[$location])) {
+ $location++;
+ }
+
+ $extensions[$location] = $extensionHtml;
+ }
+
+ return $extensions;
+ }
+
+ protected function createFeatureToggles(): array
+ {
+ $form = new ToggleObjectFeaturesForm($this->object);
+
+ if ($this->objectType === 'host') {
+ $form->setAction(HostLinks::toggleFeatures($this->object)->getAbsoluteUrl());
+ } else {
+ $form->setAction(ServiceLinks::toggleFeatures($this->object, $this->object->host)->getAbsoluteUrl());
+ }
+
+ return [
+ Html::tag('h2', t('Feature Commands')),
+ $form
+ ];
+ }
+
+ protected function getUsersAndUsergroups(): array
+ {
+ $users = [];
+ $usergroups = [];
+ $groupBy = false;
+
+ if ($this->objectType === 'host') {
+ $objectFilter = Filter::all(
+ Filter::equal('notification.host_id', $this->object->id),
+ Filter::unlike('notification.service_id', '*')
+ );
+ $objectFilter->metaData()->set('forceOptimization', false);
+ $groupBy = true;
+ } else {
+ $objectFilter = Filter::equal(
+ 'notification.service_id',
+ $this->object->id
+ );
+ }
+
+ $userQuery = null;
+ if ($this->isPermittedRoute('users')) {
+ $userQuery = User::on($this->getDb());
+ $userQuery->filter($objectFilter);
+ $this->applyRestrictions($userQuery);
+ if ($groupBy) {
+ $userQuery->getSelectBase()->groupBy(['user.id']);
+ }
+
+ foreach ($userQuery as $user) {
+ $users[$user->name] = $user;
+ }
+ }
+
+ if ($this->isPermittedRoute('usergroups')) {
+ $usergroupQuery = Usergroup::on($this->getDb());
+ $usergroupQuery->filter($objectFilter);
+ $this->applyRestrictions($usergroupQuery);
+ if ($groupBy && $userQuery !== null) {
+ $userQuery->getSelectBase()->groupBy(['usergroup.id']);
+ }
+
+ foreach ($usergroupQuery as $usergroup) {
+ $usergroups[$usergroup->name] = $usergroup;
+ }
+ }
+
+ return [$users, $usergroups];
+ }
+
+ protected function fetchCustomVars()
+ {
+ $customvarFlat = $this->object->customvar_flat;
+ if (! $customvarFlat instanceof ResultSet) {
+ $this->applyRestrictions($customvarFlat);
+ $customvarFlat->withColumns(['customvar.name', 'customvar.value']);
+ $this->object->customvar_flat = $customvarFlat->execute();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ObjectStatistics.php b/library/Icingadb/Widget/Detail/ObjectStatistics.php
new file mode 100644
index 0000000..477bf5f
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectStatistics.php
@@ -0,0 +1,59 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use ipl\Stdlib\BaseFilter;
+
+abstract class ObjectStatistics extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'object-statistics'];
+
+ abstract protected function createDonut(): ValidHtml;
+
+ abstract protected function createTotal(): ValidHtml;
+
+ abstract protected function createBadges(): ValidHtml;
+
+ /**
+ * Shorten the given amount to 4 characters max
+ *
+ * @param int $amount
+ *
+ * @return string
+ */
+ protected function shortenAmount(int $amount): string
+ {
+ if ($amount < 10000) {
+ return (string) $amount;
+ }
+
+ if ($amount < 999500) {
+ return sprintf('%dk', round($amount / 1000.0));
+ }
+
+ if ($amount < 9959000) {
+ return sprintf('%.1fM', $amount / 1000000.0);
+ }
+
+ // I think we can rule out amounts over 1 Billion
+ return sprintf('%dM', $amount / 1000000.0);
+ }
+
+ protected function assemble()
+ {
+ $this->add([
+ Html::tag('li', ['class' => 'object-statistics-graph'], $this->createDonut()),
+ Html::tag('li', ['class' => ['object-statistics-total', 'text-center']], $this->createTotal()),
+ Html::tag('li', $this->createBadges())
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ObjectsDetail.php b/library/Icingadb/Widget/Detail/ObjectsDetail.php
new file mode 100644
index 0000000..a65dfe6
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectsDetail.php
@@ -0,0 +1,190 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Util\FeatureStatus;
+use Icinga\Module\Icingadb\Widget\HostStateBadges;
+use Icinga\Module\Icingadb\Widget\ServiceStateBadges;
+use ipl\Orm\Query;
+use ipl\Stdlib\BaseFilter;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\ActionLink;
+
+class ObjectsDetail extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ protected $summary;
+
+ protected $query;
+
+ protected $type;
+
+ protected $defaultAttributes = ['class' => 'objects-detail'];
+
+ protected $tag = 'div';
+
+ /**
+ * Construct an object detail summary widget
+ *
+ * @param string $type
+ * @param HoststateSummary|ServicestateSummary $summary
+ * @param Query $query
+ */
+ public function __construct(string $type, $summary, Query $query)
+ {
+ $this->summary = $summary;
+ $this->query = $query;
+ $this->type = $type;
+ }
+
+ protected function createChart(): BaseHtmlElement
+ {
+ $content = Html::tag('div', ['class' => 'multiselect-summary']);
+
+ if ($this->type === 'host') {
+ $hostsChart = (new Donut())
+ ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending']);
+
+ $badges = (new HostStateBadges($this->summary))
+ ->setBaseFilter($this->getBaseFilter());
+
+ $content->add([
+ HtmlString::create($hostsChart->render()),
+ new VerticalKeyValue(
+ tp('Host', 'Hosts', $this->summary->hosts_total),
+ $this->summary->hosts_total
+ ),
+ $badges
+ ]);
+ } else {
+ $servicesChart = (new Donut())
+ ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled'])
+ ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning'])
+ ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled'])
+ ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown'])
+ ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending']);
+
+ $badges = (new ServiceStateBadges($this->summary))
+ ->setBaseFilter($this->getBaseFilter());
+
+ $content->add([
+ HtmlString::create($servicesChart->render()),
+ new VerticalKeyValue(
+ tp('Service', 'Services', $this->summary->services_total),
+ $this->summary->services_total
+ ),
+ $badges
+ ]);
+ }
+
+ return $content;
+ }
+
+ protected function createComments(): array
+ {
+ $content = [Html::tag('h2', t('Comments'))];
+
+ if ($this->summary->comments_total > 0) {
+ $content[] = new ActionLink(
+ sprintf(
+ tp('Show %d comment', 'Show %d comments', $this->summary->comments_total),
+ $this->summary->comments_total
+ ),
+ Links::comments()->setFilter($this->getBaseFilter())
+ );
+ } else {
+ $content[] = new EmptyState(t('No comments created.'));
+ }
+
+ return $content;
+ }
+
+ protected function createDowntimes(): array
+ {
+ $content = [Html::tag('h2', t('Downtimes'))];
+
+ if ($this->summary->downtimes_total > 0) {
+ $content[] = new ActionLink(
+ sprintf(
+ tp('Show %d downtime', 'Show %d downtimes', $this->summary->downtimes_total),
+ $this->summary->downtimes_total
+ ),
+ Links::downtimes()->setFilter($this->getBaseFilter())
+ );
+ } else {
+ $content[] = new EmptyState(t('No downtimes scheduled.'));
+ }
+
+ return $content;
+ }
+
+ protected function createFeatureToggles(): array
+ {
+ $form = new ToggleObjectFeaturesForm(new FeatureStatus($this->type, $this->summary));
+
+ if ($this->type === 'host') {
+ $form->setAction(
+ Links::toggleHostsFeatures()
+ ->setFilter($this->getBaseFilter())
+ ->getAbsoluteUrl()
+ );
+ } else {
+ $form->setAction(
+ Links::toggleServicesFeatures()
+ ->setFilter($this->getBaseFilter())
+ ->getAbsoluteUrl()
+ );
+ }
+
+ return [
+ Html::tag('h2', t('Feature Commands')),
+ $form
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectsDetailExtensionHook::loadExtensions(
+ $this->type,
+ $this->query,
+ $this->getBaseFilter()
+ );
+ }
+
+ protected function createSummary(): array
+ {
+ return [
+ Html::tag('h2', t('Summary')),
+ $this->createChart()
+ ];
+ }
+
+ protected function assemble()
+ {
+ $this->add(ObjectsDetailExtensionHook::injectExtensions([
+ 190 => $this->createSummary(),
+ 400 => $this->createComments(),
+ 401 => $this->createDowntimes(),
+ 701 => $this->createFeatureToggles()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/PerfDataTable.php b/library/Icingadb/Widget/Detail/PerfDataTable.php
new file mode 100644
index 0000000..aaee7c9
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/PerfDataTable.php
@@ -0,0 +1,130 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Util\PerfData;
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Table;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+
+class PerfDataTable extends Table
+{
+ use Translation;
+
+ protected $defaultAttributes = [
+ 'class' => 'performance-data-table collapsible',
+ 'data-visible-rows' => 6
+ ];
+
+ /** @var string The perfdata string */
+ protected $perfdataStr;
+
+ /** @var int Max labels to show; 0 for no limit */
+ protected $limit;
+
+ /** @var string The color indicating the perfdata state */
+ protected $color;
+
+ /**
+ * Display the given perfdata string to the user
+ *
+ * @param string $perfdataStr The perfdata string
+ * @param int $limit Max labels to show; 0 for no limit
+ * @param string $color The color indicating the perfdata state
+ */
+ public function __construct(string $perfdataStr, int $limit = 0, string $color = PerfData::PERFDATA_OK)
+ {
+ $this->perfdataStr = $perfdataStr;
+ $this->limit = $limit;
+ $this->color = $color;
+ }
+
+ public function assemble()
+ {
+ $pieChartData = PerfDataSet::fromString($this->perfdataStr)->asArray();
+ $keys = [
+ '' => '',
+ 'label' => t('Label'),
+ 'value' => t('Value'),
+ 'min' => t('Min'),
+ 'max' => t('Max'),
+ 'warn' => t('Warning'),
+ 'crit' => t('Critical')
+ ];
+
+ $containsSparkline = false;
+ foreach ($pieChartData as $perfdata) {
+ if ($perfdata->isVisualizable() || ! $perfdata->isValid()) {
+ $containsSparkline = true;
+ break;
+ }
+ }
+
+ $headerRow = new HtmlElement('tr');
+ foreach ($keys as $key => $col) {
+ if (! $containsSparkline && $key === '') {
+ continue;
+ }
+
+ $headerRow->addHtml(new HtmlElement('th', Attributes::create([
+ 'class' => $key === 'label' ? 'title' : null
+ ]), Text::create($col)));
+ }
+
+ $this->getHeader()->addHtml($headerRow);
+
+ $count = 0;
+ foreach ($pieChartData as $perfdata) {
+ if ($this->limit > 0 && $count === $this->limit) {
+ break;
+ }
+
+ $count++;
+ $cols = [];
+ if ($containsSparkline) {
+ if ($perfdata->isVisualizable()) {
+ $cols[] = Table::td(
+ HtmlString::create($perfdata->asInlinePie($this->color)->render()),
+ ['class' => 'sparkline-col']
+ );
+ } elseif (! $perfdata->isValid()) {
+ $cols[] = Table::td(
+ new Icon(
+ 'triangle-exclamation',
+ [
+ 'title' => $this->translate(
+ 'Evaluation failed. Performance data is invalid.'
+ ),
+ 'class' => ['invalid-perfdata']
+ ]
+ ),
+ ['class' => 'sparkline-col']
+ );
+ } else {
+ $cols[] = Table::td('');
+ }
+ }
+
+ foreach ($perfdata->toArray() as $column => $value) {
+ $cols[] = Table::td(
+ new HtmlElement(
+ 'span',
+ Attributes::create(['class' => $value ? null : 'no-value']),
+ $value ? Text::create($value) : new EmptyState(t('None', 'value'))
+ ),
+ ['class' => $column === 'label' ? 'title' : null]
+ );
+ }
+
+ $this->addHtml(Table::tr($cols));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/QuickActions.php b/library/Icingadb/Widget/Detail/QuickActions.php
new file mode 100644
index 0000000..2ea26c2
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/QuickActions.php
@@ -0,0 +1,148 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\HostLinks;
+use Icinga\Module\Icingadb\Common\ServiceLinks;
+use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\Icon;
+
+class QuickActions extends BaseHtmlElement
+{
+ use Auth;
+
+ /** @var Host|Service */
+ protected $object;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'quick-actions'];
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ }
+
+ protected function assemble()
+ {
+ if ($this->object->state->is_problem) {
+ if ($this->object->state->is_acknowledged) {
+ if ($this->isGrantedOn('icingadb/command/remove-acknowledgement', $this->object)) {
+ $removeAckForm = (new RemoveAcknowledgementForm())
+ ->setAction($this->getLink('removeAcknowledgement'))
+ ->setObjects([$this->object]);
+
+ $this->add(Html::tag('li', $removeAckForm));
+ }
+ } elseif ($this->isGrantedOn('icingadb/command/acknowledge-problem', $this->object)) {
+ $this->assembleAction(
+ 'acknowledge',
+ t('Acknowledge'),
+ 'check-circle',
+ t('Acknowledge this problem, suppress all future notifications for it and tag it as being handled')
+ );
+ }
+ }
+
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $this->object)
+ || (
+ $this->object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $this->object)
+ )
+ ) {
+ $this->add(Html::tag('li', (new CheckNowForm())->setAction($this->getLink('checkNow'))));
+ }
+
+ if ($this->isGrantedOn('icingadb/command/comment/add', $this->object)) {
+ $this->assembleAction(
+ 'addComment',
+ t('Comment', 'verb'),
+ 'comment',
+ t('Add a new comment')
+ );
+ }
+
+ if ($this->isGrantedOn('icingadb/command/send-custom-notification', $this->object)) {
+ $this->assembleAction(
+ 'sendCustomNotification',
+ t('Notification'),
+ 'bell',
+ t('Send a custom notification')
+ );
+ }
+
+ if ($this->isGrantedOn('icingadb/command/downtime/schedule', $this->object)) {
+ $this->assembleAction(
+ 'scheduleDowntime',
+ t('Downtime'),
+ 'plug',
+ t('Schedule a downtime to suppress all problem notifications within a specific period of time')
+ );
+ }
+
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $this->object)
+ || (
+ $this->object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $this->object)
+ )
+ ) {
+ $this->assembleAction(
+ 'scheduleCheck',
+ t('Reschedule'),
+ 'calendar',
+ t('Schedule the next active check at a different time than the current one')
+ );
+ }
+
+ if ($this->isGrantedOn('icingadb/command/process-check-result', $this->object)) {
+ $this->assembleAction(
+ 'processCheckresult',
+ t('Process check result'),
+ 'edit',
+ sprintf(
+ t('Submit a one time or so called passive result for the %s check'),
+ $this->object->checkcommand_name
+ )
+ );
+ }
+ }
+
+ protected function assembleAction(string $action, string $label, string $icon, string $title)
+ {
+ $link = Html::tag(
+ 'a',
+ [
+ 'href' => $this->getLink($action),
+ 'class' => 'action-link',
+ 'title' => $title,
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ],
+ [
+ new Icon($icon),
+ $label
+ ]
+ );
+
+ $this->add(Html::tag('li', $link));
+ }
+
+ protected function getLink($action)
+ {
+ if ($this->object instanceof Host) {
+ return HostLinks::$action($this->object)->getAbsoluteUrl();
+ } else {
+ return ServiceLinks::$action($this->object, $this->object->host)->getAbsoluteUrl();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceDetail.php b/library/Icingadb/Widget/Detail/ServiceDetail.php
new file mode 100644
index 0000000..8421e31
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceDetail.php
@@ -0,0 +1,37 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Service;
+
+class ServiceDetail extends ObjectDetail
+{
+ public function __construct(Service $object)
+ {
+ parent::__construct($object);
+ }
+
+ protected function assemble()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->add($this->createPrintHeader());
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $this->createPluginOutput(),
+ 300 => $this->createActions(),
+ 301 => $this->createNotes(),
+ 400 => $this->createComments(),
+ 401 => $this->createDowntimes(),
+ 500 => $this->createGroups(),
+ 501 => $this->createNotifications(),
+ 600 => $this->createCheckStatistics(),
+ 601 => $this->createPerformanceData(),
+ 700 => $this->createCustomVars(),
+ 701 => $this->createFeatureToggles()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php b/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php
new file mode 100644
index 0000000..f29ee9b
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\ObjectInspectionDetail;
+
+class ServiceInspectionDetail extends ObjectInspectionDetail
+{
+ protected function assemble()
+ {
+ $this->add([
+ $this->createSourceLocation(),
+ $this->createLastCheckResult(),
+ $this->createAttributes(),
+ $this->createCustomVariables(),
+ $this->createRedisInfo()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceMetaInfo.php b/library/Icingadb/Widget/Detail/ServiceMetaInfo.php
new file mode 100644
index 0000000..cca7237
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceMetaInfo.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Web\Widget\Icon;
+
+class ServiceMetaInfo extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'object-meta-info'];
+
+ /** @var Service */
+ protected $service;
+
+ public function __construct(Service $service)
+ {
+ $this->service = $service;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml(
+ new VerticalKeyValue('service.name', $this->service->name),
+ new VerticalKeyValue(
+ 'last_state_change',
+ DateFormatter::formatDateTime($this->service->state->last_state_change->getTimestamp())
+ )
+ );
+
+ $collapsible = new HtmlElement('div', Attributes::create([
+ 'class' => 'collapsible',
+ 'id' => 'object-meta-info',
+ 'data-toggle-element' => '.object-meta-info-control',
+ 'data-visible-height' => 0
+ ]));
+
+ $renderHelper = new HtmlDocument();
+ $renderHelper->addHtml(
+ $this,
+ new HtmlElement(
+ 'button',
+ Attributes::create(['class' => 'object-meta-info-control']),
+ new Icon('angle-double-up', ['class' => 'collapse-icon']),
+ new Icon('angle-double-down', ['class' => 'expand-icon'])
+ )
+ );
+
+ $this->addWrapper($collapsible);
+ $this->addWrapper($renderHelper);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceStatistics.php b/library/Icingadb/Widget/Detail/ServiceStatistics.php
new file mode 100644
index 0000000..51aced1
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceStatistics.php
@@ -0,0 +1,64 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\ServiceStateBadges;
+use ipl\Html\ValidHtml;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\Link;
+
+class ServiceStatistics extends ObjectStatistics
+{
+ protected $summary;
+
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function createDonut(): ValidHtml
+ {
+ $donut = (new Donut())
+ ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled'])
+ ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning'])
+ ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled'])
+ ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown'])
+ ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending']);
+
+ return HtmlString::create($donut->render());
+ }
+
+ protected function createTotal(): ValidHtml
+ {
+ $url = Links::services();
+ if ($this->hasBaseFilter()) {
+ $url->setFilter($this->getBaseFilter());
+ }
+
+ return new Link(
+ (new VerticalKeyValue(
+ tp('Service', 'Services', $this->summary->services_total),
+ $this->shortenAmount($this->summary->services_total)
+ ))->setAttribute('title', $this->summary->services_total),
+ $url
+ );
+ }
+
+ protected function createBadges(): ValidHtml
+ {
+ $badges = new ServiceStateBadges($this->summary);
+ if ($this->hasBaseFilter()) {
+ $badges->setBaseFilter($this->getBaseFilter());
+ }
+
+ return $badges;
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/UserDetail.php b/library/Icingadb/Widget/Detail/UserDetail.php
new file mode 100644
index 0000000..0bc1acb
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/UserDetail.php
@@ -0,0 +1,188 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Html\Attributes;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemTable\UsergroupTable;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+
+class UserDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+
+ /** @var User The given user */
+ protected $user;
+
+ protected $defaultAttributes = ['class' => 'object-detail'];
+
+ protected $tag = 'div';
+
+ public function __construct(User $user)
+ {
+ $this->user = $user;
+ }
+
+ protected function createCustomVars(): array
+ {
+ $content = [new HtmlElement('h2', null, Text::create(t('Custom Variables')))];
+ $flattenedVars = $this->user->customvar_flat;
+ $this->applyRestrictions($flattenedVars);
+
+ $vars = $this->user->customvar_flat->getModel()->unflattenVars($flattenedVars);
+ if (! empty($vars)) {
+ $content[] = new HtmlElement('div', Attributes::create([
+ 'id' => 'user-customvars',
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200
+ ]), new CustomVarTable($vars, $this->user));
+ } else {
+ $content[] = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return $content;
+ }
+
+ protected function createUserDetail(): array
+ {
+ list($hostStates, $serviceStates) = $this->separateStates($this->user->states);
+ $hostStates = implode(', ', $this->localizeStates($hostStates));
+ $serviceStates = implode(', ', $this->localizeStates($serviceStates));
+ $types = implode(', ', $this->localizeTypes($this->user->types));
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Details'))),
+ new HorizontalKeyValue(t('Name'), $this->user->name),
+ new HorizontalKeyValue(t('E-Mail'), $this->user->email ?: new EmptyState(t('None', 'address'))),
+ new HorizontalKeyValue(t('Pager'), $this->user->pager ?: new EmptyState(t('None', 'phone-number'))),
+ new HorizontalKeyValue(t('Host States'), $hostStates ?: t('All')),
+ new HorizontalKeyValue(t('Service States'), $serviceStates ?: t('All')),
+ new HorizontalKeyValue(t('Types'), $types ?: t('All'))
+ ];
+ }
+
+ protected function createUsergroupList(): array
+ {
+ $userGroups = $this->user->usergroup->limit(6)->peekAhead()->execute();
+
+ $showMoreLink = (new ShowMore(
+ $userGroups,
+ Links::usergroups()->addParams(['user.name' => $this->user->name])
+ ))->setBaseTarget('_next');
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Groups'))),
+ new UsergroupTable($userGroups),
+ $showMoreLink
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectDetailExtensionHook::loadExtensions($this->user);
+ }
+
+ protected function assemble()
+ {
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $this->createUserDetail(),
+ 500 => $this->createUsergroupList(),
+ 700 => $this->createCustomVars()
+ ], $this->createExtensions()));
+ }
+
+ private function localizeTypes(array $types): array
+ {
+ $localizedTypes = [];
+ foreach ($types as $type) {
+ switch ($type) {
+ case 'problem':
+ $localizedTypes[] = t('Problem');
+ break;
+ case 'ack':
+ $localizedTypes[] = t('Acknowledgement');
+ break;
+ case 'recovery':
+ $localizedTypes[] = t('Recovery');
+ break;
+ case 'downtime_start':
+ $localizedTypes[] = t('Downtime Start');
+ break;
+ case 'downtime_end':
+ $localizedTypes[] = t('Downtime End');
+ break;
+ case 'downtime_removed':
+ $localizedTypes[] = t('Downtime Removed');
+ break;
+ case 'flapping_start':
+ $localizedTypes[] = t('Flapping Start');
+ break;
+ case 'flapping_end':
+ $localizedTypes[] = t('Flapping End');
+ break;
+ case 'custom':
+ $localizedTypes[] = t('Custom');
+ break;
+ }
+ }
+
+ return $localizedTypes;
+ }
+
+ private function localizeStates(array $states): array
+ {
+ $localizedState = [];
+ foreach ($states as $state) {
+ switch ($state) {
+ case 'up':
+ $localizedState[] = t('Up');
+ break;
+ case 'down':
+ $localizedState[] = t('Down');
+ break;
+ case 'ok':
+ $localizedState[] = t('Ok');
+ break;
+ case 'warning':
+ $localizedState[] = t('Warning');
+ break;
+ case 'critical':
+ $localizedState[] = t('Critical');
+ break;
+ case 'unknown':
+ $localizedState[] = t('Unknown');
+ break;
+ }
+ }
+
+ return $localizedState;
+ }
+
+ private function separateStates(array $states): array
+ {
+ $hostStates = [];
+ $serviceStates = [];
+
+ foreach ($states as $state) {
+ if ($state === 'Up' || $state === 'Down') {
+ $hostStates[] = $state;
+ } else {
+ $serviceStates[] = $state;
+ }
+ }
+
+ return [$hostStates, $serviceStates];
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/UsergroupDetail.php b/library/Icingadb/Widget/Detail/UsergroupDetail.php
new file mode 100644
index 0000000..249c795
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/UsergroupDetail.php
@@ -0,0 +1,98 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Widget\ItemTable\UserTable;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+
+class UsergroupDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+
+ /** @var Usergroup The given user group */
+ protected $usergroup;
+
+ protected $defaultAttributes = ['class' => 'object-detail'];
+
+ protected $tag = 'div';
+
+ public function __construct(Usergroup $usergroup)
+ {
+ $this->usergroup = $usergroup;
+ }
+
+ protected function createPrintHeader()
+ {
+ return [
+ new HtmlElement('h2', null, Text::create(t('Details'))),
+ new HorizontalKeyValue(t('Name'), $this->usergroup->name)
+ ];
+ }
+
+ protected function createCustomVars(): array
+ {
+ $content = [new HtmlElement('h2', null, Text::create(t('Custom Variables')))];
+ $flattenedVars = $this->usergroup->customvar_flat;
+ $this->applyRestrictions($flattenedVars);
+
+ $vars = $this->usergroup->customvar_flat->getModel()->unflattenVars($flattenedVars);
+ if (! empty($vars)) {
+ $content[] = new HtmlElement('div', Attributes::create([
+ 'id' => 'usergroup-customvars',
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200
+ ]), new CustomVarTable($vars, $this->usergroup));
+ } else {
+ $content[] = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return $content;
+ }
+
+ protected function createUserList(): array
+ {
+ $users = $this->usergroup->user->limit(6)->peekAhead()->execute();
+
+ $showMoreLink = (new ShowMore(
+ $users,
+ Links::users()->addParams(['usergroup.name' => $this->usergroup->name])
+ ))->setBaseTarget('_next');
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Users'))),
+ new UserTable($users),
+ $showMoreLink
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectDetailExtensionHook::loadExtensions($this->usergroup);
+ }
+
+ protected function assemble()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->add($this->createPrintHeader());
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 500 => $this->createUserList(),
+ 700 => $this->createCustomVars()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Health.php b/library/Icingadb/Widget/Health.php
new file mode 100644
index 0000000..8c99dca
--- /dev/null
+++ b/library/Icingadb/Widget/Health.php
@@ -0,0 +1,66 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Web\Widget\TimeSince;
+use ipl\Web\Widget\VerticalKeyValue;
+
+class Health extends BaseHtmlElement
+{
+ protected $data;
+
+ protected $tag = 'section';
+
+ public function __construct($data)
+ {
+ $this->data = $data;
+ }
+
+ protected function assemble()
+ {
+ if ($this->data->heartbeat->getTimestamp() > time() - 60) {
+ $this->add(Html::tag('div', ['class' => 'icinga-health up'], [
+ Html::sprintf(
+ t('Icinga 2 is up and running %s', '...since <timespan>'),
+ new TimeSince($this->data->icinga2_start_time->getTimestamp())
+ )
+ ]));
+ } else {
+ $this->add(Html::tag('div', ['class' => 'icinga-health down'], [
+ Html::sprintf(
+ t('Icinga 2 or Icinga DB is not running %s', '...since <timespan>'),
+ new TimeSince($this->data->heartbeat->getTimestamp())
+ )
+ ]));
+ }
+
+ $icingaInfo = Html::tag('div', ['class' => 'icinga-info'], [
+ new VerticalKeyValue(
+ t('Icinga 2 Version'),
+ $this->data->icinga2_version
+ ),
+ new VerticalKeyValue(
+ t('Icinga 2 Start Time'),
+ new TimeAgo($this->data->icinga2_start_time->getTimestamp())
+ ),
+ new VerticalKeyValue(
+ t('Last Heartbeat'),
+ new TimeAgo($this->data->heartbeat->getTimestamp())
+ ),
+ new VerticalKeyValue(
+ t('Active Icinga 2 Endpoint'),
+ $this->data->endpoint->name ?: t('N/A')
+ ),
+ new VerticalKeyValue(
+ t('Active Icinga Web Endpoint'),
+ gethostname() ?: t('N/A')
+ )
+ ]);
+ $this->add($icingaInfo);
+ }
+}
diff --git a/library/Icingadb/Widget/HostStateBadges.php b/library/Icingadb/Widget/HostStateBadges.php
new file mode 100644
index 0000000..8141e82
--- /dev/null
+++ b/library/Icingadb/Widget/HostStateBadges.php
@@ -0,0 +1,45 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\StateBadges;
+use ipl\Web\Url;
+
+class HostStateBadges extends StateBadges
+{
+ protected function getBaseUrl(): Url
+ {
+ return Links::hosts();
+ }
+
+ protected function getType(): string
+ {
+ return 'host';
+ }
+
+ protected function getPrefix(): string
+ {
+ return 'hosts';
+ }
+
+ protected function getStateInt(string $state): int
+ {
+ return HostStates::int($state);
+ }
+
+ protected function assemble()
+ {
+ $this->addAttributes(['class' => 'host-state-badges']);
+
+ $this->add(array_filter([
+ $this->createGroup('down'),
+ $this->createBadge('unknown'),
+ $this->createBadge('up'),
+ $this->createBadge('pending')
+ ]));
+ }
+}
diff --git a/library/Icingadb/Widget/HostStatusBar.php b/library/Icingadb/Widget/HostStatusBar.php
new file mode 100644
index 0000000..f900f76
--- /dev/null
+++ b/library/Icingadb/Widget/HostStatusBar.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Common\BaseStatusBar;
+use ipl\Html\BaseHtmlElement;
+
+class HostStatusBar extends BaseStatusBar
+{
+ protected function assembleTotal(BaseHtmlElement $total): void
+ {
+ $total->add(sprintf(tp('%d Host', '%d Hosts', $this->summary->hosts_total), $this->summary->hosts_total));
+ }
+
+ protected function createStateBadges(): BaseHtmlElement
+ {
+ return (new HostStateBadges($this->summary))->setBaseFilter($this->getBaseFilter());
+ }
+}
diff --git a/library/Icingadb/Widget/HostSummaryDonut.php b/library/Icingadb/Widget/HostSummaryDonut.php
new file mode 100644
index 0000000..db5fef8
--- /dev/null
+++ b/library/Icingadb/Widget/HostSummaryDonut.php
@@ -0,0 +1,78 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\BaseFilter;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\Card;
+use ipl\Web\Filter\QueryString;
+
+class HostSummaryDonut extends Card
+{
+ use BaseFilter;
+
+ protected $defaultAttributes = ['class' => 'donut-container', 'data-base-target' => '_next'];
+
+ /** @var HoststateSummary */
+ protected $summary;
+
+ public function __construct(HoststateSummary $summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function assembleBody(BaseHtmlElement $body)
+ {
+ $labelBigUrlFilter = Filter::all(
+ Filter::equal('host.state.soft_state', 1),
+ Filter::equal('host.state.is_handled', 'n')
+ );
+ if ($this->hasBaseFilter()) {
+ $labelBigUrlFilter->add($this->getBaseFilter());
+ }
+
+ $donut = (new Donut())
+ ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending'])
+ ->setLabelBig($this->summary->hosts_down_unhandled)
+ ->setLabelBigUrl(Links::hosts()->setFilter($labelBigUrlFilter)->addParams([
+ 'sort' => 'host.state.last_state_change'
+ ]))
+ ->setLabelBigEyeCatching($this->summary->hosts_down_unhandled > 0)
+ ->setLabelSmall(t('Down'));
+
+ $body->addHtml(
+ new HtmlElement('div', Attributes::create(['class' => 'donut']), new HtmlString($donut->render()))
+ );
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer)
+ {
+ $footer->addHtml((new HostStateBadges($this->summary))->setBaseFilter($this->getBaseFilter()));
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $header->addHtml(
+ new HtmlElement('h2', null, Text::create(t('Hosts'))),
+ new HtmlElement('span', Attributes::create(['class' => 'meta']), TemplateString::create(
+ t('{{#total}}Total{{/total}} %d'),
+ ['total' => new HtmlElement('span')],
+ (int) $this->summary->hosts_total
+ ))
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/IconImage.php b/library/Icingadb/Widget/IconImage.php
new file mode 100644
index 0000000..fcf25c8
--- /dev/null
+++ b/library/Icingadb/Widget/IconImage.php
@@ -0,0 +1,74 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+
+class IconImage extends BaseHtmlElement
+{
+ /** @var string */
+ protected $source;
+
+ /** @var ?string */
+ protected $alt;
+
+ protected $tag = 'img';
+
+ /**
+ * Create a new icon image
+ *
+ * @param string $source
+ * @param ?string $alt The alternative text
+ */
+ public function __construct(string $source, ?string $alt)
+ {
+ $this->source = $source;
+ $this->alt = $alt;
+ }
+
+ public function renderUnwrapped()
+ {
+ if (! $this->getAttributes()->has('src')) {
+ // If it's an icon we don't need the <img> tag
+ return '';
+ }
+
+ return parent::renderUnwrapped();
+ }
+
+ protected function assemble()
+ {
+ $src = $this->source;
+
+ if (strpos($src, '.') === false) {
+ $this->setWrapper((new HtmlDocument())->addHtml(new Icon($src)));
+ return;
+ }
+
+ if (strpos($src, '/') === false) {
+ $src = 'img/icons/' . $src;
+ }
+
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $srcUrl = Url::fromPath($src);
+ $srcPath = $srcUrl->getRelativeUrl();
+ if (! $srcUrl->isExternal() && file_exists($srcPath) && is_file($srcPath)) {
+ $mimeType = @mime_content_type($srcPath);
+ $content = @file_get_contents($srcPath);
+ if ($mimeType !== false && $content !== false) {
+ $src = "data:$mimeType;base64," . base64_encode($content);
+ }
+ }
+ }
+
+ $this->addAttributes([
+ 'src' => $src,
+ 'alt' => $this->alt
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseCommentListItem.php b/library/Icingadb/Widget/ItemList/BaseCommentListItem.php
new file mode 100644
index 0000000..de11c0c
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseCommentListItem.php
@@ -0,0 +1,131 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use ipl\Html\Html;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\MarkdownLine;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Model\Comment;
+use ipl\Html\FormattedString;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\TimeUntil;
+
+/**
+ * Comment item of a comment list. Represents one database row.
+ *
+ * @property Comment $item
+ * @property CommentList $list
+ */
+abstract class BaseCommentListItem extends BaseListItem
+{
+ use HostLink;
+ use ServiceLink;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use TicketLinks;
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->text));
+
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->addFrom($markdownLine);
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $isAck = $this->item->entry_type === 'ack';
+ $expires = $this->item->expire_time;
+
+ $subjectText = sprintf(
+ $isAck ? t('%s acknowledged', '<username>..') : t('%s commented', '<username>..'),
+ $this->item->author
+ );
+
+ $headerParts = [
+ new Icon(Icons::USER),
+ $this->getNoSubjectLink()
+ ? new HtmlElement('span', Attributes::create(['class' => 'subject']), Text::create($subjectText))
+ : new Link($subjectText, Links::comment($this->item), ['class' => 'subject'])
+ ];
+
+ if ($isAck) {
+ $label = [Text::create('ack')];
+
+ if ($this->item->is_persistent) {
+ array_unshift($label, new Icon(Icons::IS_PERSISTENT));
+ }
+
+ $headerParts[] = Text::create(' ');
+ $headerParts[] = new HtmlElement('span', Attributes::create(['class' => 'ack-badge badge']), ...$label);
+ }
+
+ if ($expires !== null) {
+ $headerParts[] = Text::create(' ');
+ $headerParts[] = new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'ack-badge badge']),
+ Text::create(t('EXPIRES'))
+ );
+ }
+
+ if ($this->getObjectLinkDisabled()) {
+ // pass
+ } elseif ($this->item->object_type === 'host') {
+ $headerParts[] = $this->createHostLink($this->item->host, true);
+ } else {
+ $headerParts[] = $this->createServiceLink($this->item->service, $this->item->service->host, true);
+ }
+
+ $title->addHtml(...$headerParts);
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $visual->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'user-ball']),
+ Text::create($this->item->author[0])
+ ));
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ if ($this->item->expire_time) {
+ return Html::tag(
+ 'span',
+ FormattedString::create(t("expires %s"), new TimeUntil($this->item->expire_time->getTimestamp()))
+ );
+ }
+
+ return Html::tag(
+ 'span',
+ FormattedString::create(t("created %s"), new TimeAgo($this->item->entry_time->getTimestamp()))
+ );
+ }
+
+ protected function init(): void
+ {
+ $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ $this->list->addMultiselectFilterAttribute($this, Filter::equal('name', $this->item->name));
+ $this->setObjectLinkDisabled($this->list->getObjectLinkDisabled());
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php b/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
new file mode 100644
index 0000000..7ebc1f6
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseDowntimeListItem.php
@@ -0,0 +1,212 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Widget\MarkdownLine;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+/**
+ * Downtime item of a downtime list. Represents one database row.
+ *
+ * @property Downtime $item
+ * @property DowntimeList $list
+ */
+abstract class BaseDowntimeListItem extends BaseListItem
+{
+ use HostLink;
+ use ServiceLink;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use TicketLinks;
+
+ /** @var int Current Time */
+ protected $currentTime;
+
+ /** @var int Duration */
+ protected $duration;
+
+ /** @var int Downtime end time */
+ protected $endTime;
+
+ /** @var bool Whether the downtime is active */
+ protected $isActive;
+
+ /** @var int Downtime start time */
+ protected $startTime;
+
+ protected function init(): void
+ {
+ if ($this->item->is_flexible && $this->item->is_in_effect) {
+ $this->startTime = $this->item->start_time->getTimestamp();
+ $this->endTime = $this->item->end_time->getTimestamp();
+ } else {
+ $this->startTime = $this->item->scheduled_start_time->getTimestamp();
+ $this->endTime = $this->item->scheduled_end_time->getTimestamp();
+ }
+
+ $this->currentTime = time();
+
+ $this->isActive = $this->item->is_in_effect
+ || $this->item->is_flexible && $this->item->scheduled_start_time->getTimestamp() <= $this->currentTime;
+
+ $until = ($this->isActive ? $this->endTime : $this->startTime) - $this->currentTime;
+ $this->duration = explode(' ', DateFormatter::formatDuration(
+ $until <= 3600 ? $until : $until + (3600 - ((int) $until % 3600))
+ ), 2)[0];
+
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ $this->list->addMultiselectFilterAttribute($this, Filter::equal('name', $this->item->name));
+ $this->setObjectLinkDisabled($this->list->getObjectLinkDisabled());
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled());
+
+ if ($this->item->is_in_effect) {
+ $this->getAttributes()->add('class', 'in-effect');
+ }
+ }
+
+ protected function createProgress(): BaseHtmlElement
+ {
+ return new HtmlElement(
+ 'div',
+ Attributes::create([
+ 'class' => 'progress',
+ 'data-animate-progress' => true,
+ 'data-start-time' => $this->startTime,
+ 'data-end-time' => $this->endTime
+ ]),
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'bar'])
+ )
+ );
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->comment));
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->addHtml(
+ new HtmlElement(
+ 'span',
+ null,
+ new Icon(Icons::USER),
+ Text::create($this->item->author)
+ ),
+ Text::create(': ')
+ )->addFrom($markdownLine);
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ if ($this->getObjectLinkDisabled()) {
+ $link = null;
+ } elseif ($this->item->object_type === 'host') {
+ $link = $this->createHostLink($this->item->host, true);
+ } else {
+ $link = $this->createServiceLink($this->item->service, $this->item->service->host, true);
+ }
+
+ if ($this->item->is_flexible) {
+ if ($link !== null) {
+ $template = t('{{#link}}Flexible Downtime{{/link}} for %s');
+ } else {
+ $template = t('Flexible Downtime');
+ }
+ } else {
+ if ($link !== null) {
+ $template = t('{{#link}}Fixed Downtime{{/link}} for %s');
+ } else {
+ $template = t('Fixed Downtime');
+ }
+ }
+
+ if ($this->getNoSubjectLink()) {
+ if ($link === null) {
+ $title->addHtml(HtmlElement::create('span', [ 'class' => 'subject'], $template));
+ } else {
+ $title->addHtml(TemplateString::create(
+ $template,
+ ['link' => HtmlElement::create('span', [ 'class' => 'subject'])],
+ $link
+ ));
+ }
+ } else {
+ if ($link === null) {
+ $title->addHtml(new Link($template, Links::downtime($this->item)));
+ } else {
+ $title->addHtml(TemplateString::create(
+ $template,
+ ['link' => new Link('', Links::downtime($this->item))],
+ $link
+ ));
+ }
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $dateTime = DateFormatter::formatDateTime($this->endTime);
+
+ if ($this->isActive) {
+ $visual->addHtml(Html::sprintf(
+ t('%s left', '<timespan>..'),
+ Html::tag(
+ 'strong',
+ Html::tag(
+ 'time',
+ [
+ 'datetime' => $dateTime,
+ 'title' => $dateTime
+ ],
+ $this->duration
+ )
+ )
+ ));
+ } else {
+ $visual->addHtml(Html::sprintf(
+ t('in %s', '..<timespan>'),
+ Html::tag('strong', $this->duration)
+ ));
+ }
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ $dateTime = DateFormatter::formatDateTime($this->isActive ? $this->endTime : $this->startTime);
+
+ return Html::tag(
+ 'time',
+ [
+ 'datetime' => $dateTime,
+ 'title' => $dateTime
+ ],
+ sprintf(
+ $this->isActive
+ ? t('expires in %s', '..<timespan>')
+ : t('starts in %s', '..<timespan>'),
+ $this->duration
+ )
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php b/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
new file mode 100644
index 0000000..6999324
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseHistoryListItem.php
@@ -0,0 +1,405 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Widget\MarkdownLine;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\CheckAttempt;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+abstract class BaseHistoryListItem extends BaseListItem
+{
+ use HostLink;
+ use NoSubjectLink;
+ use ServiceLink;
+ use TicketLinks;
+
+ /** @var History */
+ protected $item;
+
+ /** @var HistoryList */
+ protected $list;
+
+ protected function init(): void
+ {
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->setTicketLinkEnabled($this->list->getTicketLinkEnabled());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('id', bin2hex($this->item->id)));
+ }
+
+ abstract protected function getStateBallSize(): string;
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ switch ($this->item->event_type) {
+ case 'comment_add':
+ case 'comment_remove':
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->comment->comment));
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->comment->author,
+ ': '
+ ])->addFrom($markdownLine);
+
+ break;
+ case 'downtime_end':
+ case 'downtime_start':
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->downtime->comment));
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->downtime->author,
+ ': '
+ ])->addFrom($markdownLine);
+
+ break;
+ case 'flapping_start':
+ $caption
+ ->add(sprintf(
+ t('State Change Rate: %.2f%%; Start Threshold: %.2f%%'),
+ $this->item->flapping->percent_state_change_start,
+ $this->item->flapping->flapping_threshold_high
+ ))
+ ->getAttributes()
+ ->add('class', 'plugin-output');
+
+ break;
+ case 'flapping_end':
+ $caption
+ ->add(sprintf(
+ t('State Change Rate: %.2f%%; End Threshold: %.2f%%; Flapping for %s'),
+ $this->item->flapping->percent_state_change_end,
+ $this->item->flapping->flapping_threshold_low,
+ DateFormatter::formatDuration(
+ $this->item->flapping->end_time->getTimestamp()
+ - $this->item->flapping->start_time->getTimestamp()
+ )
+ ))
+ ->getAttributes()
+ ->add('class', 'plugin-output');
+
+ break;
+ case 'ack_clear':
+ case 'ack_set':
+ if (! isset($this->item->acknowledgement->comment) && ! isset($this->item->acknowledgement->author)) {
+ $caption->addHtml(new EmptyState(
+ t('This acknowledgement was set before Icinga DB history recording')
+ ));
+ } else {
+ $markdownLine = new MarkdownLine($this->createTicketLinks($this->item->acknowledgement->comment));
+ $caption->getAttributes()->add($markdownLine->getAttributes());
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->acknowledgement->author,
+ ': '
+ ])->addFrom($markdownLine);
+ }
+
+ break;
+ case 'notification':
+ if (! empty($this->item->notification->author)) {
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->notification->author,
+ ': ',
+ $this->item->notification->text
+ ]);
+ } else {
+ $commandName = $this->item->object_type === 'host'
+ ? $this->item->host->checkcommand_name
+ : $this->item->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($this->item->notification->text)) {
+ $caption->addHtml(new EmptyState(t('Output unavailable.')));
+ } else {
+ $caption->addHtml(new PluginOutputContainer(
+ (new PluginOutput($this->item->notification->text))
+ ->setCommandName($commandName)
+ ));
+ }
+ } else {
+ $caption->addHtml(new EmptyState(t('Waiting for Icinga DB to synchronize the config.')));
+ }
+ }
+
+ break;
+ case 'state_change':
+ $commandName = $this->item->object_type === 'host'
+ ? $this->item->host->checkcommand_name
+ : $this->item->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($this->item->state->output)) {
+ $caption->addHtml(new EmptyState(t('Output unavailable.')));
+ } else {
+ $caption->addHtml(new PluginOutputContainer(
+ (new PluginOutput($this->item->state->output))
+ ->setCommandName($commandName)
+ ));
+ }
+ } else {
+ $caption->addHtml(new EmptyState(t('Waiting for Icinga DB to synchronize the config.')));
+ }
+
+ break;
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ switch ($this->item->event_type) {
+ case 'comment_add':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::COMMENT)
+ ));
+
+ break;
+ case 'comment_remove':
+ case 'downtime_end':
+ case 'ack_clear':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::REMOVE)
+ ));
+
+ break;
+ case 'downtime_start':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IN_DOWNTIME)
+ ));
+
+ break;
+ case 'ack_set':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IS_ACKNOWLEDGED)
+ ));
+
+ break;
+ case 'flapping_end':
+ case 'flapping_start':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IS_FLAPPING)
+ ));
+
+ break;
+ case 'notification':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::NOTIFICATION)
+ ));
+
+ break;
+ case 'state_change':
+ if ($this->item->state->state_type === 'soft') {
+ $stateType = 'soft_state';
+ $previousStateType = 'previous_soft_state';
+
+ if ($this->item->state->previous_soft_state === 0) {
+ $previousStateType = 'hard_state';
+ }
+
+ $visual->addHtml(new CheckAttempt(
+ (int) $this->item->state->check_attempt,
+ (int) $this->item->state->max_check_attempts
+ ));
+ } else {
+ $stateType = 'hard_state';
+ $previousStateType = 'previous_hard_state';
+
+ if ($this->item->state->hard_state === $this->item->state->previous_hard_state) {
+ $previousStateType = 'previous_soft_state';
+ }
+ }
+
+ if ($this->item->object_type === 'host') {
+ $state = HostStates::text($this->item->state->$stateType);
+ $previousState = HostStates::text($this->item->state->$previousStateType);
+ } else {
+ $state = ServiceStates::text($this->item->state->$stateType);
+ $previousState = ServiceStates::text($this->item->state->$previousStateType);
+ }
+
+ $stateChange = new StateChange($state, $previousState);
+ if ($stateType === 'soft_state') {
+ $stateChange->setCurrentStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ }
+
+ if ($previousStateType === 'previous_soft_state') {
+ $stateChange->setPreviousStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ if ($stateType === 'soft_state') {
+ $visual->getAttributes()->add('class', 'small-state-change');
+ }
+ }
+
+ $visual->prependHtml($stateChange);
+
+ break;
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ switch ($this->item->event_type) {
+ case 'comment_add':
+ $subjectLabel = t('Comment added');
+
+ break;
+ case 'comment_remove':
+ if (! empty($this->item->comment->removed_by)) {
+ if ($this->item->comment->removed_by !== $this->item->comment->author) {
+ $subjectLabel = sprintf(
+ t('Comment removed by %s', '..<username>'),
+ $this->item->comment->removed_by
+ );
+ } else {
+ $subjectLabel = t('Comment removed by author');
+ }
+ } elseif (isset($this->item->comment->expire_time)) {
+ $subjectLabel = t('Comment expired');
+ } else {
+ $subjectLabel = t('Comment removed');
+ }
+
+ break;
+ case 'downtime_end':
+ if (! empty($this->item->downtime->cancelled_by)) {
+ if ($this->item->downtime->cancelled_by !== $this->item->downtime->author) {
+ $subjectLabel = sprintf(
+ t('Downtime cancelled by %s', '..<username>'),
+ $this->item->downtime->cancelled_by
+ );
+ } else {
+ $subjectLabel = t('Downtime cancelled by author');
+ }
+ } elseif ($this->item->downtime->has_been_cancelled === 'y') {
+ $subjectLabel = t('Downtime cancelled');
+ } else {
+ $subjectLabel = t('Downtime ended');
+ }
+
+ break;
+ case 'downtime_start':
+ $subjectLabel = t('Downtime started');
+
+ break;
+ case 'flapping_start':
+ $subjectLabel = t('Flapping started');
+
+ break;
+ case 'flapping_end':
+ $subjectLabel = t('Flapping stopped');
+
+ break;
+ case 'ack_set':
+ $subjectLabel = t('Acknowledgement set');
+
+ break;
+ case 'ack_clear':
+ if (! empty($this->item->acknowledgement->cleared_by)) {
+ if ($this->item->acknowledgement->cleared_by !== $this->item->acknowledgement->author) {
+ $subjectLabel = sprintf(
+ t('Acknowledgement cleared by %s', '..<username>'),
+ $this->item->acknowledgement->cleared_by
+ );
+ } else {
+ $subjectLabel = t('Acknowledgement cleared by author');
+ }
+ } elseif (isset($this->item->acknowledgement->expire_time)) {
+ $subjectLabel = t('Acknowledgement expired');
+ } else {
+ $subjectLabel = t('Acknowledgement cleared');
+ }
+
+ break;
+ case 'notification':
+ $subjectLabel = sprintf(
+ NotificationListItem::phraseForType($this->item->notification->type),
+ ucfirst($this->item->object_type)
+ );
+
+ break;
+ case 'state_change':
+ $state = $this->item->state->state_type === 'hard'
+ ? $this->item->state->hard_state
+ : $this->item->state->soft_state;
+ if ($state === 0) {
+ if ($this->item->object_type === 'service') {
+ $subjectLabel = t('Service recovered');
+ } else {
+ $subjectLabel = t('Host recovered');
+ }
+ } else {
+ if ($this->item->state->state_type === 'hard') {
+ $subjectLabel = t('Hard state changed');
+ } else {
+ $subjectLabel = t('Soft state changed');
+ }
+ }
+
+ break;
+ default:
+ $subjectLabel = $this->item->event_type;
+
+ break;
+ }
+
+ if ($this->getNoSubjectLink()) {
+ $title->addHtml(HtmlElement::create('span', ['class' => 'subject'], $subjectLabel));
+ } else {
+ $title->addHtml(new Link($subjectLabel, Links::event($this->item), ['class' => 'subject']));
+ }
+
+ if ($this->item->object_type === 'host') {
+ if (isset($this->item->host->id)) {
+ $link = $this->createHostLink($this->item->host, true);
+ }
+ } else {
+ if (isset($this->item->host->id, $this->item->service->id)) {
+ $link = $this->createServiceLink($this->item->service, $this->item->host, true);
+ }
+ }
+
+ $title->addHtml(Text::create(' '));
+ if (isset($link)) {
+ $title->addHtml($link);
+ }
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ return new TimeAgo($this->item->event_time->getTimestamp());
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseHostListItem.php b/library/Icingadb/Widget/ItemList/BaseHostListItem.php
new file mode 100644
index 0000000..edaf6c8
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseHostListItem.php
@@ -0,0 +1,56 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Link;
+
+/**
+ * Host item of a host list. Represents one database row.
+ *
+ * @property Host $item
+ * @property HostList $list
+ */
+abstract class BaseHostListItem extends StateListItem
+{
+ use NoSubjectLink;
+
+ /**
+ * Create new subject link
+ *
+ * @return BaseHtmlElement
+ */
+ protected function createSubject()
+ {
+ if ($this->getNoSubjectLink()) {
+ return new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ );
+ } else {
+ return new Link($this->item->display_name, Links::host($this->item), ['class' => 'subject']);
+ }
+ }
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->getNoSubjectLink()) {
+ $this->setNoSubjectLink();
+ }
+
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name))
+ ->addMultiselectFilterAttribute($this, Filter::equal('host.name', $this->item->name));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseNotificationListItem.php b/library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
new file mode 100644
index 0000000..b538ac4
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseNotificationListItem.php
@@ -0,0 +1,189 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\TimeAgo;
+use InvalidArgumentException;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+abstract class BaseNotificationListItem extends BaseListItem
+{
+ use HostLink;
+ use NoSubjectLink;
+ use ServiceLink;
+
+ /** @var NotificationList */
+ protected $list;
+
+ protected function init(): void
+ {
+ $this->setNoSubjectLink($this->list->getNoSubjectLink());
+ $this->list->addDetailFilterAttribute($this, Filter::equal('id', bin2hex($this->item->history->id)));
+ }
+
+ /**
+ * Get a localized phrase for the given notification type
+ *
+ * @param string $type
+ *
+ * @return string
+ */
+ public static function phraseForType(string $type): string
+ {
+ switch ($type) {
+ case 'acknowledgement':
+ return t('Problem acknowledged');
+ case 'custom':
+ return t('Custom Notification triggered');
+ case 'downtime_end':
+ return t('Downtime ended');
+ case 'downtime_removed':
+ return t('Downtime removed');
+ case 'downtime_start':
+ return t('Downtime started');
+ case 'flapping_end':
+ return t('Flapping stopped');
+ case 'flapping_start':
+ return t('Flapping started');
+ case 'problem':
+ return t('%s ran into a problem');
+ case 'recovery':
+ return t('%s recovered');
+ default:
+ throw new InvalidArgumentException(sprintf('Type %s is not a valid notification type', $type));
+ }
+ }
+
+ abstract protected function getStateBallSize();
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ if (in_array($this->item->type, ['flapping_end', 'flapping_start', 'problem', 'recovery'])) {
+ $commandName = $this->item->object_type === 'host'
+ ? $this->item->host->checkcommand_name
+ : $this->item->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($this->item->text)) {
+ $caption->addHtml(new EmptyState(t('Output unavailable.')));
+ } else {
+ $caption->addHtml(new PluginOutputContainer(
+ (new PluginOutput($this->item->text))
+ ->setCommandName($commandName)
+ ));
+ }
+ } else {
+ $caption->addHtml(new EmptyState(t('Waiting for Icinga DB to synchronize the config.')));
+ }
+ } else {
+ $caption->add([
+ new Icon(Icons::USER),
+ $this->item->author,
+ ': ',
+ $this->item->text
+ ]);
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ switch ($this->item->type) {
+ case 'acknowledgement':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IS_ACKNOWLEDGED)
+ ));
+
+ break;
+ case 'custom':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::NOTIFICATION)
+ ));
+
+ break;
+ case 'downtime_end':
+ case 'downtime_removed':
+ case 'downtime_start':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IN_DOWNTIME)
+ ));
+
+ break;
+ case 'flapping_end':
+ case 'flapping_start':
+ $visual->addHtml(HtmlElement::create(
+ 'div',
+ ['class' => ['icon-ball', 'ball-size-' . $this->getStateBallSize()]],
+ new Icon(Icons::IS_FLAPPING)
+ ));
+
+ break;
+ case 'problem':
+ case 'recovery':
+ if ($this->item->object_type === 'host') {
+ $state = HostStates::text($this->item->state);
+ $previousHardState = HostStates::text($this->item->previous_hard_state);
+ } else {
+ $state = ServiceStates::text($this->item->state);
+ $previousHardState = ServiceStates::text($this->item->previous_hard_state);
+ }
+
+ $visual->addHtml(new StateChange($state, $previousHardState));
+
+ break;
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ if ($this->getNoSubjectLink()) {
+ $title->addHtml(HtmlElement::create(
+ 'span',
+ ['class' => 'subject'],
+ sprintf(self::phraseForType($this->item->type), ucfirst($this->item->object_type))
+ ));
+ } else {
+ $title->addHtml(new Link(
+ sprintf(self::phraseForType($this->item->type), ucfirst($this->item->object_type)),
+ Links::event($this->item->history),
+ ['class' => 'subject']
+ ));
+ }
+
+ if ($this->item->object_type === 'host') {
+ $link = $this->createHostLink($this->item->host, true);
+ } else {
+ $link = $this->createServiceLink($this->item->service, $this->item->host, true);
+ }
+
+ $title->addHtml(Text::create(' '), $link);
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ return new TimeAgo($this->item->send_time->getTimestamp());
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/BaseServiceListItem.php b/library/Icingadb/Widget/ItemList/BaseServiceListItem.php
new file mode 100644
index 0000000..fe4f014
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/BaseServiceListItem.php
@@ -0,0 +1,70 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\Attributes;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBall;
+
+/**
+ * Service item of a service list. Represents one database row.
+ *
+ * @property Service $item
+ * @property ServiceList $list
+ */
+abstract class BaseServiceListItem extends StateListItem
+{
+ use NoSubjectLink;
+
+ protected function createSubject()
+ {
+ $service = $this->item->display_name;
+ $host = [
+ new StateBall($this->item->host->state->getStateText(), StateBall::SIZE_MEDIUM),
+ ' ',
+ $this->item->host->display_name
+ ];
+
+ $host = new Link($host, Links::host($this->item->host), ['class' => 'subject']);
+ if ($this->getNoSubjectLink()) {
+ $service = new HtmlElement('span', Attributes::create(['class' => 'subject']), Text::create($service));
+ } else {
+ $service = new Link($service, Links::service($this->item, $this->item->host), ['class' => 'subject']);
+ }
+
+ return [Html::sprintf(t('%s on %s', '<service> on <host>'), $service, $host)];
+ }
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->getNoSubjectLink()) {
+ $this->setNoSubjectLink();
+ }
+
+ $this->list->addMultiselectFilterAttribute(
+ $this,
+ Filter::all(
+ Filter::equal('service.name', $this->item->name),
+ Filter::equal('host.name', $this->item->host->name)
+ )
+ );
+ $this->list->addDetailFilterAttribute(
+ $this,
+ Filter::all(
+ Filter::equal('name', $this->item->name),
+ Filter::equal('host.name', $this->item->host->name)
+ )
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/CommandTransportList.php b/library/Icingadb/Widget/ItemList/CommandTransportList.php
new file mode 100644
index 0000000..61d771d
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommandTransportList.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use ipl\Web\Common\BaseOrderedItemList;
+use ipl\Web\Url;
+
+class CommandTransportList extends BaseOrderedItemList
+{
+ use DetailActions;
+
+ protected function init(): void
+ {
+ $this->getAttributes()->add('class', 'command-transport-list');
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/command-transport/show'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return CommandTransportListItem::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/CommandTransportListItem.php b/library/Icingadb/Widget/ItemList/CommandTransportListItem.php
new file mode 100644
index 0000000..9873403
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommandTransportListItem.php
@@ -0,0 +1,71 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseOrderedListItem;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+
+class CommandTransportListItem extends BaseOrderedListItem
+{
+ protected function init(): void
+ {
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header): void
+ {
+ }
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ $main->addHtml(new Link(
+ new HtmlElement('strong', null, Text::create($this->item->name)),
+ Url::fromPath('icingadb/command-transport/show', ['name' => $this->item->name])
+ ));
+
+ $main->addHtml(new Link(
+ new Icon('trash', ['title' => sprintf(t('Remove command transport "%s"'), $this->item->name)]),
+ Url::fromPath('icingadb/command-transport/remove', ['name' => $this->item->name]),
+ [
+ 'class' => 'pull-right action-link',
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ]
+ ));
+
+ if ($this->getOrder() + 1 < $this->list->count()) {
+ $main->addHtml((new Link(
+ new Icon('arrow-down'),
+ Url::fromPath('icingadb/command-transport/sort', [
+ 'name' => $this->item->name,
+ 'pos' => $this->getOrder() + 1
+ ]),
+ ['class' => 'pull-right action-link']
+ ))->setBaseTarget('_self'));
+ }
+
+ if ($this->getOrder() > 0) {
+ $main->addHtml((new Link(
+ new Icon('arrow-up'),
+ Url::fromPath('icingadb/command-transport/sort', [
+ 'name' => $this->item->name,
+ 'pos' => $this->getOrder() - 1
+ ]),
+ ['class' => 'pull-right action-link']
+ ))->setBaseTarget('_self'));
+ }
+ }
+
+ protected function createVisual(): ?BaseHtmlElement
+ {
+ return null;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/CommentList.php b/library/Icingadb/Widget/ItemList/CommentList.php
new file mode 100644
index 0000000..5cf65ae
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommentList.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\CaptionDisabled;
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Url;
+
+class CommentList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use ViewMode;
+ use TicketLinks;
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'comment-list'];
+
+ protected function getItemClass(): string
+ {
+ $viewMode = $this->getViewMode();
+
+ $this->addAttributes(['class' => $viewMode]);
+
+ if ($viewMode === 'minimal') {
+ return CommentListItemMinimal::class;
+ } elseif ($viewMode === 'detailed') {
+ $this->removeAttribute('class', 'default-layout');
+ }
+
+ return CommentListItem::class;
+ }
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::commentsDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/comment'));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/CommentListItem.php b/library/Icingadb/Widget/ItemList/CommentListItem.php
new file mode 100644
index 0000000..3bbd0c2
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommentListItem.php
@@ -0,0 +1,12 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+
+class CommentListItem extends BaseCommentListItem
+{
+ use ListItemCommonLayout;
+}
diff --git a/library/Icingadb/Widget/ItemList/CommentListItemMinimal.php b/library/Icingadb/Widget/ItemList/CommentListItemMinimal.php
new file mode 100644
index 0000000..3c23ccd
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/CommentListItemMinimal.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+
+class CommentListItemMinimal extends BaseCommentListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->isCaptionDisabled()) {
+ $this->setCaptionDisabled();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/DowntimeList.php b/library/Icingadb/Widget/ItemList/DowntimeList.php
new file mode 100644
index 0000000..591ad98
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/DowntimeList.php
@@ -0,0 +1,49 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\CaptionDisabled;
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ObjectLinkDisabled;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Url;
+
+class DowntimeList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ObjectLinkDisabled;
+ use ViewMode;
+ use TicketLinks;
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'downtime-list'];
+
+ protected function getItemClass(): string
+ {
+ $viewMode = $this->getViewMode();
+
+ $this->addAttributes(['class' => $viewMode]);
+
+ if ($viewMode === 'minimal') {
+ return DowntimeListItemMinimal::class;
+ } elseif ($viewMode === 'detailed') {
+ $this->removeAttribute('class', 'default-layout');
+ }
+
+ return DowntimeListItem::class;
+ }
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::downtimesDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/downtime'));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/DowntimeListItem.php b/library/Icingadb/Widget/ItemList/DowntimeListItem.php
new file mode 100644
index 0000000..cb7e9b3
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/DowntimeListItem.php
@@ -0,0 +1,23 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Html\BaseHtmlElement;
+
+class DowntimeListItem extends BaseDowntimeListItem
+{
+ use ListItemCommonLayout;
+
+ protected function assembleMain(BaseHtmlElement $main): void
+ {
+ if ($this->item->is_in_effect) {
+ $main->add($this->createProgress());
+ }
+
+ $main->add($this->createHeader());
+ $main->add($this->createCaption());
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php b/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php
new file mode 100644
index 0000000..b8581d2
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/DowntimeListItemMinimal.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+
+class DowntimeListItemMinimal extends BaseDowntimeListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->isCaptionDisabled()) {
+ $this->setCaptionDisabled();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HistoryList.php b/library/Icingadb/Widget/ItemList/HistoryList.php
new file mode 100644
index 0000000..d3b6232
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HistoryList.php
@@ -0,0 +1,57 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\CaptionDisabled;
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\LoadMore;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Orm\ResultSet;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Url;
+
+class HistoryList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ViewMode;
+ use LoadMore;
+ use TicketLinks;
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'history-list'];
+
+ protected function init(): void
+ {
+ /** @var ResultSet $data */
+ $data = $this->data;
+ $this->data = $this->getIterator($data);
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/event'));
+ }
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return HistoryListItemMinimal::class;
+ case 'detailed':
+ $this->removeAttribute('class', 'default-layout');
+
+ return HistoryListItemDetailed::class;
+ default:
+ return HistoryListItem::class;
+ }
+ }
+
+ protected function assemble(): void
+ {
+ $this->addAttributes(['class' => $this->getViewMode()]);
+
+ parent::assemble();
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HistoryListItem.php b/library/Icingadb/Widget/ItemList/HistoryListItem.php
new file mode 100644
index 0000000..c44a807
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HistoryListItem.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Web\Widget\StateBall;
+
+class HistoryListItem extends BaseHistoryListItem
+{
+ use ListItemCommonLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php b/library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php
new file mode 100644
index 0000000..7129d2d
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HistoryListItemDetailed.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemDetailedLayout;
+use ipl\Web\Widget\StateBall;
+
+class HistoryListItemDetailed extends BaseHistoryListItem
+{
+ use ListItemDetailedLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php b/library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php
new file mode 100644
index 0000000..5a7f214
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HistoryListItemMinimal.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+use ipl\Web\Widget\StateBall;
+
+class HistoryListItemMinimal extends BaseHistoryListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->isCaptionDisabled()) {
+ $this->setCaptionDisabled();
+ }
+ }
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_BIG;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostDetailHeader.php b/library/Icingadb/Widget/ItemList/HostDetailHeader.php
new file mode 100644
index 0000000..97176da
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostDetailHeader.php
@@ -0,0 +1,67 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\StateBall;
+
+class HostDetailHeader extends HostListItemMinimal
+{
+ protected function getStateBallSize(): string
+ {
+ return '';
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ if ($this->state->state_type === 'soft') {
+ $stateType = 'soft_state';
+ $previousStateType = 'previous_soft_state';
+
+ if ($this->state->previous_soft_state === 0) {
+ $previousStateType = 'hard_state';
+ }
+ } else {
+ $stateType = 'hard_state';
+ $previousStateType = 'previous_hard_state';
+
+ if ($this->state->hard_state === $this->state->previous_hard_state) {
+ $previousStateType = 'previous_soft_state';
+ }
+ }
+
+ $state = HostStates::text($this->state->$stateType);
+ $previousState = HostStates::text($this->state->$previousStateType);
+
+ $stateChange = new StateChange($state, $previousState);
+ if ($stateType === 'soft_state') {
+ $stateChange->setCurrentStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ }
+
+ if ($previousStateType === 'previous_soft_state') {
+ $stateChange->setPreviousStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ if ($stateType === 'soft_state') {
+ $visual->getAttributes()->add('class', 'small-state-change');
+ }
+ }
+
+ $stateChange->setIcon($this->state->getIcon());
+ $stateChange->setHandled($this->state->is_handled || ! $this->state->is_reachable);
+
+ $visual->addHtml($stateChange);
+ }
+
+ protected function assemble(): void
+ {
+ $attributes = $this->list->getAttributes();
+ if (! in_array('minimal', $attributes->get('class')->getValue())) {
+ $attributes->add('class', 'minimal');
+ }
+
+ parent::assemble();
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostList.php b/library/Icingadb/Widget/ItemList/HostList.php
new file mode 100644
index 0000000..2be1f84
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostList.php
@@ -0,0 +1,39 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Links;
+use ipl\Web\Url;
+
+/**
+ * Host list
+ */
+class HostList extends StateList
+{
+ protected $defaultAttributes = ['class' => 'host-list'];
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return HostListItemMinimal::class;
+ case 'detailed':
+ $this->removeAttribute('class', 'default-layout');
+
+ return HostListItemDetailed::class;
+ case 'objectHeader':
+ return HostDetailHeader::class;
+ default:
+ return HostListItem::class;
+ }
+ }
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::hostsDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/host'));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostListItem.php b/library/Icingadb/Widget/ItemList/HostListItem.php
new file mode 100644
index 0000000..2eae660
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostListItem.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Web\Widget\StateBall;
+
+class HostListItem extends BaseHostListItem
+{
+ use ListItemCommonLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostListItemDetailed.php b/library/Icingadb/Widget/ItemList/HostListItemDetailed.php
new file mode 100644
index 0000000..255bdcc
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostListItemDetailed.php
@@ -0,0 +1,108 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemDetailedLayout;
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class HostListItemDetailed extends BaseHostListItem
+{
+ use ListItemDetailedLayout;
+
+ /** @var int Max pie charts to be shown */
+ const PIE_CHART_LIMIT = 5;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer): void
+ {
+ $statusIcons = new HtmlElement('div', Attributes::create(['class' => 'status-icons']));
+
+ if ($this->item->state->last_comment->host_id === $this->item->id) {
+ $comment = $this->item->state->last_comment;
+ $comment->host = $this->item;
+ $comment = (new CommentList([$comment]))
+ ->setNoSubjectLink()
+ ->setObjectLinkDisabled()
+ ->setDetailActionsDisabled();
+
+ $statusIcons->addHtml(
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'comment-wrapper']),
+ new HtmlElement('div', Attributes::create(['class' => 'comment-popup']), $comment),
+ (new Icon('comments', ['class' => 'comment-icon']))
+ )
+ );
+ }
+
+ if ($this->item->state->is_flapping) {
+ $statusIcons->addHtml(new Icon(
+ 'random',
+ [
+ 'title' => sprintf(t('Host "%s" is in flapping state'), $this->item->display_name),
+ ]
+ ));
+ }
+
+ if (! $this->item->notifications_enabled) {
+ $statusIcons->addHtml(new Icon('bell-slash', ['title' => t('Notifications disabled')]));
+ }
+
+ if (! $this->item->active_checks_enabled) {
+ $statusIcons->addHtml(new Icon('eye-slash', ['title' => t('Active checks disabled')]));
+ }
+
+ $performanceData = new HtmlElement('div', Attributes::create(['class' => 'performance-data']));
+ if ($this->item->state->performance_data) {
+ $pieChartData = PerfDataSet::fromString($this->item->state->normalized_performance_data)->asArray();
+
+ $pies = [];
+ foreach ($pieChartData as $i => $perfdata) {
+ if ($perfdata->isVisualizable()) {
+ $pies[] = $perfdata->asInlinePie()->render();
+ }
+
+ // Check if number of visualizable pie charts is larger than PIE_CHART_LIMIT
+ if (count($pies) > HostListItemDetailed::PIE_CHART_LIMIT) {
+ break;
+ }
+ }
+
+ $maxVisiblePies = HostListItemDetailed::PIE_CHART_LIMIT - 2;
+ $numOfPies = count($pies);
+ foreach ($pies as $i => $pie) {
+ if (
+ // Show max. 5 elements: if there are more than 5, show 4 + `…`
+ $i > $maxVisiblePies && $numOfPies > HostListItemDetailed::PIE_CHART_LIMIT
+ ) {
+ $performanceData->addHtml(new HtmlElement('span', null, Text::create('…')));
+ break;
+ }
+
+ $performanceData->addHtml(HtmlString::create($pie));
+ }
+ }
+
+ if (! $statusIcons->isEmpty()) {
+ $footer->addHtml($statusIcons);
+ }
+
+ if (! $performanceData->isEmpty()) {
+ $footer->addHtml($performanceData);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/HostListItemMinimal.php b/library/Icingadb/Widget/ItemList/HostListItemMinimal.php
new file mode 100644
index 0000000..f04b991
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/HostListItemMinimal.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+use ipl\Web\Widget\StateBall;
+
+class HostListItemMinimal extends BaseHostListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_BIG;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/NotificationList.php b/library/Icingadb/Widget/ItemList/NotificationList.php
new file mode 100644
index 0000000..3a16b0b
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/NotificationList.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\CaptionDisabled;
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\LoadMore;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Orm\ResultSet;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Url;
+
+class NotificationList extends BaseItemList
+{
+ use CaptionDisabled;
+ use NoSubjectLink;
+ use ViewMode;
+ use LoadMore;
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'notification-list'];
+
+ protected function init(): void
+ {
+ /** @var ResultSet $data */
+ $data = $this->data;
+ $this->data = $this->getIterator($data);
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/event'));
+ }
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return NotificationListItemMinimal::class;
+ case 'detailed':
+ $this->removeAttribute('class', 'default-layout');
+
+ return NotificationListItemDetailed::class;
+ default:
+ return NotificationListItem::class;
+ }
+ }
+
+ protected function assemble(): void
+ {
+ $this->addAttributes(['class' => $this->getViewMode()]);
+
+ parent::assemble();
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/NotificationListItem.php b/library/Icingadb/Widget/ItemList/NotificationListItem.php
new file mode 100644
index 0000000..683762f
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/NotificationListItem.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Web\Widget\StateBall;
+
+class NotificationListItem extends BaseNotificationListItem
+{
+ use ListItemCommonLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php b/library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php
new file mode 100644
index 0000000..0a7449e
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/NotificationListItemDetailed.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemDetailedLayout;
+use ipl\Web\Widget\StateBall;
+
+class NotificationListItemDetailed extends BaseNotificationListItem
+{
+ use ListItemDetailedLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php b/library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php
new file mode 100644
index 0000000..dd6d226
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/NotificationListItemMinimal.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+use ipl\Web\Widget\StateBall;
+
+class NotificationListItemMinimal extends BaseNotificationListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function init(): void
+ {
+ parent::init();
+
+ if ($this->list->isCaptionDisabled()) {
+ $this->setCaptionDisabled();
+ }
+ }
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_BIG;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/PageSeparatorItem.php b/library/Icingadb/Widget/ItemList/PageSeparatorItem.php
new file mode 100644
index 0000000..3e252eb
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/PageSeparatorItem.php
@@ -0,0 +1,36 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class PageSeparatorItem extends BaseHtmlElement
+{
+ protected $defaultAttributes = ['class' => 'list-item page-separator'];
+
+ /** @var int */
+ protected $pageNumber;
+
+ /** @var string */
+ protected $tag = 'li';
+
+ public function __construct(int $pageNumber)
+ {
+ $this->pageNumber = $pageNumber;
+ }
+
+ protected function assemble()
+ {
+ $this->add(Html::tag(
+ 'a',
+ [
+ 'id' => 'page-' . $this->pageNumber,
+ 'data-icinga-no-scroll-on-focus' => true
+ ],
+ $this->pageNumber
+ ));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php b/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php
new file mode 100644
index 0000000..2f0dbbd
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceDetailHeader.php
@@ -0,0 +1,67 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\StateBall;
+
+class ServiceDetailHeader extends ServiceListItemMinimal
+{
+ protected function getStateBallSize(): string
+ {
+ return '';
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ if ($this->state->state_type === 'soft') {
+ $stateType = 'soft_state';
+ $previousStateType = 'previous_soft_state';
+
+ if ($this->state->previous_soft_state === 0) {
+ $previousStateType = 'hard_state';
+ }
+ } else {
+ $stateType = 'hard_state';
+ $previousStateType = 'previous_hard_state';
+
+ if ($this->state->hard_state === $this->state->previous_hard_state) {
+ $previousStateType = 'previous_soft_state';
+ }
+ }
+
+ $state = ServiceStates::text($this->state->$stateType);
+ $previousState = ServiceStates::text($this->state->$previousStateType);
+
+ $stateChange = new StateChange($state, $previousState);
+ if ($stateType === 'soft_state') {
+ $stateChange->setCurrentStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ }
+
+ if ($previousStateType === 'previous_soft_state') {
+ $stateChange->setPreviousStateBallSize(StateBall::SIZE_MEDIUM_LARGE);
+ if ($stateType === 'soft_state') {
+ $visual->getAttributes()->add('class', 'small-state-change');
+ }
+ }
+
+ $stateChange->setIcon($this->state->getIcon());
+ $stateChange->setHandled($this->state->is_handled || ! $this->state->is_reachable);
+
+ $visual->addHtml($stateChange);
+ }
+
+ protected function assemble(): void
+ {
+ $attributes = $this->list->getAttributes();
+ if (! in_array('minimal', $attributes->get('class')->getValue())) {
+ $attributes->add('class', 'minimal');
+ }
+
+ parent::assemble();
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceList.php b/library/Icingadb/Widget/ItemList/ServiceList.php
new file mode 100644
index 0000000..8d41a70
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceList.php
@@ -0,0 +1,36 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Links;
+use ipl\Web\Url;
+
+class ServiceList extends StateList
+{
+ protected $defaultAttributes = ['class' => 'service-list'];
+
+ protected function getItemClass(): string
+ {
+ switch ($this->getViewMode()) {
+ case 'minimal':
+ return ServiceListItemMinimal::class;
+ case 'detailed':
+ $this->removeAttribute('class', 'default-layout');
+
+ return ServiceListItemDetailed::class;
+ case 'objectHeader':
+ return ServiceDetailHeader::class;
+ default:
+ return ServiceListItem::class;
+ }
+ }
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::servicesDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/service'));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceListItem.php b/library/Icingadb/Widget/ItemList/ServiceListItem.php
new file mode 100644
index 0000000..a974581
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceListItem.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemCommonLayout;
+use ipl\Web\Widget\StateBall;
+
+class ServiceListItem extends BaseServiceListItem
+{
+ use ListItemCommonLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php b/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
new file mode 100644
index 0000000..1613599
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceListItemDetailed.php
@@ -0,0 +1,112 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemDetailedLayout;
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class ServiceListItemDetailed extends BaseServiceListItem
+{
+ use ListItemDetailedLayout;
+
+ /** @var int Max pie charts to be shown */
+ const PIE_CHART_LIMIT = 5;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_LARGE;
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer): void
+ {
+ $statusIcons = new HtmlElement('div', Attributes::create(['class' => 'status-icons']));
+
+ if ($this->item->state->last_comment->service_id === $this->item->id) {
+ $comment = $this->item->state->last_comment;
+ $comment->service = $this->item;
+ $comment = (new CommentList([$comment]))
+ ->setNoSubjectLink()
+ ->setObjectLinkDisabled()
+ ->setDetailActionsDisabled();
+
+ $statusIcons->addHtml(
+ new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'comment-wrapper']),
+ new HtmlElement('div', Attributes::create(['class' => 'comment-popup']), $comment),
+ (new Icon('comments', ['class' => 'comment-icon']))
+ )
+ );
+ }
+
+ if ($this->item->state->is_flapping) {
+ $statusIcons->addHtml(new Icon(
+ 'random',
+ [
+ 'title' => sprintf(
+ t('Service "%s" on "%s" is in flapping state'),
+ $this->item->display_name,
+ $this->item->host->display_name
+ ),
+ ]
+ ));
+ }
+
+ if (! $this->item->notifications_enabled) {
+ $statusIcons->addHtml(new Icon('bell-slash', ['title' => t('Notifications disabled')]));
+ }
+
+ if (! $this->item->active_checks_enabled) {
+ $statusIcons->addHtml(new Icon('eye-slash', ['title' => t('Active checks disabled')]));
+ }
+
+ $performanceData = new HtmlElement('div', Attributes::create(['class' => 'performance-data']));
+ if ($this->item->state->performance_data) {
+ $pieChartData = PerfDataSet::fromString($this->item->state->normalized_performance_data)->asArray();
+
+ $pies = [];
+ foreach ($pieChartData as $i => $perfdata) {
+ if ($perfdata->isVisualizable()) {
+ $pies[] = $perfdata->asInlinePie()->render();
+ }
+
+ // Check if number of visualizable pie charts is larger than PIE_CHART_LIMIT
+ if (count($pies) > ServiceListItemDetailed::PIE_CHART_LIMIT) {
+ break;
+ }
+ }
+
+ $maxVisiblePies = ServiceListItemDetailed::PIE_CHART_LIMIT - 2;
+ $numOfPies = count($pies);
+ foreach ($pies as $i => $pie) {
+ if (
+ // Show max. 5 elements: if there are more than 5, show 4 + `…`
+ $i > $maxVisiblePies && $numOfPies > ServiceListItemDetailed::PIE_CHART_LIMIT
+ ) {
+ $performanceData->addHtml(new HtmlElement('span', null, Text::create('…')));
+ break;
+ }
+
+ $performanceData->addHtml(HtmlString::create($pie));
+ }
+ }
+
+ if (! $statusIcons->isEmpty()) {
+ $footer->addHtml($statusIcons);
+ }
+
+ if (! $performanceData->isEmpty()) {
+ $footer->addHtml($performanceData);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php b/library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php
new file mode 100644
index 0000000..e7a1bc6
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/ServiceListItemMinimal.php
@@ -0,0 +1,18 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\ListItemMinimalLayout;
+use ipl\Web\Widget\StateBall;
+
+class ServiceListItemMinimal extends BaseServiceListItem
+{
+ use ListItemMinimalLayout;
+
+ protected function getStateBallSize(): string
+ {
+ return StateBall::SIZE_BIG;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/StateList.php b/library/Icingadb/Widget/ItemList/StateList.php
new file mode 100644
index 0000000..1e6dcb9
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/StateList.php
@@ -0,0 +1,60 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\NoSubjectLink;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use Icinga\Module\Icingadb\Redis\VolatileStateResults;
+use Icinga\Module\Icingadb\Widget\Notice;
+use ipl\Html\HtmlDocument;
+use ipl\Web\Common\BaseItemList;
+
+abstract class StateList extends BaseItemList
+{
+ use ViewMode;
+ use NoSubjectLink;
+ use DetailActions;
+
+ /** @var bool Whether the list contains at least one item with an icon_image */
+ protected $hasIconImages = false;
+
+ /**
+ * Get whether the list contains at least one item with an icon_image
+ *
+ * @return bool
+ */
+ public function hasIconImages(): bool
+ {
+ return $this->hasIconImages;
+ }
+
+ /**
+ * Set whether the list contains at least one item with an icon_image
+ *
+ * @param bool $hasIconImages
+ *
+ * @return $this
+ */
+ public function setHasIconImages(bool $hasIconImages): self
+ {
+ $this->hasIconImages = $hasIconImages;
+
+ return $this;
+ }
+
+ protected function assemble(): void
+ {
+ $this->addAttributes(['class' => $this->getViewMode()]);
+
+ parent::assemble();
+
+ if ($this->data instanceof VolatileStateResults && $this->data->isRedisUnavailable()) {
+ $this->prependWrapper((new HtmlDocument())->addHtml(new Notice(
+ t('Icinga Redis is currently unavailable. The shown information might be outdated.')
+ )));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemList/StateListItem.php b/library/Icingadb/Widget/ItemList/StateListItem.php
new file mode 100644
index 0000000..d0b3363
--- /dev/null
+++ b/library/Icingadb/Widget/ItemList/StateListItem.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemList;
+
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Model\State;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\CheckAttempt;
+use Icinga\Module\Icingadb\Widget\IconImage;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use ipl\Html\HtmlElement;
+use ipl\Web\Common\BaseListItem;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\TimeSince;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\Text;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+/**
+ * Host or service item of a host or service list. Represents one database row.
+ */
+abstract class StateListItem extends BaseListItem
+{
+ /** @var StateList The list where the item is part of */
+ protected $list;
+
+ /** @var State The state of the item */
+ protected $state;
+
+ protected function init(): void
+ {
+ $this->state = $this->item->state;
+
+ if (isset($this->item->icon_image->icon_image)) {
+ $this->list->setHasIconImages(true);
+ }
+ }
+
+ abstract protected function createSubject();
+
+ abstract protected function getStateBallSize(): string;
+
+ /**
+ * @return ?BaseHtmlElement
+ */
+ protected function createIconImage(): ?BaseHtmlElement
+ {
+ if (! $this->list->hasIconImages()) {
+ return null;
+ }
+
+ $iconImage = HtmlElement::create('div', [
+ 'class' => 'icon-image',
+ ]);
+
+ $this->assembleIconImage($iconImage);
+
+ return $iconImage;
+ }
+
+ protected function assembleCaption(BaseHtmlElement $caption): void
+ {
+ if ($this->state->soft_state === null && $this->state->output === null) {
+ $caption->addHtml(Text::create(t('Waiting for Icinga DB to synchronize the state.')));
+ } else {
+ if (empty($this->state->output)) {
+ $pluginOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->item));
+ }
+
+ $caption->addHtml($pluginOutput);
+ }
+ }
+
+ protected function assembleIconImage(BaseHtmlElement $iconImage): void
+ {
+ if (isset($this->item->icon_image->icon_image)) {
+ $iconImage->addHtml(new IconImage($this->item->icon_image->icon_image, $this->item->icon_image_alt));
+ } else {
+ $iconImage->addAttributes(['class' => 'placeholder']);
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(Html::sprintf(
+ t('%s is %s', '<hostname> is <state-text>'),
+ $this->createSubject(),
+ Html::tag('span', ['class' => 'state-text'], $this->state->getStateTextTranslated())
+ ));
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $stateBall = new StateBall($this->state->getStateText(), $this->getStateBallSize());
+ $stateBall->add($this->state->getIcon());
+ if ($this->state->is_handled || ! $this->state->is_reachable) {
+ $stateBall->getAttributes()->add('class', 'handled');
+ }
+
+ $visual->addHtml($stateBall);
+ if ($this->state->state_type === 'soft') {
+ $visual->addHtml(
+ new CheckAttempt((int) $this->state->check_attempt, (int) $this->item->max_check_attempts)
+ );
+ }
+ }
+
+ protected function createTimestamp(): ?BaseHtmlElement
+ {
+ $since = null;
+ if ($this->state->is_overdue) {
+ $since = new TimeSince($this->state->next_update->getTimestamp());
+ $since->prepend(t('Overdue') . ' ');
+ $since->prependHtml(new Icon(Icons::WARNING));
+ } elseif ($this->state->last_state_change !== null && $this->state->last_state_change->getTimestamp() > 0) {
+ $since = new TimeSince($this->state->last_state_change->getTimestamp());
+ }
+
+ return $since;
+ }
+
+ protected function assemble(): void
+ {
+ if ($this->state->is_overdue) {
+ $this->addAttributes(['class' => 'overdue']);
+ }
+
+ $this->add([
+ $this->createVisual(),
+ $this->createIconImage(),
+ $this->createMain()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php b/library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php
new file mode 100644
index 0000000..c56a1f8
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/BaseHostGroupItem.php
@@ -0,0 +1,60 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Hostgroup;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTableRowItem;
+use ipl\Web\Widget\Link;
+
+/**
+ * Hostgroup item of a hostgroup list. Represents one database row.
+ *
+ * @property Hostgroup $item
+ * @property HostgroupTable $table
+ */
+abstract class BaseHostGroupItem extends BaseTableRowItem
+{
+ use Translation;
+
+ protected function init(): void
+ {
+ if (isset($this->table)) {
+ $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+ }
+
+ protected function createSubject(): BaseHtmlElement
+ {
+ return isset($this->table)
+ ? new Link(
+ $this->item->display_name,
+ Links::hostgroup($this->item),
+ [
+ 'class' => 'subject',
+ 'title' => sprintf(
+ $this->translate('List all hosts in the group "%s"'),
+ $this->item->display_name
+ )
+ ]
+ )
+ : new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ );
+ }
+
+ protected function createCaption(): BaseHtmlElement
+ {
+ return new HtmlElement('span', null, Text::create($this->item->name));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php b/library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php
new file mode 100644
index 0000000..7bee532
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/BaseServiceGroupItem.php
@@ -0,0 +1,60 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Servicegroup;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\I18n\Translation;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTableRowItem;
+use ipl\Web\Widget\Link;
+
+/**
+ * Servicegroup item of a servicegroup list. Represents one database row.
+ *
+ * @property Servicegroup $item
+ * @property ServicegroupTable $table
+ */
+abstract class BaseServiceGroupItem extends BaseTableRowItem
+{
+ use Translation;
+
+ protected function init(): void
+ {
+ if (isset($this->table)) {
+ $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+ }
+
+ protected function createSubject(): BaseHtmlElement
+ {
+ return isset($this->table)
+ ? new Link(
+ $this->item->display_name,
+ Links::servicegroup($this->item),
+ [
+ 'class' => 'subject',
+ 'title' => sprintf(
+ $this->translate('List all services in the group "%s"'),
+ $this->item->display_name
+ )
+ ]
+ )
+ : new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ );
+ }
+
+ protected function createCaption(): BaseHtmlElement
+ {
+ return new HtmlElement('span', null, Text::create($this->item->name));
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php b/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php
new file mode 100644
index 0000000..642d6b3
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/BaseStateRowItem.php
@@ -0,0 +1,107 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Orm\Model;
+
+/** @todo Figure out what this might (should) have in common with the new BaseTableRowItem implementation */
+abstract class BaseStateRowItem extends BaseHtmlElement
+{
+ protected $defaultAttributes = ['class' => 'row-item'];
+
+ /** @var Model */
+ protected $item;
+
+ /** @var StateItemTable */
+ protected $list;
+
+ protected $tag = 'tr';
+
+ /**
+ * Create a new row item
+ *
+ * @param Model $item
+ * @param StateItemTable $list
+ */
+ public function __construct(Model $item, StateItemTable $list)
+ {
+ $this->item = $item;
+ $this->list = $list;
+
+ $this->init();
+ }
+
+ /**
+ * Initialize the row item
+ *
+ * If you want to adjust the row item after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ abstract protected function assembleVisual(BaseHtmlElement $visual);
+
+ abstract protected function assembleCell(BaseHtmlElement $cell, string $path, $value);
+
+ protected function createVisual(): BaseHtmlElement
+ {
+ $visual = new HtmlElement('td', Attributes::create(['class' => 'visual']));
+
+ $this->assembleVisual($visual);
+
+ return $visual;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml($this->createVisual());
+
+ foreach ($this->list->getColumns() as $columnPath => $_) {
+ $steps = explode('.', $columnPath);
+ if ($steps[0] === $this->item->getTableName()) {
+ array_shift($steps);
+ $columnPath = implode('.', $steps);
+ }
+
+ $column = null;
+ $subject = $this->item;
+ foreach ($steps as $i => $step) {
+ if (isset($subject->$step)) {
+ if ($subject->$step instanceof Model) {
+ $subject = $subject->$step;
+ } else {
+ $column = $step;
+ }
+ } else {
+ $columnCandidate = implode('.', array_slice($steps, $i));
+ if (isset($subject->$columnCandidate)) {
+ $column = $columnCandidate;
+ } else {
+ break;
+ }
+ }
+ }
+
+ $value = null;
+ if ($column !== null) {
+ $value = $subject->$column;
+ if (is_array($value)) {
+ $value = empty($value) ? null : implode(',', $value);
+ }
+ }
+
+ $cell = new HtmlElement('td');
+ if ($value !== null) {
+ $this->assembleCell($cell, $columnPath, $value);
+ }
+
+ $this->addHtml($cell);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/GridCellLayout.php b/library/Icingadb/Widget/ItemTable/GridCellLayout.php
new file mode 100644
index 0000000..95b1a0a
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/GridCellLayout.php
@@ -0,0 +1,39 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\Link;
+
+trait GridCellLayout
+{
+ /**
+ * Creates a state badge for the Host / Service group with the highest severity that an object in the group has,
+ * along with the count of the objects with this severity belonging to the corresponding group.
+ *
+ * @return Link
+ */
+ abstract public function createGroupBadge(): Link;
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $visual->add($this->createGroupBadge());
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ $this->createSubject(),
+ $this->createCaption()
+ );
+ }
+
+ protected function assemble(): void
+ {
+ $this->add([
+ $this->createTitle()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostItemTable.php b/library/Icingadb/Widget/ItemTable/HostItemTable.php
new file mode 100644
index 0000000..e303746
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostItemTable.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\Links;
+use ipl\Web\Url;
+
+class HostItemTable extends StateItemTable
+{
+ use DetailActions;
+
+ protected function init()
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::hostsDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/host'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return HostRowItem::class;
+ }
+
+ protected function getVisualColumn(): string
+ {
+ return 'host.state.severity';
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostRowItem.php b/library/Icingadb/Widget/ItemTable/HostRowItem.php
new file mode 100644
index 0000000..cff70dd
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostRowItem.php
@@ -0,0 +1,51 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Host;
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Link;
+
+class HostRowItem extends StateRowItem
+{
+ /** @var HostItemTable */
+ protected $list;
+
+ /** @var Host */
+ protected $item;
+
+ protected function init()
+ {
+ parent::init();
+
+ $this->list->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name))
+ ->addMultiselectFilterAttribute($this, Filter::equal('host.name', $this->item->name));
+ }
+
+ protected function assembleCell(BaseHtmlElement $cell, string $path, $value)
+ {
+ switch ($path) {
+ case 'name':
+ case 'display_name':
+ $cell->addHtml(new Link($this->item->$path, Links::host($this->item), [
+ 'class' => 'subject',
+ 'title' => $this->item->$path
+ ]));
+ break;
+ case 'service.name':
+ case 'service.display_name':
+ $column = substr($path, 8);
+ $cell->addHtml(new Link(
+ $this->item->service->$column,
+ Links::service($this->item->service, $this->item)
+ ));
+ break;
+ default:
+ parent::assembleCell($cell, $path, $value);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostgroupGridCell.php b/library/Icingadb/Widget/ItemTable/HostgroupGridCell.php
new file mode 100644
index 0000000..5396747
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostgroupGridCell.php
@@ -0,0 +1,114 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+class HostgroupGridCell extends BaseHostGroupItem
+{
+ use GridCellLayout;
+
+ protected $defaultAttributes = ['class' => ['group-grid-cell', 'hostgroup-grid-cell']];
+
+ protected function createGroupBadge(): Link
+ {
+ $url = Url::fromPath('icingadb/hosts');
+ $urlFilter = Filter::all(Filter::equal('hostgroup.name', $this->item->name));
+
+ if ($this->item->hosts_down_unhandled > 0) {
+ $urlFilter->add(Filter::equal('host.state.soft_state', 1))
+ ->add(Filter::equal('host.state.is_handled', 'n'))
+ ->add(Filter::equal('host.state.is_reachable', 'y'));
+
+ return new Link(
+ new StateBadge($this->item->hosts_down_unhandled, 'down'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d host that is currently in DOWN state in host group "%s"',
+ 'List %d hosts which are currently in DOWN state in host group "%s"',
+ $this->item->hosts_down_unhandled
+ ),
+ $this->item->hosts_down_unhandled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->hosts_down_handled > 0) {
+ $urlFilter->add(Filter::equal('host.state.soft_state', 1))
+ ->add(Filter::any(
+ Filter::equal('host.state.is_handled', 'y'),
+ Filter::equal('host.state.is_reachable', 'n')
+ ));
+
+ return new Link(
+ new StateBadge($this->item->hosts_down_handled, 'down', true),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d host that is currently in DOWN (Acknowledged) state in host group "%s"',
+ 'List %d hosts which are currently in DOWN (Acknowledged) state in host group "%s"',
+ $this->item->hosts_down_handled
+ ),
+ $this->item->hosts_down_handled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->hosts_pending > 0) {
+ $urlFilter->add(Filter::equal('host.state.soft_state', 99));
+
+ return new Link(
+ new StateBadge($this->item->hosts_pending, 'pending'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d host that is currently in PENDING state in host group "%s"',
+ 'List %d hosts which are currently in PENDING state in host group "%s"',
+ $this->item->hosts_pending
+ ),
+ $this->item->hosts_pending,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->hosts_up > 0) {
+ $urlFilter->add(Filter::equal('host.state.soft_state', 0));
+
+ return new Link(
+ new StateBadge($this->item->hosts_up, 'up'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d host that is currently in UP state in host group "%s"',
+ 'List %d hosts which are currently in UP state in host group "%s"',
+ $this->item->hosts_up
+ ),
+ $this->item->hosts_up,
+ $this->item->display_name
+ )
+ ]
+ );
+ }
+
+ return new Link(
+ new StateBadge(0, 'none'),
+ $url,
+ [
+ 'title' => sprintf(
+ $this->translate('There are no hosts in host group "%s"'),
+ $this->item->display_name
+ )
+ ]
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostgroupTable.php b/library/Icingadb/Widget/ItemTable/HostgroupTable.php
new file mode 100644
index 0000000..6b40f76
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostgroupTable.php
@@ -0,0 +1,38 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Url;
+
+class HostgroupTable extends BaseItemTable
+{
+ use DetailActions;
+ use ViewMode;
+
+ protected $defaultAttributes = ['class' => 'hostgroup-table'];
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/hostgroup'));
+ }
+
+ protected function getLayout(): string
+ {
+ return $this->getViewMode() === 'grid'
+ ? 'group-grid'
+ : parent::getLayout();
+ }
+
+ protected function getItemClass(): string
+ {
+ return $this->getViewMode() === 'grid'
+ ? HostgroupGridCell::class
+ : HostgroupTableRow::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/HostgroupTableRow.php b/library/Icingadb/Widget/ItemTable/HostgroupTableRow.php
new file mode 100644
index 0000000..6aa61c2
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/HostgroupTableRow.php
@@ -0,0 +1,55 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Model\Hostgroup;
+use Icinga\Module\Icingadb\Widget\Detail\HostStatistics;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceStatistics;
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+
+/**
+ * Hostgroup table row of a hostgroup table. Represents one database row.
+ *
+ * @property Hostgroup $item
+ * @property HostgroupTable $table
+ */
+class HostgroupTableRow extends BaseHostGroupItem
+{
+ use TableRowLayout;
+
+ protected $defaultAttributes = ['class' => 'hostgroup-table-row'];
+
+ /**
+ * Create Host and service statistics columns
+ *
+ * @return BaseHtmlElement[]
+ */
+ protected function createStatistics(): array
+ {
+ $hostStats = new HostStatistics($this->item);
+
+ $hostStats->setBaseFilter(Filter::equal('hostgroup.name', $this->item->name));
+ if (isset($this->table) && $this->table->hasBaseFilter()) {
+ $hostStats->setBaseFilter(
+ Filter::all($hostStats->getBaseFilter(), $this->table->getBaseFilter())
+ );
+ }
+
+ $serviceStats = new ServiceStatistics($this->item);
+
+ $serviceStats->setBaseFilter(Filter::equal('hostgroup.name', $this->item->name));
+ if (isset($this->table) && $this->table->hasBaseFilter()) {
+ $serviceStats->setBaseFilter(
+ Filter::all($serviceStats->getBaseFilter(), $this->table->getBaseFilter())
+ );
+ }
+
+ return [
+ $this->createColumn($hostStats),
+ $this->createColumn($serviceStats)
+ ];
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServiceItemTable.php b/library/Icingadb/Widget/ItemTable/ServiceItemTable.php
new file mode 100644
index 0000000..60872d8
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServiceItemTable.php
@@ -0,0 +1,31 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\Links;
+use ipl\Web\Url;
+
+class ServiceItemTable extends StateItemTable
+{
+ use DetailActions;
+
+ protected function init()
+ {
+ $this->initializeDetailActions();
+ $this->setMultiselectUrl(Links::servicesDetails());
+ $this->setDetailUrl(Url::fromPath('icingadb/service'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return ServiceRowItem::class;
+ }
+
+ protected function getVisualColumn(): string
+ {
+ return 'service.state.severity';
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServiceRowItem.php b/library/Icingadb/Widget/ItemTable/ServiceRowItem.php
new file mode 100644
index 0000000..0fb95d0
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServiceRowItem.php
@@ -0,0 +1,64 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Link;
+
+class ServiceRowItem extends StateRowItem
+{
+ /** @var ServiceItemTable */
+ protected $list;
+
+ /** @var Service */
+ protected $item;
+
+ protected function init()
+ {
+ parent::init();
+
+ $this->list->addMultiselectFilterAttribute(
+ $this,
+ Filter::all(
+ Filter::equal('service.name', $this->item->name),
+ Filter::equal('host.name', $this->item->host->name)
+ )
+ );
+ $this->list->addDetailFilterAttribute(
+ $this,
+ Filter::all(
+ Filter::equal('name', $this->item->name),
+ Filter::equal('host.name', $this->item->host->name)
+ )
+ );
+ }
+
+ protected function assembleCell(BaseHtmlElement $cell, string $path, $value)
+ {
+ switch ($path) {
+ case 'name':
+ case 'display_name':
+ $cell->addHtml(new Link(
+ $this->item->$path,
+ Links::service($this->item, $this->item->host),
+ [
+ 'class' => 'subject',
+ 'title' => $this->item->$path
+ ]
+ ));
+ break;
+ case 'host.name':
+ case 'host.display_name':
+ $column = substr($path, 5);
+ $cell->addHtml(new Link($this->item->host->$column, Links::host($this->item->host)));
+ break;
+ default:
+ parent::assembleCell($cell, $path, $value);
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php b/library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php
new file mode 100644
index 0000000..16e50e1
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServicegroupGridCell.php
@@ -0,0 +1,204 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Stdlib\Filter;
+use ipl\Web\Url;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+class ServicegroupGridCell extends BaseServiceGroupItem
+{
+ use GridCellLayout;
+
+ protected $defaultAttributes = ['class' => ['group-grid-cell', 'servicegroup-grid-cell']];
+
+ protected function createGroupBadge(): Link
+ {
+ $url = Url::fromPath('icingadb/services/grid');
+ $urlFilter = Filter::all(Filter::equal('servicegroup.name', $this->item->name));
+
+ if ($this->item->services_critical_unhandled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 2))
+ ->add(Filter::equal('service.state.is_handled', 'n'))
+ ->add(Filter::equal('service.state.is_reachable', 'y'));
+
+ return new Link(
+ new StateBadge($this->item->services_critical_unhandled, 'critical'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in CRITICAL state in service group "%s"',
+ 'List %d services which are currently in CRITICAL state in service group "%s"',
+ $this->item->services_critical_unhandled
+ ),
+ $this->item->services_critical_unhandled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_critical_handled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 2))
+ ->add(Filter::any(
+ Filter::equal('service.state.is_handled', 'y'),
+ Filter::equal('service.state.is_reachable', 'n')
+ ));
+
+ return new Link(
+ new StateBadge($this->item->services_critical_handled, 'critical', true),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in CRITICAL (Acknowledged) state in service group'
+ . ' "%s"',
+ 'List %d services which are currently in CRITICAL (Acknowledged) state in service group'
+ . ' "%s"',
+ $this->item->services_critical_handled
+ ),
+ $this->item->services_critical_handled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_warning_unhandled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 1))
+ ->add(Filter::equal('service.state.is_handled', 'n'))
+ ->add(Filter::equal('service.state.is_reachable', 'y'));
+
+ return new Link(
+ new StateBadge($this->item->services_warning_unhandled, 'warning'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in WARNING state in service group "%s"',
+ 'List %d services which are currently in WARNING state in service group "%s"',
+ $this->item->services_warning_unhandled
+ ),
+ $this->item->services_warning_unhandled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_warning_handled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 1))
+ ->add(Filter::any(
+ Filter::equal('service.state.is_handled', 'y'),
+ Filter::equal('service.state.is_reachable', 'n')
+ ));
+
+ return new Link(
+ new StateBadge($this->item->services_warning_handled, 'warning', true),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in WARNING (Acknowledged) state in service group'
+ . ' "%s"',
+ 'List %d services which are currently in WARNING (Acknowledged) state in service group'
+ . ' "%s"',
+ $this->item->services_warning_handled
+ ),
+ $this->item->services_warning_handled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_unknown_unhandled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 3))
+ ->add(Filter::equal('service.state.is_handled', 'n'))
+ ->add(Filter::equal('service.state.is_reachable', 'y'));
+
+ return new Link(
+ new StateBadge($this->item->services_unknown_unhandled, 'unknown'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in UNKNOWN state in service group "%s"',
+ 'List %d services which are currently in UNKNOWN state in service group "%s"',
+ $this->item->services_unknown_unhandled
+ ),
+ $this->item->services_unknown_unhandled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_unknown_handled > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 3))
+ ->add(Filter::any(
+ Filter::equal('service.state.is_handled', 'y'),
+ Filter::equal('service.state.is_reachable', 'n')
+ ));
+
+ return new Link(
+ new StateBadge($this->item->services_unknown_handled, 'unknown', true),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in UNKNOWN (Acknowledged) state in service group'
+ . ' "%s"',
+ 'List %d services which are currently in UNKNOWN (Acknowledged) state in service group'
+ . ' "%s"',
+ $this->item->services_unknown_handled
+ ),
+ $this->item->services_unknown_handled,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_pending > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 99));
+
+ return new Link(
+ new StateBadge($this->item->services_pending, 'pending'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in PENDING state in service group "%s"',
+ 'List %d services which are currently in PENDING state in service group "%s"',
+ $this->item->services_pending
+ ),
+ $this->item->services_pending,
+ $this->item->display_name
+ )
+ ]
+ );
+ } elseif ($this->item->services_ok > 0) {
+ $urlFilter->add(Filter::equal('service.state.soft_state', 0));
+
+ return new Link(
+ new StateBadge($this->item->services_ok, 'ok'),
+ $url->setFilter($urlFilter),
+ [
+ 'title' => sprintf(
+ $this->translatePlural(
+ 'List %d service that is currently in OK state in service group "%s"',
+ 'List %d services which are currently in OK state in service group "%s"',
+ $this->item->services_ok
+ ),
+ $this->item->services_ok,
+ $this->item->display_name
+ )
+ ]
+ );
+ }
+
+ return new Link(
+ new StateBadge(0, 'none'),
+ $url,
+ [
+ 'title' => sprintf(
+ $this->translate('There are no services in service group "%s"'),
+ $this->item->display_name
+ )
+ ]
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServicegroupTable.php b/library/Icingadb/Widget/ItemTable/ServicegroupTable.php
new file mode 100644
index 0000000..2378a77
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServicegroupTable.php
@@ -0,0 +1,38 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use Icinga\Module\Icingadb\Common\ViewMode;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Url;
+
+class ServicegroupTable extends BaseItemTable
+{
+ use DetailActions;
+ use ViewMode;
+
+ protected $defaultAttributes = ['class' => 'servicegroup-table'];
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/servicegroup'));
+ }
+
+ protected function getLayout(): string
+ {
+ return $this->getViewMode() === 'grid'
+ ? 'group-grid'
+ : parent::getLayout();
+ }
+
+ protected function getItemClass(): string
+ {
+ return $this->getViewMode() === 'grid'
+ ? ServicegroupGridCell::class
+ : ServicegroupTableRow::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php b/library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php
new file mode 100644
index 0000000..3dea4c1
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/ServicegroupTableRow.php
@@ -0,0 +1,42 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Model\Servicegroup;
+use Icinga\Module\Icingadb\Widget\Detail\ServiceStatistics;
+use ipl\Html\BaseHtmlElement;
+use ipl\Stdlib\Filter;
+
+/**
+ * Servicegroup item of a servicegroup list. Represents one database row.
+ *
+ * @property Servicegroup $item
+ * @property ServicegroupTable $table
+ */
+class ServicegroupTableRow extends BaseServiceGroupItem
+{
+ use TableRowLayout;
+
+ protected $defaultAttributes = ['class' => 'servicegroup-table-row'];
+
+ /**
+ * Create Service statistics cell
+ *
+ * @return BaseHtmlElement[]
+ */
+ protected function createStatistics(): array
+ {
+ $serviceStats = new ServiceStatistics($this->item);
+
+ $serviceStats->setBaseFilter(Filter::equal('servicegroup.name', $this->item->name));
+ if (isset($this->table) && $this->table->hasBaseFilter()) {
+ $serviceStats->setBaseFilter(
+ Filter::all($serviceStats->getBaseFilter(), $this->table->getBaseFilter())
+ );
+ }
+
+ return [$this->createColumn($serviceStats)];
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/StateItemTable.php b/library/Icingadb/Widget/ItemTable/StateItemTable.php
new file mode 100644
index 0000000..f392322
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/StateItemTable.php
@@ -0,0 +1,216 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Form;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Orm\Common\SortUtil;
+use ipl\Orm\Query;
+use ipl\Web\Control\SortControl;
+use ipl\Web\Widget\EmptyStateBar;
+use ipl\Web\Widget\Icon;
+
+/** @todo Figure out what this might (should) have in common with the new BaseItemTable implementation */
+abstract class StateItemTable extends BaseHtmlElement
+{
+ protected $baseAttributes = [
+ 'class' => 'state-item-table'
+ ];
+
+ /** @var array<string, string> The columns to render */
+ protected $columns;
+
+ /** @var iterable The datasource */
+ protected $data;
+
+ /** @var string The sort rules */
+ protected $sort;
+
+ protected $tag = 'table';
+
+ /**
+ * Create a new item table
+ *
+ * @param iterable $data Datasource of the table
+ * @param array<string, string> $columns The columns to render, keys are labels
+ */
+ public function __construct(iterable $data, array $columns)
+ {
+ $this->data = $data;
+ $this->columns = array_flip($columns);
+
+ $this->addAttributes($this->baseAttributes);
+
+ $this->init();
+ }
+
+ /**
+ * Initialize the item table
+ *
+ * If you want to adjust the item table after construction, override this method.
+ */
+ protected function init()
+ {
+ }
+
+ /**
+ * Get the columns being rendered
+ *
+ * @return array<string, string>
+ */
+ public function getColumns(): array
+ {
+ return $this->columns;
+ }
+
+ /**
+ * Set sort rules (as returned by {@see SortControl::getSort()})
+ *
+ * @param ?string $sort
+ *
+ * @return $this
+ */
+ public function setSort(?string $sort): self
+ {
+ $this->sort = $sort;
+
+ return $this;
+ }
+
+ abstract protected function getItemClass(): string;
+
+ abstract protected function getVisualColumn(): string;
+
+ protected function getVisualLabel()
+ {
+ return new Icon('heartbeat', ['title' => t('Severity')]);
+ }
+
+ protected function assembleColumnHeader(BaseHtmlElement $header, string $name, $label): void
+ {
+ $sortRules = [];
+ if ($this->sort !== null) {
+ $sortRules = SortUtil::createOrderBy($this->sort);
+ }
+
+ $active = false;
+ $sortDirection = null;
+ foreach ($sortRules as $rule) {
+ if ($rule[0] === $name) {
+ $sortDirection = $rule[1];
+ $active = true;
+ break;
+ }
+ }
+
+ if ($sortDirection === 'desc') {
+ $value = "$name asc";
+ } else {
+ $value = "$name desc";
+ }
+
+ $icon = 'sort';
+ if ($active) {
+ $icon = $sortDirection === 'desc' ? 'sort-up' : 'sort-down';
+ }
+
+ $form = new Form();
+ $form->setAttribute('method', 'GET');
+
+ $button = $form->createElement('button', 'sort', [
+ 'value' => $value,
+ 'type' => 'submit',
+ 'title' => is_string($label) ? $label : null,
+ 'class' => $active ? 'active' : null
+ ]);
+ $button->addHtml(
+ Html::tag(
+ 'span',
+ null,
+ // With &nbsp; to have the height sized the same as the others
+ $label ?? HtmlString::create('&nbsp;')
+ ),
+ new Icon($icon)
+ );
+ $form->addElement($button);
+
+ $header->add($form);
+
+ switch (true) {
+ case substr($name, -7) === '.output':
+ case substr($name, -12) === '.long_output':
+ $header->getAttributes()->add('class', 'has-plugin-output');
+ break;
+ case substr($name, -22) === '.icon_image.icon_image':
+ $header->getAttributes()->add('class', 'has-icon-images');
+ break;
+ case substr($name, -17) === '.performance_data':
+ case substr($name, -28) === '.normalized_performance_data':
+ $header->getAttributes()->add('class', 'has-performance-data');
+ break;
+ }
+ }
+
+ protected function assemble()
+ {
+ $itemClass = $this->getItemClass();
+
+ $headerRow = new HtmlElement('tr');
+
+ $visualCell = new HtmlElement('th', Attributes::create(['class' => 'has-visual']));
+ $this->assembleColumnHeader($visualCell, $this->getVisualColumn(), $this->getVisualLabel());
+ $headerRow->addHtml($visualCell);
+
+ foreach ($this->columns as $name => $label) {
+ $headerCell = new HtmlElement('th');
+ $this->assembleColumnHeader($headerCell, $name, is_int($label) ? $name : $label);
+ $headerRow->addHtml($headerCell);
+ }
+
+ $this->addHtml(new HtmlElement('thead', null, $headerRow));
+
+ $body = new HtmlElement('tbody', Attributes::create(['data-base-target' => '_next']));
+ foreach ($this->data as $item) {
+ $body->addHtml(new $itemClass($item, $this));
+ }
+
+ if ($body->isEmpty()) {
+ $body->addHtml(new HtmlElement(
+ 'tr',
+ null,
+ new HtmlElement(
+ 'td',
+ Attributes::create(['colspan' => count($this->columns)]),
+ new EmptyStateBar(t('No items found.'))
+ )
+ ));
+ }
+
+ $this->addHtml($body);
+ }
+
+ /**
+ * Enrich the given list of column names with appropriate labels
+ *
+ * @param Query $query
+ * @param array $columns
+ *
+ * @return array
+ */
+ public static function applyColumnMetaData(Query $query, array $columns): array
+ {
+ $newColumns = [];
+ foreach ($columns as $columnPath) {
+ $label = $query->getResolver()->getColumnDefinition($columnPath)->getLabel();
+ $newColumns[$label ?? $columnPath] = $columnPath;
+ }
+
+ return $newColumns;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/StateRowItem.php b/library/Icingadb/Widget/ItemTable/StateRowItem.php
new file mode 100644
index 0000000..f62286b
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/StateRowItem.php
@@ -0,0 +1,124 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\CheckAttempt;
+use Icinga\Module\Icingadb\Widget\IconImage;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Web\Widget\EmptyState;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeSince;
+use ipl\Web\Widget\TimeUntil;
+
+abstract class StateRowItem extends BaseStateRowItem
+{
+ /** @var StateItemTable */
+ protected $list;
+
+ protected function assembleVisual(BaseHtmlElement $visual)
+ {
+ $stateBall = new StateBall($this->item->state->getStateText(), StateBall::SIZE_LARGE);
+ $stateBall->add($this->item->state->getIcon());
+
+ if ($this->item->state->is_handled) {
+ $stateBall->getAttributes()->add('class', 'handled');
+ }
+
+ $visual->addHtml($stateBall);
+ if ($this->item->state->state_type === 'soft') {
+ $visual->addHtml(new CheckAttempt(
+ (int) $this->item->state->check_attempt,
+ (int) $this->item->max_check_attempts
+ ));
+ }
+ }
+
+ protected function assembleCell(BaseHtmlElement $cell, string $path, $value)
+ {
+ switch (true) {
+ case $path === 'state.output':
+ case $path === 'state.long_output':
+ if (empty($value)) {
+ $pluginOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->item));
+ }
+
+ $cell->addHtml($pluginOutput)
+ ->getAttributes()
+ ->add('class', 'has-plugin-output');
+ break;
+ case $path === 'state.soft_state':
+ case $path === 'state.hard_state':
+ case $path === 'state.previous_soft_state':
+ case $path === 'state.previous_hard_state':
+ $stateType = substr($path, 6);
+ if ($this->item instanceof Host) {
+ $stateName = HostStates::translated($this->item->state->$stateType);
+ } else {
+ $stateName = ServiceStates::translated($this->item->state->$stateType);
+ }
+
+ $cell->addHtml(Text::create($stateName));
+ break;
+ case $path === 'state.last_update':
+ case $path === 'state.last_state_change':
+ $column = substr($path, 6);
+ $cell->addHtml(new TimeSince($this->item->state->$column->getTimestamp()));
+ break;
+ case $path === 'state.next_check':
+ case $path === 'state.next_update':
+ $column = substr($path, 6);
+ $cell->addHtml(new TimeUntil($this->item->state->$column->getTimestamp()));
+ break;
+ case $path === 'state.performance_data':
+ case $path === 'state.normalized_performance_data':
+ $perfdataContainer = new HtmlElement('div', Attributes::create(['class' => 'performance-data']));
+
+ $pieChartData = PerfDataSet::fromString($this->item->state->normalized_performance_data)->asArray();
+ foreach ($pieChartData as $perfdata) {
+ if ($perfdata->isVisualizable()) {
+ $perfdataContainer->addHtml(new HtmlString($perfdata->asInlinePie()->render()));
+ }
+ }
+
+ $cell->addHtml($perfdataContainer)
+ ->getAttributes()
+ ->add('class', 'has-performance-data');
+ break;
+ case $path === 'is_volatile':
+ case $path === 'host.is_volatile':
+ case substr($path, -8) == '_enabled':
+ case (bool) preg_match('/state\.(is_|in_)/', $path):
+ if ($value) {
+ $cell->addHtml(new Icon('check'));
+ }
+
+ break;
+ case $path === 'icon_image.icon_image':
+ $cell->addHtml(new IconImage($value, $this->item->icon_image_alt))
+ ->getAttributes()
+ ->add('class', 'has-icon-images');
+ break;
+ default:
+ if (preg_match('/(^id|_id|.id|_checksum|_bin)$/', $path)) {
+ $value = bin2hex($value);
+ }
+
+ $cell->addHtml(Text::create($value));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/TableRowLayout.php b/library/Icingadb/Widget/ItemTable/TableRowLayout.php
new file mode 100644
index 0000000..b9ce022
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/TableRowLayout.php
@@ -0,0 +1,26 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+
+trait TableRowLayout
+{
+ protected function assembleColumns(HtmlDocument $columns): void
+ {
+ foreach ($this->createStatistics() as $objectStatistic) {
+ $columns->addHtml($objectStatistic);
+ }
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ $this->createSubject(),
+ $this->createCaption()
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/UserTable.php b/library/Icingadb/Widget/ItemTable/UserTable.php
new file mode 100644
index 0000000..432817b
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/UserTable.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Url;
+
+class UserTable extends BaseItemTable
+{
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'user-table'];
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/user'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return UserTableRow::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/UserTableRow.php b/library/Icingadb/Widget/ItemTable/UserTableRow.php
new file mode 100644
index 0000000..c10851e
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/UserTableRow.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\User;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTableRowItem;
+use ipl\Web\Widget\Link;
+
+/**
+ * User item of a user list. Represents one database row.
+ *
+ * @property User $item
+ * @property UserTable $table
+ */
+class UserTableRow extends BaseTableRowItem
+{
+ protected $defaultAttributes = ['class' => 'user-table-row'];
+
+ protected function init(): void
+ {
+ if (isset($this->table)) {
+ $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $visual->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'user-ball']),
+ Text::create($this->item->display_name[0])
+ ));
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ isset($this->table)
+ ? new Link($this->item->display_name, Links::user($this->item), ['class' => 'subject'])
+ : new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ ),
+ new HtmlElement('span', null, Text::create($this->item->name))
+ );
+ }
+
+ protected function assembleColumns(HtmlDocument $columns): void
+ {
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/UsergroupTable.php b/library/Icingadb/Widget/ItemTable/UsergroupTable.php
new file mode 100644
index 0000000..77d3ba9
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/UsergroupTable.php
@@ -0,0 +1,27 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\DetailActions;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Url;
+
+class UsergroupTable extends BaseItemTable
+{
+ use DetailActions;
+
+ protected $defaultAttributes = ['class' => 'usergroup-table'];
+
+ protected function init(): void
+ {
+ $this->initializeDetailActions();
+ $this->setDetailUrl(Url::fromPath('icingadb/usergroup'));
+ }
+
+ protected function getItemClass(): string
+ {
+ return UsergroupTableRow::class;
+ }
+}
diff --git a/library/Icingadb/Widget/ItemTable/UsergroupTableRow.php b/library/Icingadb/Widget/ItemTable/UsergroupTableRow.php
new file mode 100644
index 0000000..c3cbf74
--- /dev/null
+++ b/library/Icingadb/Widget/ItemTable/UsergroupTableRow.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\ItemTable;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseTableRowItem;
+use ipl\Web\Widget\Link;
+
+/**
+ * Usergroup item of a usergroup list. Represents one database row.
+ *
+ * @property Usergroup $item
+ * @property UsergroupTable $table
+ */
+class UsergroupTableRow extends BaseTableRowItem
+{
+ protected $defaultAttributes = ['class' => 'usergroup-table-row'];
+
+ protected function init(): void
+ {
+ if (isset($this->table)) {
+ $this->table->addDetailFilterAttribute($this, Filter::equal('name', $this->item->name));
+ }
+ }
+
+ protected function assembleVisual(BaseHtmlElement $visual): void
+ {
+ $visual->addHtml(new HtmlElement(
+ 'div',
+ Attributes::create(['class' => 'usergroup-ball']),
+ Text::create($this->item->display_name[0])
+ ));
+ }
+
+ protected function assembleTitle(BaseHtmlElement $title): void
+ {
+ $title->addHtml(
+ isset($this->table)
+ ? new Link($this->item->display_name, Links::usergroup($this->item), ['class' => 'subject'])
+ : new HtmlElement(
+ 'span',
+ Attributes::create(['class' => 'subject']),
+ Text::create($this->item->display_name)
+ ),
+ new HtmlElement('span', null, Text::create($this->item->name))
+ );
+ }
+
+ protected function assembleColumns(HtmlDocument $columns): void
+ {
+ }
+}
diff --git a/library/Icingadb/Widget/MarkdownLine.php b/library/Icingadb/Widget/MarkdownLine.php
new file mode 100644
index 0000000..74c413d
--- /dev/null
+++ b/library/Icingadb/Widget/MarkdownLine.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Web\Helper\Markdown;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\DeferredText;
+
+class MarkdownLine extends BaseHtmlElement
+{
+ protected $tag = 'section';
+
+ protected $defaultAttributes = ['class' => ['markdown', 'inline']];
+
+ /**
+ * MarkdownLine constructor.
+ *
+ * @param string $line
+ */
+ public function __construct(string $line)
+ {
+ $this->add((new DeferredText(function () use ($line) {
+ return Markdown::line($line);
+ }))->setEscaped(true));
+ }
+}
diff --git a/library/Icingadb/Widget/MarkdownText.php b/library/Icingadb/Widget/MarkdownText.php
new file mode 100644
index 0000000..43db03e
--- /dev/null
+++ b/library/Icingadb/Widget/MarkdownText.php
@@ -0,0 +1,28 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Web\Helper\Markdown;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\DeferredText;
+
+class MarkdownText extends BaseHtmlElement
+{
+ protected $tag = 'section';
+
+ protected $defaultAttributes = ['class' => 'markdown'];
+
+ /**
+ * MarkdownText constructor.
+ *
+ * @param string $text
+ */
+ public function __construct(string $text)
+ {
+ $this->add((new DeferredText(function () use ($text) {
+ return Markdown::text($text);
+ }))->setEscaped(true));
+ }
+}
diff --git a/library/Icingadb/Widget/Notice.php b/library/Icingadb/Widget/Notice.php
new file mode 100644
index 0000000..3ed6dad
--- /dev/null
+++ b/library/Icingadb/Widget/Notice.php
@@ -0,0 +1,36 @@
+<?php
+
+/* Icinga DB Web | (c) 2022 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Web\Widget\Icon;
+
+class Notice extends BaseHtmlElement
+{
+ /** @var mixed */
+ protected $content;
+
+ protected $tag = 'p';
+
+ protected $defaultAttributes = ['class' => 'notice'];
+
+ /**
+ * Create a html notice
+ *
+ * @param mixed $content
+ */
+ public function __construct($content)
+ {
+ $this->content = $content;
+ }
+
+ protected function assemble(): void
+ {
+ $this->addHtml(new Icon('triangle-exclamation'));
+ $this->addHtml((new HtmlElement('span'))->add($this->content));
+ $this->addHtml(new Icon('triangle-exclamation'));
+ }
+}
diff --git a/library/Icingadb/Widget/PluginOutputContainer.php b/library/Icingadb/Widget/PluginOutputContainer.php
new file mode 100644
index 0000000..a8ff578
--- /dev/null
+++ b/library/Icingadb/Widget/PluginOutputContainer.php
@@ -0,0 +1,22 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use ipl\Html\BaseHtmlElement;
+
+class PluginOutputContainer extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ public function __construct(PluginOutput $output)
+ {
+ $this->setHtmlContent($output);
+
+ $this->getAttributes()->registerAttributeCallback('class', function () use ($output) {
+ return $output->isHtml() ? 'plugin-output' : 'plugin-output preformatted';
+ });
+ }
+}
diff --git a/library/Icingadb/Widget/ServiceStateBadges.php b/library/Icingadb/Widget/ServiceStateBadges.php
new file mode 100644
index 0000000..fee2586
--- /dev/null
+++ b/library/Icingadb/Widget/ServiceStateBadges.php
@@ -0,0 +1,46 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Common\StateBadges;
+use ipl\Web\Url;
+
+class ServiceStateBadges extends StateBadges
+{
+ protected function getBaseUrl(): Url
+ {
+ return Links::services();
+ }
+
+ protected function getType(): string
+ {
+ return 'service';
+ }
+
+ protected function getPrefix(): string
+ {
+ return 'services';
+ }
+
+ protected function getStateInt(string $state): int
+ {
+ return ServiceStates::int($state);
+ }
+
+ protected function assemble()
+ {
+ $this->addAttributes(['class' => 'service-state-badges']);
+
+ $this->add(array_filter([
+ $this->createGroup('critical'),
+ $this->createGroup('warning'),
+ $this->createGroup('unknown'),
+ $this->createBadge('ok'),
+ $this->createBadge('pending')
+ ]));
+ }
+}
diff --git a/library/Icingadb/Widget/ServiceStatusBar.php b/library/Icingadb/Widget/ServiceStatusBar.php
new file mode 100644
index 0000000..56f47aa
--- /dev/null
+++ b/library/Icingadb/Widget/ServiceStatusBar.php
@@ -0,0 +1,24 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Module\Icingadb\Common\BaseStatusBar;
+use ipl\Html\BaseHtmlElement;
+
+class ServiceStatusBar extends BaseStatusBar
+{
+ protected function assembleTotal(BaseHtmlElement $total): void
+ {
+ $total->add(sprintf(
+ tp('%d Service', '%d Services', $this->summary->services_total),
+ $this->summary->services_total
+ ));
+ }
+
+ protected function createStateBadges(): BaseHtmlElement
+ {
+ return (new ServiceStateBadges($this->summary))->setBaseFilter($this->getBaseFilter());
+ }
+}
diff --git a/library/Icingadb/Widget/ServiceSummaryDonut.php b/library/Icingadb/Widget/ServiceSummaryDonut.php
new file mode 100644
index 0000000..e806fba
--- /dev/null
+++ b/library/Icingadb/Widget/ServiceSummaryDonut.php
@@ -0,0 +1,81 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\BaseFilter;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\Card;
+
+class ServiceSummaryDonut extends Card
+{
+ use BaseFilter;
+
+ protected $defaultAttributes = ['class' => 'donut-container', 'data-base-target' => '_next'];
+
+ /** @var ServicestateSummary */
+ protected $summary;
+
+ public function __construct(ServicestateSummary $summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function assembleBody(BaseHtmlElement $body)
+ {
+ $labelBigUrlFilter = Filter::all(
+ Filter::equal('service.state.soft_state', 2),
+ Filter::equal('service.state.is_handled', 'n')
+ );
+ if ($this->hasBaseFilter()) {
+ $labelBigUrlFilter->add($this->getBaseFilter());
+ }
+
+ $donut = (new Donut())
+ ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled'])
+ ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning'])
+ ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled'])
+ ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown'])
+ ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending'])
+ ->setLabelBig($this->summary->services_critical_unhandled)
+ ->setLabelBigUrl(Links::services()->setFilter($labelBigUrlFilter)->addParams([
+ 'sort' => 'service.state.last_state_change'
+ ]))
+ ->setLabelBigEyeCatching($this->summary->services_critical_unhandled > 0)
+ ->setLabelSmall(t('Critical'));
+
+ $body->addHtml(
+ new HtmlElement('div', Attributes::create(['class' => 'donut']), new HtmlString($donut->render()))
+ );
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer)
+ {
+ $footer->addHtml((new ServiceStateBadges($this->summary))->setBaseFilter($this->getBaseFilter()));
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $header->addHtml(
+ new HtmlElement('h2', null, Text::create(t('Services'))),
+ new HtmlElement('span', Attributes::create(['class' => 'meta']), TemplateString::create(
+ t('{{#total}}Total{{/total}} %d'),
+ ['total' => new HtmlElement('span')],
+ (int) $this->summary->services_total
+ ))
+ );
+ }
+}
diff --git a/library/Icingadb/Widget/ShowMore.php b/library/Icingadb/Widget/ShowMore.php
new file mode 100644
index 0000000..d7fc7fb
--- /dev/null
+++ b/library/Icingadb/Widget/ShowMore.php
@@ -0,0 +1,64 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Orm\ResultSet;
+use ipl\Web\Common\BaseTarget;
+use ipl\Web\Url;
+use ipl\Web\Widget\ActionLink;
+
+class ShowMore extends BaseHtmlElement
+{
+ use BaseTarget;
+
+ protected $defaultAttributes = ['class' => 'show-more'];
+
+ protected $tag = 'div';
+
+ /** @var ResultSet */
+ protected $resultSet;
+
+ /** @var Url */
+ protected $url;
+
+ /** @var ?string */
+ protected $label;
+
+ public function __construct(ResultSet $resultSet, Url $url, string $label = null)
+ {
+ $this->label = $label;
+ $this->resultSet = $resultSet;
+ $this->url = $url;
+ }
+
+ public function setLabel(string $label): self
+ {
+ $this->label = $label;
+
+ return $this;
+ }
+
+ public function getLabel(): string
+ {
+ return $this->label ?: t('Show More');
+ }
+
+ public function renderUnwrapped(): string
+ {
+ if ($this->resultSet->hasMore()) {
+ return parent::renderUnwrapped();
+ }
+
+ return '';
+ }
+
+ protected function assemble(): void
+ {
+ if ($this->resultSet->hasMore()) {
+ $this->addHtml(new ActionLink($this->getLabel(), $this->url));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/StateBadge.php b/library/Icingadb/Widget/StateBadge.php
new file mode 100644
index 0000000..d947590
--- /dev/null
+++ b/library/Icingadb/Widget/StateBadge.php
@@ -0,0 +1,10 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+/** @deprecated Use {@see \ipl\Web\Widget\StateBadge} instead */
+class StateBadge extends \ipl\Web\Widget\StateBadge
+{
+}
diff --git a/library/Icingadb/Widget/StateChange.php b/library/Icingadb/Widget/StateChange.php
new file mode 100644
index 0000000..a9987be
--- /dev/null
+++ b/library/Icingadb/Widget/StateChange.php
@@ -0,0 +1,133 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class StateChange extends BaseHtmlElement
+{
+ protected $previousState;
+
+ protected $state;
+
+ protected $previousStateBallSize = StateBall::SIZE_BIG;
+
+ protected $currentStateBallSize = StateBall::SIZE_BIG;
+
+ protected $defaultAttributes = ['class' => 'state-change'];
+
+ protected $tag = 'div';
+
+ /** @var ?Icon Current state ball icon */
+ protected $icon;
+
+ /** @var bool Whether the state is handled */
+ protected $isHandled = false;
+
+ public function __construct(string $state, string $previousState)
+ {
+ $this->previousState = $previousState;
+ $this->state = $state;
+ }
+
+ /**
+ * Set the state ball size for the previous state
+ *
+ * @param string $size
+ *
+ * @return $this
+ */
+ public function setPreviousStateBallSize(string $size): self
+ {
+ $this->previousStateBallSize = $size;
+
+ return $this;
+ }
+
+ /**
+ * Set the state ball size for the current state
+ *
+ * @param string $size
+ *
+ * @return $this
+ */
+ public function setCurrentStateBallSize(string $size): self
+ {
+ $this->currentStateBallSize = $size;
+
+ return $this;
+ }
+
+ /**
+ * Set the current state ball icon
+ *
+ * @param $icon
+ *
+ * @return $this
+ */
+ public function setIcon($icon): self
+ {
+ $this->icon = $icon;
+
+ return $this;
+ }
+
+ /**
+ * Set whether the current state is handled
+ *
+ * @return $this
+ */
+ public function setHandled($isHandled = true): self
+ {
+ $this->isHandled = $isHandled;
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ $currentStateBall = (new StateBall($this->state, $this->currentStateBallSize))
+ ->add($this->icon);
+
+ if ($this->isHandled) {
+ $currentStateBall->getAttributes()->add('class', 'handled');
+ }
+
+ $previousStateBall = new StateBall($this->previousState, $this->previousStateBallSize);
+ if ($this->isRightBiggerThanLeft()) {
+ $this->getAttributes()->add('class', 'reversed-state-balls');
+
+ $this->addHtml($currentStateBall, $previousStateBall);
+ } else {
+ $this->addHtml($previousStateBall, $currentStateBall);
+ }
+ }
+
+ protected function isRightBiggerThanLeft(): bool
+ {
+ $left = $this->previousStateBallSize;
+ $right = $this->currentStateBallSize;
+
+ if ($left === $right) {
+ return false;
+ } elseif ($left === StateBall::SIZE_LARGE) {
+ return false;
+ }
+
+ $map = [
+ StateBall::SIZE_BIG => [false, [StateBall::SIZE_LARGE]],
+ StateBall::SIZE_MEDIUM_LARGE => [false, [StateBall::SIZE_BIG, StateBall::SIZE_LARGE]],
+ StateBall::SIZE_MEDIUM => [true, [StateBall::SIZE_TINY, StateBall::SIZE_SMALL]],
+ StateBall::SIZE_SMALL => [true, [StateBall::SIZE_TINY]]
+ ];
+
+ list($negate, $sizes) = $map[$left];
+ $found = in_array($right, $sizes, true);
+
+ return ($negate && ! $found) || (! $negate && $found);
+ }
+}
diff --git a/library/Icingadb/Widget/TagList.php b/library/Icingadb/Widget/TagList.php
new file mode 100644
index 0000000..6a28a9c
--- /dev/null
+++ b/library/Icingadb/Widget/TagList.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\Link;
+
+class TagList extends BaseHtmlElement
+{
+ protected $content = [];
+
+ protected $defaultAttributes = ['class' => 'tag-list'];
+
+ protected $tag = 'div';
+
+ public function addLink($content, $url): self
+ {
+ $this->content[] = new Link($content, $url);
+
+ return $this;
+ }
+
+ public function hasContent(): bool
+ {
+ return ! empty($this->content);
+ }
+
+ protected function assemble()
+ {
+ $this->add(Html::wrapEach($this->content, 'li'));
+ }
+}