summaryrefslogtreecommitdiffstats
path: root/library/Icingadb/Widget
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-13 11:44:46 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-13 11:44:46 +0000
commitb18bc644404e02b57635bfcc8258e85abb141146 (patch)
tree686512eacb2dba0055277ef7ec2f28695b3418ea /library/Icingadb/Widget
parentInitial commit. (diff)
downloadicingadb-web-b18bc644404e02b57635bfcc8258e85abb141146.tar.xz
icingadb-web-b18bc644404e02b57635bfcc8258e85abb141146.zip
Adding upstream version 1.1.1.upstream/1.1.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-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
96 files changed, 8561 insertions, 0 deletions
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'));
+ }
+}