summaryrefslogtreecommitdiffstats
path: root/library/Icingadb/Widget/Detail
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icingadb/Widget/Detail')
-rw-r--r--library/Icingadb/Widget/Detail/CheckStatistics.php204
-rw-r--r--library/Icingadb/Widget/Detail/CommentDetail.php140
-rw-r--r--library/Icingadb/Widget/Detail/CustomVarTable.php267
-rw-r--r--library/Icingadb/Widget/Detail/DowntimeCard.php336
-rw-r--r--library/Icingadb/Widget/Detail/DowntimeDetail.php206
-rw-r--r--library/Icingadb/Widget/Detail/EventDetail.php612
-rw-r--r--library/Icingadb/Widget/Detail/HostDetail.php58
-rw-r--r--library/Icingadb/Widget/Detail/HostInspectionDetail.php21
-rw-r--r--library/Icingadb/Widget/Detail/HostMetaInfo.php75
-rw-r--r--library/Icingadb/Widget/Detail/HostStatistics.php62
-rw-r--r--library/Icingadb/Widget/Detail/MultiselectQuickActions.php194
-rw-r--r--library/Icingadb/Widget/Detail/ObjectDetail.php585
-rw-r--r--library/Icingadb/Widget/Detail/ObjectStatistics.php34
-rw-r--r--library/Icingadb/Widget/Detail/ObjectsDetail.php192
-rw-r--r--library/Icingadb/Widget/Detail/PerfDataTable.php133
-rw-r--r--library/Icingadb/Widget/Detail/QuickActions.php148
-rw-r--r--library/Icingadb/Widget/Detail/ServiceDetail.php37
-rw-r--r--library/Icingadb/Widget/Detail/ServiceInspectionDetail.php21
-rw-r--r--library/Icingadb/Widget/Detail/ServiceMetaInfo.php61
-rw-r--r--library/Icingadb/Widget/Detail/ServiceStatistics.php66
-rw-r--r--library/Icingadb/Widget/Detail/UserDetail.php188
-rw-r--r--library/Icingadb/Widget/Detail/UsergroupDetail.php98
22 files changed, 3738 insertions, 0 deletions
diff --git a/library/Icingadb/Widget/Detail/CheckStatistics.php b/library/Icingadb/Widget/Detail/CheckStatistics.php
new file mode 100644
index 0000000..51bcb63
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CheckStatistics.php
@@ -0,0 +1,204 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Widget\CheckAttempt;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use Icinga\Util\Format;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Common\Card;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Web\Widget\TimeSince;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Web\Widget\VerticalKeyValue;
+
+class CheckStatistics extends Card
+{
+ protected $object;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'progress-bar check-statistics'];
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ }
+
+ protected function assembleBody(BaseHtmlElement $body)
+ {
+ $hPadding = 10;
+ $durationScale = 80;
+
+ $timeline = Html::tag('div', ['class' => 'check-timeline timeline']);
+
+ $overdueBar = null;
+ $nextCheckTime = $this->object->state->next_check;
+ if ($this->object->state->is_overdue) {
+ $nextCheckTime = $this->object->state->next_update;
+ $leftNow = $durationScale + $hPadding / 2;
+
+ $overdueScale = ($durationScale / 2) * (time() - $nextCheckTime) / (10 * $this->object->check_interval);
+ if ($overdueScale > $durationScale / 2) {
+ $overdueScale = $durationScale / 2;
+ }
+
+ $durationScale -= $overdueScale;
+ $overdueBar = Html::tag('div', [
+ 'class' => 'timeline-overlay check-overdue',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $durationScale,
+ $overdueScale + $hPadding / 2
+ )
+ ]);
+ } else {
+ $leftNow = $durationScale * (1 - ($nextCheckTime - time()) / $this->object->check_interval);
+ if ($leftNow > $durationScale) {
+ $leftNow = $durationScale;
+ } elseif ($leftNow < 0) {
+ $leftNow = 0;
+ }
+ }
+
+ $above = Html::tag('ul', ['class' => 'above']);
+ $now = Html::tag(
+ 'li',
+ [
+ 'class' => 'now positioned',
+ 'style' => sprintf('left: %F%%', $hPadding + $leftNow)
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ Html::tag(
+ 'strong',
+ t('Now')
+ )
+ )
+ );
+ $above->add($now);
+
+ $markerLast = Html::tag('div', [
+ 'class' => 'marker start',
+ 'style' => 'left: ' . $hPadding . '%',
+ 'title' => $this->object->state->last_update !== null
+ ? DateFormatter::formatDateTime($this->object->state->last_update)
+ : null
+ ]);
+ $markerNext = Html::tag('div', [
+ 'class' => 'marker end',
+ 'style' => sprintf('left: %F%%', $hPadding + $durationScale),
+ 'title' => $nextCheckTime !== null ? DateFormatter::formatDateTime($nextCheckTime) : null
+ ]);
+ $markerNow = Html::tag('div', [
+ 'class' => 'marker now',
+ 'style' => sprintf('left: %F%%', $hPadding + $leftNow),
+ ]);
+
+ $timeline->add([
+ $markerLast,
+ $markerNow,
+ $markerNext,
+ $overdueBar
+ ]);
+
+ $lastUpdate = Html::tag(
+ 'li',
+ ['class' => 'start'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('Last update'), $this->object->state->last_update !== null
+ ? new TimeAgo($this->object->state->last_update)
+ : t('PENDING'))
+ )
+ );
+ $interval = Html::tag(
+ 'li',
+ ['class' => 'interval'],
+ new VerticalKeyValue(
+ t('Interval'),
+ $this->object->check_interval
+ ? Format::seconds($this->object->check_interval)
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ );
+ $nextCheck = Html::tag(
+ 'li',
+ ['class' => 'end'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ $this->object->state->is_overdue
+ ? new VerticalKeyValue(t('Overdue'), new TimeSince($nextCheckTime))
+ : new VerticalKeyValue(
+ t('Next Check'),
+ $nextCheckTime !== null ? new TimeUntil($nextCheckTime) : t('PENDING')
+ )
+ )
+ );
+
+ $below = Html::tag(
+ 'ul',
+ [
+ 'class' => 'below',
+ 'style' => sprintf('width: %F%%;', $durationScale)
+ ]
+ );
+ $below->add([
+ $lastUpdate,
+ $interval,
+ $nextCheck
+ ]);
+
+ $body->add([$above, $timeline, $below]);
+ }
+
+ protected function assembleFooter(BaseHtmlElement $footer)
+ {
+ $footer->add(new HorizontalKeyValue(
+ t('Scheduling Source') . ':',
+ $this->object->state->scheduling_source ?? (new EmptyState(t('n. a.')))->setTag('span')
+ ));
+ }
+
+ protected function assembleHeader(BaseHtmlElement $header)
+ {
+ $checkSource = (new EmptyState(t('n. a.')))->setTag('span');
+ if ($this->object->state->check_source) {
+ $checkSource = [
+ new StateBall($this->object->state->is_reachable ? 'up' : 'down', StateBall::SIZE_MEDIUM),
+ ' ',
+ $this->object->state->check_source
+ ];
+ }
+
+ $header->add([
+ new VerticalKeyValue(t('Command'), $this->object->checkcommand_name),
+ new VerticalKeyValue(
+ t('Attempts'),
+ new CheckAttempt((int) $this->object->state->check_attempt, (int) $this->object->max_check_attempts)
+ ),
+ new VerticalKeyValue(t('Check Source'), $checkSource),
+ new VerticalKeyValue(
+ t('Execution time'),
+ $this->object->state->execution_time
+ ? Format::seconds($this->object->state->execution_time)
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ ),
+ new VerticalKeyValue(
+ t('Latency'),
+ $this->object->state->latency
+ ? Format::seconds($this->object->state->latency)
+ : (new EmptyState(t('n. a.')))->setTag('span')
+ )
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/CommentDetail.php b/library/Icingadb/Widget/Detail/CommentDetail.php
new file mode 100644
index 0000000..eb13523
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CommentDetail.php
@@ -0,0 +1,140 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Model\Comment;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteCommentForm;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Web\Widget\StateBall;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class CommentDetail extends BaseHtmlElement
+{
+ use Auth;
+ use TicketLinks;
+
+ protected $comment;
+
+ protected $defaultAttributes = ['class' => ['object-detail', 'comment-detail']];
+
+ protected $tag = 'div';
+
+ public function __construct(Comment $comment)
+ {
+ $this->comment = $comment;
+ }
+
+ protected function createComment(): array
+ {
+ return [
+ Html::tag('h2', t('Comment')),
+ new MarkdownText($this->createTicketLinks($this->comment->text))
+ ];
+ }
+
+ protected function createDetails(): array
+ {
+ $details = [];
+
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ if ($this->comment->object_type === 'host') {
+ $details[] = new HorizontalKeyValue(t('Host'), [
+ $this->comment->host->name,
+ ' ',
+ new StateBall($this->comment->host->state->getStateText())
+ ]);
+ } else {
+ $details[] = new HorizontalKeyValue(t('Service'), Html::sprintf(
+ t('%s on %s', '<service> on <host>'),
+ [$this->comment->service->name, ' ', new StateBall($this->comment->service->state->getStateText())],
+ $this->comment->host->name
+ ));
+ }
+
+ $details[] = new HorizontalKeyValue(t('Author'), $this->comment->author);
+ $details[] = new HorizontalKeyValue(
+ t('Acknowledgement'),
+ $this->comment->entry_type === 'ack' ? t('Yes') : t('No')
+ );
+ $details[] = new HorizontalKeyValue(
+ t('Persistent'),
+ $this->comment->is_persistent ? t('Yes') : t('No')
+ );
+ $details[] = new HorizontalKeyValue(
+ t('Created'),
+ DateFormatter::formatDateTime($this->comment->entry_time)
+ );
+ $details[] = new HorizontalKeyValue(t('Expires'), $this->comment->expire_time != 0
+ ? DateFormatter::formatDateTime($this->comment->expire_time)
+ : t('Never'));
+ } else {
+ if ($this->comment->expire_time != 0) {
+ $details[] = Html::tag(
+ 'p',
+ Html::sprintf(
+ $this->comment->entry_type === 'ack'
+ ? t('This acknowledgement expires %s.', '..<time-until>')
+ : t('This comment expires %s.', '..<time-until>'),
+ new TimeUntil($this->comment->expire_time)
+ )
+ );
+ }
+
+ if ($this->comment->is_sticky) {
+ $details[] = Html::tag('p', t('This acknowledgement is sticky.'));
+ }
+ }
+
+ if (! empty($details)) {
+ array_unshift($details, Html::tag('h2', t('Details')));
+ }
+
+ return $details;
+ }
+
+ protected function createRemoveCommentForm()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ return null;
+ }
+
+ $action = Links::commentsDelete();
+ $action->setQueryString(QueryString::render(Filter::equal('name', $this->comment->name)));
+
+ return (new DeleteCommentForm())
+ ->setObjects([$this->comment])
+ ->populate(['redirect' => '__BACK__'])
+ ->setAction($action->getAbsoluteUrl());
+ }
+
+ protected function assemble()
+ {
+ $this->add($this->createComment());
+
+ $details = $this->createDetails();
+
+ if (! empty($details)) {
+ $this->add($details);
+ }
+
+ if (
+ $this->isGrantedOn(
+ 'icingadb/command/comment/delete',
+ $this->comment->{$this->comment->object_type}
+ )
+ ) {
+ $this->add($this->createRemoveCommentForm());
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/CustomVarTable.php b/library/Icingadb/Widget/Detail/CustomVarTable.php
new file mode 100644
index 0000000..9d3e06b
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/CustomVarTable.php
@@ -0,0 +1,267 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\CustomVarRendererHook;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Orm\Model;
+use ipl\Web\Widget\Icon;
+
+class CustomVarTable extends BaseHtmlElement
+{
+ /** @var array The variables */
+ protected $data;
+
+ /** @var ?Model The object the variables are bound to */
+ protected $object;
+
+ /** @var Closure Callback to apply hooks */
+ protected $hookApplier;
+
+ /** @var array The groups as identified by hooks */
+ protected $groups = [];
+
+ /** @var string Header title */
+ protected $headerTitle;
+
+ /** @var int The nesting level */
+ protected $level = 0;
+
+ protected $tag = 'table';
+
+ /** @var HtmlElement The table body */
+ protected $body;
+
+ protected $defaultAttributes = [
+ 'class' => ['custom-var-table', 'name-value-table']
+ ];
+
+ /**
+ * Create a new CustomVarTable
+ *
+ * @param iterable $data
+ * @param ?Model $object
+ */
+ public function __construct($data, Model $object = null)
+ {
+ $this->data = $data;
+ $this->object = $object;
+ $this->body = new HtmlElement('tbody');
+ }
+
+ /**
+ * Set the header to show
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ protected function setHeader(string $title): self
+ {
+ $this->headerTitle = $title;
+
+ return $this;
+ }
+
+ /**
+ * Add a new row to the body
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function addRow($name, $value)
+ {
+ $this->body->addHtml(new HtmlElement(
+ 'tr',
+ Attributes::create(['class' => "level-{$this->level}"]),
+ new HtmlElement('th', null, Html::wantHtml($name)),
+ new HtmlElement('td', null, Html::wantHtml($value))
+ ));
+ }
+
+ /**
+ * Render a variable
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function renderVar($name, $value)
+ {
+ if ($this->object !== null && $this->level === 0) {
+ list($name, $value, $group) = call_user_func($this->hookApplier, $name, $value);
+ if ($group !== null) {
+ $this->groups[$group][] = [$name, $value];
+ return;
+ }
+ }
+
+ $isArray = is_array($value);
+ switch (true) {
+ case $isArray && is_int(key($value)):
+ $this->renderArray($name, $value);
+ break;
+ case $isArray:
+ $this->renderObject($name, $value);
+ break;
+ default:
+ $this->renderScalar($name, $value);
+ }
+ }
+
+ /**
+ * Render an array
+ *
+ * @param string $name
+ * @param array $array
+ *
+ * @return void
+ */
+ protected function renderArray($name, array $array)
+ {
+ $numItems = count($array);
+ $name = (new HtmlDocument())->addHtml(
+ Html::wantHtml($name),
+ Text::create(' (Array)')
+ );
+
+ $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems));
+
+ ++$this->level;
+
+ ksort($array);
+ foreach ($array as $key => $value) {
+ $this->renderVar("[$key]", $value);
+ }
+
+ --$this->level;
+ }
+
+ /**
+ * Render an object (associative array)
+ *
+ * @param mixed $name
+ * @param array $object
+ *
+ * @return void
+ */
+ protected function renderObject($name, array $object)
+ {
+ $numItems = count($object);
+ $this->addRow($name, sprintf(tp('%d item', '%d items', $numItems), $numItems));
+
+ ++$this->level;
+
+ ksort($object);
+ foreach ($object as $key => $value) {
+ $this->renderVar($key, $value);
+ }
+
+ --$this->level;
+ }
+
+ /**
+ * Render a scalar
+ *
+ * @param mixed $name
+ * @param mixed $value
+ *
+ * @return void
+ */
+ protected function renderScalar($name, $value)
+ {
+ if ($value === '') {
+ $value = new EmptyState(t('empty string'));
+ }
+
+ $this->addRow($name, $value);
+ }
+
+ /**
+ * Render a group
+ *
+ * @param string $name
+ * @param iterable $entries
+ *
+ * @return void
+ */
+ protected function renderGroup(string $name, $entries)
+ {
+ $table = new self($entries);
+
+ $wrapper = $this->getWrapper();
+ if ($wrapper === null) {
+ $wrapper = new HtmlDocument();
+ $wrapper->addHtml($this);
+ $this->prependWrapper($wrapper);
+ }
+
+ $wrapper->addHtml($table->setHeader($name));
+ }
+
+ protected function assemble()
+ {
+ if ($this->object !== null) {
+ $this->hookApplier = CustomVarRendererHook::prepareForObject($this->object);
+ }
+
+ if ($this->headerTitle !== null) {
+ $this->getAttributes()
+ ->add('class', 'collapsible')
+ ->add('data-visible-height', 100)
+ ->add('data-toggle-element', 'thead')
+ ->add(
+ 'id',
+ preg_replace('/\s+/', '-', strtolower($this->headerTitle)) . '-customvars'
+ );
+
+ $this->addHtml(new HtmlElement('thead', null, new HtmlElement(
+ 'tr',
+ null,
+ new HtmlElement(
+ 'th',
+ Attributes::create(['colspan' => 2]),
+ new HtmlElement(
+ 'span',
+ null,
+ new Icon('angle-right'),
+ new Icon('angle-down')
+ ),
+ Text::create($this->headerTitle)
+ )
+ )));
+ }
+
+ if (is_array($this->data)) {
+ ksort($this->data);
+ }
+
+ foreach ($this->data as $name => $value) {
+ $this->renderVar($name, $value);
+ }
+
+ $this->addHtml($this->body);
+
+ // Hooks can return objects as replacement for keys, hence a generator is needed for group entries
+ $genGenerator = function ($entries) {
+ foreach ($entries as list($key, $value)) {
+ yield $key => $value;
+ }
+ };
+
+ foreach ($this->groups as $group => $entries) {
+ $this->renderGroup($group, $genGenerator($entries));
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/DowntimeCard.php b/library/Icingadb/Widget/Detail/DowntimeCard.php
new file mode 100644
index 0000000..68cc922
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/DowntimeCard.php
@@ -0,0 +1,336 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Model\Downtime;
+use ipl\Web\Widget\TimeAgo;
+use ipl\Web\Widget\TimeUntil;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+
+class DowntimeCard extends BaseHtmlElement
+{
+ protected $downtime;
+
+ protected $duration;
+
+ protected $defaultAttributes = ['class' => 'progress-bar downtime-progress'];
+
+ protected $tag = 'div';
+
+ public function __construct(Downtime $downtime)
+ {
+ $this->downtime = $downtime;
+
+ $this->start = $this->downtime->scheduled_start_time;
+ $this->end = $this->downtime->scheduled_end_time;
+
+ if ($this->downtime->end_time > $this->downtime->scheduled_end_time) {
+ $this->duration = $this->downtime->end_time - $this->downtime->scheduled_start_time;
+ } else {
+ $this->duration = $this->downtime->scheduled_end_time - $this->downtime->scheduled_start_time;
+ }
+ }
+
+ protected function assemble()
+ {
+ $timeline = Html::tag('div', ['class' => 'downtime-timeline timeline']);
+ $hPadding = 10;
+
+ $above = Html::tag('ul', ['class' => 'above']);
+ $below = Html::tag('ul', ['class' => 'below']);
+
+ $flexProgress = null;
+ $markerFlexStart = null;
+ $markerFlexEnd = null;
+
+ if ($this->downtime->scheduled_end_time < time()) {
+ $endTime = new TimeAgo($this->downtime->scheduled_end_time);
+ } else {
+ $endTime = new TimeUntil($this->downtime->scheduled_end_time);
+ }
+
+ if ($this->downtime->is_flexible && $this->downtime->is_in_effect) {
+ $this->addAttributes(['class' => 'flexible in-effect']);
+
+ $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->downtime->start_time);
+ $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->downtime->end_time);
+
+ $evade = false;
+ if ($flexEndLeft - $flexStartLeft < 2) {
+ $flexStartLeft -= 1;
+ $flexEndLeft += 1;
+
+ if ($flexEndLeft > $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)) {
+ $flexEndLeft = $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time) - .5;
+ $flexStartLeft = $flexEndLeft - 2;
+ }
+
+ if ($flexStartLeft < $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time)) {
+ $flexStartLeft = $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time) + .5;
+ $flexEndLeft = $flexStartLeft + 2;
+ }
+
+ $evade = true;
+ }
+
+ $markerFlexStart = Html::tag('div', [
+ 'class' => 'marker flex-start',
+ 'style' => sprintf('left: %F%%', $flexStartLeft)
+ ]);
+
+ $markerFlexEnd = Html::tag('div', [
+ 'class' => 'marker flex-end',
+ 'style' => sprintf('left: %F%%', $flexEndLeft)
+ ]);
+
+ if (time() > $this->downtime->scheduled_end_time) {
+ $timelineProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-elapsed',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $this->calcRelativeLeft($this->downtime->start_time),
+ $this->calcRelativeLeft($this->downtime->scheduled_end_time, $this->downtime->start_time)
+ )
+ ]);
+ $flexProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-overrun',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time),
+ $this->calcRelativeLeft(time(), $this->downtime->scheduled_end_time)
+ )
+ ]);
+ } else {
+ $timelineProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-elapsed',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $flexStartLeft,
+ $hPadding + $this->calcRelativeLeft(time()) - $flexStartLeft
+ )
+ ]);
+ }
+
+ $above->add([
+ Html::tag(
+ 'li',
+ ['class' => 'start positioned'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ new VerticalKeyValue(t('Scheduled Start'), new TimeAgo($this->downtime->scheduled_start_time))
+ )
+ ),
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'end positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)
+ )
+ ],
+ Html::tag('div', ['class' => 'bubble'], new VerticalKeyValue(t('Scheduled End'), $endTime))
+ )
+ ]);
+
+ $below->add([
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'start positioned',
+ 'style' => sprintf('left: %F%%', $flexStartLeft)
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards' . ($evade ? ' left' : '')],
+ new VerticalKeyValue(t('Start'), new TimeAgo($this->downtime->start_time))
+ )
+ ),
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'end positioned',
+ 'style' => sprintf('left: %F%%', $flexEndLeft)
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards' . ($evade ? ' right' : '')],
+ new VerticalKeyValue(t('End'), new TimeUntil($this->downtime->end_time))
+ )
+ )
+ ]);
+ } elseif ($this->downtime->is_flexible) {
+ $this->addAttributes(['class' => 'flexible']);
+
+ $timelineProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-elapsed',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time),
+ $this->calcRelativeLeft(time())
+ )
+ ]);
+
+ $above->add([
+ Html::tag(
+ 'li',
+ ['class' => 'start positioned'],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ new VerticalKeyValue(
+ t('Scheduled Start'),
+ time() > $this->downtime->scheduled_start_time
+ ? new TimeAgo($this->downtime->scheduled_start_time)
+ : new TimeUntil($this->downtime->scheduled_start_time)
+ )
+ )
+ ),
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'end positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)
+ )
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ new VerticalKeyValue(t('Scheduled End'), $endTime)
+ )
+ )
+ ]);
+
+ $below = null;
+ } else {
+ $timelineProgress = Html::tag('div', [
+ 'class' => 'timeline-overlay downtime-elapsed',
+ 'style' => sprintf(
+ 'left: %F%%; width: %F%%;',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time),
+ $this->calcRelativeLeft(time())
+ )
+ ]);
+
+ $below->add([
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'start positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time)
+ )
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('Start'), new TimeAgo($this->downtime->scheduled_start_time))
+ )
+ ),
+ Html::tag(
+ 'li',
+ [
+ 'class' => 'end positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)
+ )
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble upwards'],
+ new VerticalKeyValue(t('End'), new TimeUntil($this->downtime->scheduled_end_time))
+ )
+ )
+ ]);
+ }
+
+ $now = Html::tag(
+ 'li',
+ [
+ 'class' => 'now positioned',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft(time(), null, null, -$hPadding + 3)
+ )
+ ],
+ Html::tag(
+ 'div',
+ ['class' => 'bubble'],
+ Html::tag('strong', t('Now'))
+ )
+ );
+ $above->add($now);
+
+ $markerStart = Html::tag('div', [
+ 'class' => 'marker start',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_start_time)
+ )
+ ]);
+
+ $markerNow = Html::tag('div', [
+ 'class' => 'marker now',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft(time(), null, null, -$hPadding + 3)
+ )
+ ]);
+
+ $markerEnd = Html::tag('div', [
+ 'class' => 'marker end',
+ 'style' => sprintf(
+ 'left: %F%%',
+ $hPadding + $this->calcRelativeLeft($this->downtime->scheduled_end_time)
+ )
+ ]);
+
+ $timeline->add([
+ $timelineProgress,
+ $flexProgress,
+ $markerStart,
+ $markerEnd,
+ $markerFlexStart,
+ $markerFlexEnd,
+ $markerNow,
+ ]);
+
+ $this->add([
+ $above,
+ $timeline,
+ $below
+ ]);
+ }
+
+ protected function calcRelativeLeft($value, $relativeStart = null, $relativeWidth = null, $min = null, $max = null)
+ {
+ if ($relativeStart === null) {
+ $relativeStart = $this->downtime->scheduled_start_time;
+ }
+
+ if ($relativeWidth === null) {
+ $relativeWidth = $this->duration;
+ }
+
+ $left = round(($value - $relativeStart) / $relativeWidth * 80, 2);
+
+ if ($min !== null && $left < $min) {
+ $left = $min;
+ }
+
+ if ($max !== null && $left > $max) {
+ $left = $max;
+ }
+
+ return $left;
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/DowntimeDetail.php b/library/Icingadb/Widget/Detail/DowntimeDetail.php
new file mode 100644
index 0000000..fbeb069
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/DowntimeDetail.php
@@ -0,0 +1,206 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Date\DateFormatter as WebDateFormatter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Forms\Command\Object\DeleteDowntimeForm;
+use Icinga\Module\Icingadb\Model\Downtime;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Stdlib\Filter;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBall;
+
+class DowntimeDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+ use HostLink;
+ use ServiceLink;
+
+ /** @var BaseHtmlElement */
+ protected $control;
+
+ /** @var Downtime */
+ protected $downtime;
+
+ protected $defaultAttributes = ['class' => ['object-detail', 'downtime-detail']];
+
+ protected $tag = 'div';
+
+ public function __construct(Downtime $downtime)
+ {
+ $this->downtime = $downtime;
+ }
+
+ protected function createCancelDowntimeForm()
+ {
+ $action = Links::downtimesDelete();
+ $action->setQueryString(QueryString::render(Filter::equal('name', $this->downtime->name)));
+
+ return (new DeleteDowntimeForm())
+ ->setObjects([$this->downtime])
+ ->populate(['redirect' => '__BACK__'])
+ ->setAction($action->getAbsoluteUrl());
+ }
+
+ protected function createTimeline(): DowntimeCard
+ {
+ return new DowntimeCard($this->downtime);
+ }
+
+ protected function assemble()
+ {
+ $this->add(Html::tag('h2', t('Comment')));
+ $this->add(Html::tag('div', [
+ new Icon('user'),
+ Html::sprintf(
+ t('%s commented: %s', '<username> ..: <comment>'),
+ $this->downtime->author,
+ new MarkdownText($this->downtime->comment)
+ )
+ ]));
+
+ $this->add(Html::tag('h2', t('Details')));
+
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->addHtml(new HorizontalKeyValue(
+ t('Type'),
+ $this->downtime->is_flexible ? t('Flexible') : t('Fixed')
+ ));
+ if ($this->downtime->object_type === 'host') {
+ $this->addHtml(new HorizontalKeyValue(t('Host'), [
+ $this->downtime->host->name,
+ ' ',
+ new StateBall($this->downtime->host->state->getStateText())
+ ]));
+ } else {
+ $this->addHtml(new HorizontalKeyValue(t('Service'), Html::sprintf(
+ t('%s on %s', '<service> on <host>'),
+ [
+ $this->downtime->service->name,
+ ' ',
+ new StateBall($this->downtime->service->state->getStateText())
+ ],
+ $this->downtime->host->name
+ )));
+ }
+ }
+
+ if ($this->downtime->triggered_by_id !== null || $this->downtime->parent_id !== null) {
+ if ($this->downtime->triggered_by_id !== null) {
+ $label = t('Triggered By');
+ $relatedDowntime = $this->downtime->triggered_by;
+ } else {
+ $label = t('Parent');
+ $relatedDowntime = $this->downtime->parent;
+ }
+
+ $this->addHtml(new HorizontalKeyValue(
+ $label,
+ HtmlElement::create('span', ['class' => 'accompanying-text'], TemplateString::create(
+ $relatedDowntime->is_flexible
+ ? t('{{#link}}Flexible Downtime{{/link}} for %s')
+ : t('{{#link}}Fixed Downtime{{/link}} for %s'),
+ ['link' => new Link(null, Links::downtime($relatedDowntime), ['class' => 'subject'])],
+ ($relatedDowntime->object_type === 'host'
+ ? $this->createHostLink($relatedDowntime->host, true)
+ : $this->createServiceLink($relatedDowntime->service, $relatedDowntime->host, true))
+ ))
+ ));
+ }
+
+ $this->add(new HorizontalKeyValue(
+ t('Created'),
+ WebDateFormatter::formatDateTime($this->downtime->entry_time)
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Start time'),
+ $this->downtime->start_time
+ ? WebDateFormatter::formatDateTime($this->downtime->start_time)
+ : new EmptyState(t('Not started yet'))
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('End time'),
+ $this->downtime->end_time
+ ? WebDateFormatter::formatDateTime($this->downtime->end_time)
+ : new EmptyState(t('Not started yet'))
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled Start'),
+ WebDateFormatter::formatDateTime($this->downtime->scheduled_start_time)
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled End'),
+ WebDateFormatter::formatDateTime($this->downtime->scheduled_end_time)
+ ));
+ $this->add(new HorizontalKeyValue(
+ t('Scheduled Duration'),
+ DateFormatter::formatDuration($this->downtime->scheduled_duration)
+ ));
+ if ($this->downtime->is_flexible) {
+ $this->add(new HorizontalKeyValue(
+ t('Flexible Duration'),
+ DateFormatter::formatDuration($this->downtime->flexible_duration)
+ ));
+ }
+
+ $query = Downtime::on($this->getDb())->with([
+ 'host',
+ 'host.state',
+ 'service',
+ 'service.host',
+ 'service.host.state',
+ 'service.state'
+ ])
+ ->limit(3)
+ ->filter(Filter::equal('parent_id', $this->downtime->id))
+ ->orFilter(Filter::equal('triggered_by_id', $this->downtime->id));
+ $this->applyRestrictions($query);
+
+ $children = $query->peekAhead()->execute();
+ if ($children->hasResult()) {
+ $this->addHtml(
+ new HtmlElement('h2', null, Text::create(t('Children'))),
+ new DowntimeList($children),
+ (new ShowMore($children, Links::downtimes()->setQueryString(
+ QueryString::render(Filter::any(
+ Filter::equal('downtime.parent.name', $this->downtime->name),
+ Filter::equal('downtime.triggered_by.name', $this->downtime->name)
+ ))
+ )))->setBaseTarget('_next')
+ );
+ }
+
+ $this->add(Html::tag('h2', t('Progress')));
+ $this->add($this->createTimeline());
+
+ if (
+ getenv('ICINGAWEB_EXPORT_FORMAT') !== 'pdf'
+ && $this->isGrantedOn(
+ 'icingadb/command/downtime/delete',
+ $this->downtime->{$this->downtime->object_type}
+ )
+ ) {
+ $this->add($this->createCancelDowntimeForm());
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/EventDetail.php b/library/Icingadb/Widget/Detail/EventDetail.php
new file mode 100644
index 0000000..d29a8a1
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/EventDetail.php
@@ -0,0 +1,612 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use DateTime;
+use DateTimeZone;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\HostLink;
+use Icinga\Module\Icingadb\Common\HostStates;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\TicketLinks;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Common\ServiceLink;
+use Icinga\Module\Icingadb\Common\ServiceStates;
+use Icinga\Module\Icingadb\Model\AcknowledgementHistory;
+use Icinga\Module\Icingadb\Model\CommentHistory;
+use Icinga\Module\Icingadb\Model\DowntimeHistory;
+use Icinga\Module\Icingadb\Model\FlappingHistory;
+use Icinga\Module\Icingadb\Model\History;
+use Icinga\Module\Icingadb\Model\NotificationHistory;
+use Icinga\Module\Icingadb\Model\StateHistory;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemList\UserList;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\FormattedString;
+use ipl\Html\HtmlElement;
+use ipl\Html\TemplateString;
+use ipl\Html\Text;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Str;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBall;
+
+class EventDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+ use HostLink;
+ use ServiceLink;
+ use TicketLinks;
+
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'object-detail'];
+
+ /** @var History */
+ protected $event;
+
+ public function __construct(History $event)
+ {
+ $this->event = $event;
+ }
+
+ protected function assembleNotificationEvent(NotificationHistory $notification)
+ {
+ $pluginOutput = [];
+
+ $commandName = $notification->object_type === 'host'
+ ? $this->event->host->checkcommand_name
+ : $this->event->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($notification->text)) {
+ $notificationText = new EmptyState(t('Output unavailable.'));
+ } else {
+ $notificationText = new PluginOutputContainer(
+ (new PluginOutput($notification->text))
+ ->setCommandName($notification->object_type === 'host'
+ ? $this->event->host->checkcommand_name
+ : $this->event->service->checkcommand_name)
+ );
+ }
+
+ $pluginOutput = [
+ HtmlElement::create('h2', null, $notification->author ? t('Comment') : t('Plugin Output')),
+ HtmlElement::create('div', [
+ 'id' => 'check-output-' . $commandName,
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100
+ ], $notificationText)
+ ];
+ } else {
+ $pluginOutput[] = new EmptyState(t('Waiting for Icinga DB to synchronize the config.'));
+ }
+
+ if ($notification->object_type === 'host') {
+ $objectKey = t('Host');
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(HostStates::text($notification->previous_hard_state), StateBall::SIZE_MEDIUM),
+ new StateBall(HostStates::text($notification->state), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ]);
+ } else {
+ $objectKey = t('Service');
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(ServiceStates::text($notification->previous_hard_state), StateBall::SIZE_MEDIUM),
+ new StateBall(ServiceStates::text($notification->state), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ]);
+ }
+
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ new HorizontalKeyValue(t('Sent On'), DateFormatter::formatDateTime($notification->send_time))
+ ];
+
+ if ($notification->author) {
+ $eventInfo[] = (new HorizontalKeyValue(t('Sent by'), [
+ new Icon('user'),
+ $notification->author
+ ]));
+ }
+
+ $eventInfo[] = new HorizontalKeyValue(t('Type'), ucfirst(Str::camel($notification->type)));
+ $eventInfo[] = new HorizontalKeyValue(t('State'), $notification->object_type === 'host'
+ ? ucfirst(HostStates::text($notification->state))
+ : ucfirst(ServiceStates::text($notification->state)));
+ $eventInfo[] = new HorizontalKeyValue($objectKey, $objectInfo);
+
+
+ $notifiedUsers = [new HtmlElement('h2', null, Text::create(t('Notified Users')))];
+
+ if ($notification->users_notified === 0) {
+ $notifiedUsers[] = new EmptyState(t('None', 'notified users: none'));
+ } elseif (! $this->isPermittedRoute('users')) {
+ $notifiedUsers[] = Text::create(sprintf(tp(
+ 'This notification was sent to a single user',
+ 'This notification was sent to %d users',
+ $notification->users_notified
+ ), $notification->users_notified));
+ } elseif ($notification->users_notified > 0) {
+ $users = $notification->user
+ ->limit(5)
+ ->peekAhead();
+
+ $users = $users->execute();
+ /** @var ResultSet $users */
+
+ $notifiedUsers[] = new UserList($users);
+ $notifiedUsers[] = (new ShowMore(
+ $users,
+ Links::users()->addParams(['notification_history.id' => bin2hex($notification->id)]),
+ sprintf(t('Show all %d recipients'), $notification->users_notified)
+ ))->setBaseTarget('_next');
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $pluginOutput,
+ 200 => $eventInfo,
+ 500 => $notifiedUsers
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleStateChangeEvent(StateHistory $stateChange)
+ {
+ $pluginOutput = [];
+
+ $commandName = $stateChange->object_type === 'host'
+ ? $this->event->host->checkcommand_name
+ : $this->event->service->checkcommand_name;
+ if (isset($commandName)) {
+ if (empty($stateChange->output) && empty($stateChange->long_output)) {
+ $commandOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $commandOutput = new PluginOutputContainer(
+ (new PluginOutput($stateChange->output . "\n" . $stateChange->long_output))
+ ->setCommandName($commandName)
+ );
+ }
+
+ $pluginOutput = [
+ new HtmlElement('h2', null, Text::create(t('Plugin Output'))),
+ HtmlElement::create('div', [
+ 'id' => 'check-output-' . $commandName,
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100
+ ], $commandOutput)
+ ];
+ } else {
+ $pluginOutput[] = new EmptyState(t('Waiting for Icinga DB to synchronize the config.'));
+ }
+
+ if ($stateChange->object_type === 'host') {
+ $objectKey = t('Host');
+ $objectState = $stateChange->state_type === 'hard' ? $stateChange->hard_state : $stateChange->soft_state;
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(HostStates::text($stateChange->previous_soft_state), StateBall::SIZE_MEDIUM),
+ new StateBall(HostStates::text($objectState), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ]);
+ } else {
+ $objectKey = t('Service');
+ $objectState = $stateChange->state_type === 'hard' ? $stateChange->hard_state : $stateChange->soft_state;
+ $objectInfo = HtmlElement::create('span', ['class' => 'accompanying-text'], [
+ HtmlElement::create('span', ['class' => 'state-change'], [
+ new StateBall(ServiceStates::text($stateChange->previous_soft_state), StateBall::SIZE_MEDIUM),
+ new StateBall(ServiceStates::text($objectState), StateBall::SIZE_MEDIUM)
+ ]),
+ ' ',
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ]);
+ }
+
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ new HorizontalKeyValue(t('Occurred On'), DateFormatter::formatDateTime($stateChange->event_time)),
+ new HorizontalKeyValue(t('Scheduling Source'), $stateChange->scheduling_source),
+ new HorizontalKeyValue(t('Check Source'), $stateChange->check_source)
+ ];
+
+ if ($stateChange->state_type === 'soft') {
+ $eventInfo[] = new HorizontalKeyValue(t('Check Attempt'), sprintf(
+ t('%d of %d'),
+ $stateChange->check_attempt,
+ $stateChange->max_check_attempts
+ ));
+ }
+
+ $eventInfo[] = new HorizontalKeyValue(
+ t('State'),
+ $stateChange->object_type === 'host'
+ ? ucfirst(HostStates::text($objectState))
+ : ucfirst(ServiceStates::text($objectState))
+ );
+
+ $eventInfo[] = new HorizontalKeyValue(
+ t('State Type'),
+ $stateChange->state_type === 'hard' ? t('Hard', 'state') : t('Soft', 'state')
+ );
+
+ $eventInfo[] = new HorizontalKeyValue($objectKey, $objectInfo);
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $pluginOutput,
+ 200 => $eventInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleDowntimeEvent(DowntimeHistory $downtime)
+ {
+ $commentInfo = [
+ new HtmlElement('h2', null, Text::create(t('Comment'))),
+ new MarkdownText($this->createTicketLinks($downtime->comment))
+ ];
+
+ $eventInfo = [new HtmlElement('h2', null, Text::create(t('Event Info')))];
+
+ if ($downtime->triggered_by_id !== null || $downtime->parent_id !== null) {
+ if ($downtime->triggered_by_id !== null) {
+ $label = t('Triggered By');
+ $relatedDowntime = $downtime->triggered_by;
+ } else {
+ $label = t('Parent');
+ $relatedDowntime = $downtime->parent;
+ }
+
+ $query = History::on($this->getDb())
+ ->columns('id')
+ ->filter(Filter::equal('event_type', 'downtime_start'))
+ ->filter(Filter::equal('history.downtime_history_id', $relatedDowntime->downtime_id));
+ $this->applyRestrictions($query);
+ if (($relatedEvent = $query->first()) !== null) {
+ /** @var History $relatedEvent */
+ $eventInfo[] = new HorizontalKeyValue(
+ $label,
+ HtmlElement::create('span', ['class' => 'accompanying-text'], TemplateString::create(
+ $relatedDowntime->is_flexible
+ ? t('{{#link}}Flexible Downtime{{/link}} for %s')
+ : t('{{#link}}Fixed Downtime{{/link}} for %s'),
+ ['link' => new Link(null, Links::event($relatedEvent), ['class' => 'subject'])],
+ ($relatedDowntime->object_type === 'host'
+ ? $this->createHostLink($relatedDowntime->host, true)
+ : $this->createServiceLink($relatedDowntime->service, $relatedDowntime->host, true))
+ ->addAttributes(['class' => 'subject'])
+ ))
+ );
+ }
+ }
+
+ $eventInfo[] = $downtime->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ));
+ $eventInfo[] = new HorizontalKeyValue(t('Entered On'), DateFormatter::formatDateTime($downtime->entry_time));
+ $eventInfo[] = new HorizontalKeyValue(t('Author'), [new Icon('user'), $downtime->author]);
+ // TODO: The following should be presented in a specific widget (maybe just like the downtime card)
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Triggered On'),
+ DateFormatter::formatDateTime($downtime->trigger_time)
+ );
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Scheduled Start'),
+ DateFormatter::formatDateTime($downtime->scheduled_start_time)
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Actual Start'), DateFormatter::formatDateTime($downtime->start_time));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Scheduled End'),
+ DateFormatter::formatDateTime($downtime->scheduled_end_time)
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Actual End'), DateFormatter::formatDateTime($downtime->end_time));
+
+ if ($downtime->is_flexible) {
+ $eventInfo[] = new HorizontalKeyValue(t('Flexible'), t('Yes'));
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Duration'),
+ DateFormatter::formatDuration($downtime->flexible_duration)
+ );
+ }
+
+ $cancelInfo = [];
+ if ($downtime->has_been_cancelled) {
+ $cancelInfo = [
+ new HtmlElement('h2', null, Text::create(t('This downtime has been cancelled'))),
+ new HorizontalKeyValue(t('Cancelled On'), DateFormatter::formatDateTime($downtime->cancel_time)),
+ new HorizontalKeyValue(t('Cancelled by'), [new Icon('user'), $downtime->cancelled_by])
+ ];
+ }
+
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $commentInfo,
+ 201 => $eventInfo,
+ 600 => $cancelInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleCommentEvent(CommentHistory $comment)
+ {
+ $commentInfo = [
+ new HtmlElement('h2', null, Text::create(t('Comment'))),
+ new MarkdownText($this->createTicketLinks($comment->comment))
+ ];
+
+ $eventInfo = [new HtmlElement('h2', null, Text::create(t('Event Info')))];
+ $eventInfo[] = $comment->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ));
+ $eventInfo[] = new HorizontalKeyValue(t('Entered On'), DateFormatter::formatDateTime($comment->entry_time));
+ $eventInfo[] = new HorizontalKeyValue(t('Author'), [new Icon('user'), $comment->author]);
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Expires On'),
+ $comment->expire_time
+ ? DateFormatter::formatDateTime($comment->expire_time)
+ : new EmptyState(t('Never'))
+ );
+
+ $tiedToAckInfo = [];
+ if ($comment->entry_type === 'ack') {
+ $tiedToAckInfo = [
+ new HtmlElement('h2', null, Text::create(t('This comment is tied to an acknowledgement'))),
+ new HorizontalKeyValue(t('Sticky'), $comment->is_sticky ? t('Yes') : t('No')),
+ new HorizontalKeyValue(t('Persistent'), $comment->is_persistent ? t('Yes') : t('No'))
+ ];
+ }
+
+ $removedInfo = [];
+ if ($comment->has_been_removed) {
+ $removedInfo[] = new HtmlElement('h2', null, Text::create(t('This comment has been removed')));
+ if ($comment->removed_by) {
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Removed On'),
+ DateFormatter::formatDateTime($comment->remove_time)
+ );
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Removed by'),
+ [new Icon('user'), $comment->removed_by]
+ );
+ } else {
+ $removedInfo[] = new HorizontalKeyValue(
+ t('Expired On'),
+ DateFormatter::formatDateTime($comment->remove_time)
+ );
+ }
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $commentInfo,
+ 201 => $eventInfo,
+ 500 => $tiedToAckInfo,
+ 600 => $removedInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleFlappingEvent(FlappingHistory $flapping)
+ {
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ $flapping->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ )),
+ new HorizontalKeyValue(t('Started on'), DateFormatter::formatDateTime($flapping->start_time))
+ ];
+ if ($this->event->event_type === 'flapping_start') {
+ $eventInfo[] = new HorizontalKeyValue(t('Reason'), sprintf(
+ t('State change rate of %.2f%% exceeded the threshold (%.2f%%)'),
+ $flapping->percent_state_change_start,
+ $flapping->flapping_threshold_high
+ ));
+ } else {
+ $eventInfo[] = new HorizontalKeyValue(t('Ended on'), DateFormatter::formatDateTime($flapping->end_time));
+ $eventInfo[] = new HorizontalKeyValue(t('Reason'), sprintf(
+ t('State change rate of %.2f%% undercut the threshold (%.2f%%)'),
+ $flapping->percent_state_change_end,
+ $flapping->flapping_threshold_low
+ ));
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $eventInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function assembleAcknowledgeEvent(AcknowledgementHistory $acknowledgement)
+ {
+ $commentInfo = [];
+ if ($acknowledgement->comment) {
+ $commentInfo = [
+ new HtmlElement('h2', null, Text::create(t('Comment'))),
+ new MarkdownText($this->createTicketLinks($acknowledgement->comment))
+ ];
+ } elseif (! isset($acknowledgement->author)) {
+ $commentInfo[] = new EmptyState(t('This acknowledgement was set before Icinga DB history recording'));
+ }
+
+ $eventInfo = [
+ new HtmlElement('h2', null, Text::create(t('Event Info'))),
+ new HorizontalKeyValue(t('Set on'), DateFormatter::formatDateTime($acknowledgement->set_time)),
+ new HorizontalKeyValue(t('Author'), $acknowledgement->author
+ ? [new Icon('user'), $acknowledgement->author]
+ : new EmptyState(t('n. a.'))),
+ $acknowledgement->object_type === 'host'
+ ? new HorizontalKeyValue(t('Host'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ ))
+ : new HorizontalKeyValue(t('Service'), HtmlElement::create(
+ 'span',
+ ['class' => 'accompanying-text'],
+ FormattedString::create(
+ t('%s on %s', '<service> on <host>'),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->service->display_name),
+ HtmlElement::create('span', ['class' => 'subject'], $this->event->host->display_name)
+ )
+ ))
+ ];
+
+ if ($this->event->event_type === 'ack_set') {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Expires On'),
+ $acknowledgement->expire_time
+ ? DateFormatter::formatDateTime($acknowledgement->expire_time)
+ : new EmptyState(t('Never'))
+ );
+ $eventInfo[] = new HorizontalKeyValue(t('Sticky'), isset($acknowledgement->is_sticky)
+ ? ($acknowledgement->is_sticky ? t('Yes') : t('No'))
+ : new EmptyState(t('n. a.')));
+ $eventInfo[] = new HorizontalKeyValue(t('Persistent'), isset($acknowledgement->is_persistent)
+ ? ($acknowledgement->is_persistent ? t('Yes') : t('No'))
+ : new EmptyState(t('n. a.')));
+ } else {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Cleared on'),
+ DateFormatter::formatDateTime($acknowledgement->clear_time ?: $this->event->event_time)
+ );
+ if ($acknowledgement->cleared_by) {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Cleared by'),
+ [new Icon('user', $acknowledgement->cleared_by)]
+ );
+ } else {
+ $expired = false;
+ if ($acknowledgement->expire_time) {
+ $now = (new DateTime())->setTimezone(new DateTimeZone('UTC'));
+ $expiresOn = clone $now;
+ $expiresOn->setTimestamp($acknowledgement->expire_time);
+ if ($now <= $expiresOn) {
+ $expired = true;
+ $eventInfo[] = new HorizontalKeyValue(t('Removal Reason'), t(
+ 'The acknowledgement expired on %s',
+ DateFormatter::formatDateTime($acknowledgement->expire_time)
+ ));
+ }
+ }
+
+ if (! $expired) {
+ if ($acknowledgement->is_sticky) {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Reason'),
+ $acknowledgement->object_type === 'host'
+ ? t('Host recovered')
+ : t('Service recovered')
+ );
+ } else {
+ $eventInfo[] = new HorizontalKeyValue(
+ t('Reason'),
+ $acknowledgement->object_type === 'host'
+ ? t('Host recovered') // Hosts have no other state between UP and DOWN
+ : t('Service changed its state')
+ );
+ }
+ }
+ }
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $commentInfo,
+ 201 => $eventInfo
+ ], $this->createExtensions()));
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectDetailExtensionHook::loadExtensions($this->event);
+ }
+
+ protected function assemble()
+ {
+ switch ($this->event->event_type) {
+ case 'notification':
+ $this->assembleNotificationEvent($this->event->notification);
+
+ break;
+ case 'state_change':
+ $this->assembleStateChangeEvent($this->event->state);
+
+ break;
+ case 'downtime_start':
+ case 'downtime_end':
+ $this->assembleDowntimeEvent($this->event->downtime);
+
+ break;
+ case 'comment_add':
+ case 'comment_remove':
+ $this->assembleCommentEvent($this->event->comment);
+
+ break;
+ case 'flapping_start':
+ case 'flapping_end':
+ $this->assembleFlappingEvent($this->event->flapping);
+
+ break;
+ case 'ack_set':
+ case 'ack_clear':
+ $this->assembleAcknowledgeEvent($this->event->acknowledgement);
+
+ break;
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostDetail.php b/library/Icingadb/Widget/Detail/HostDetail.php
new file mode 100644
index 0000000..8757436
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostDetail.php
@@ -0,0 +1,58 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use ipl\Html\Html;
+use ipl\Stdlib\Filter;
+
+class HostDetail extends ObjectDetail
+{
+ protected $serviceSummary;
+
+ public function __construct(Host $object, ServicestateSummary $serviceSummary)
+ {
+ parent::__construct($object);
+
+ $this->serviceSummary = $serviceSummary;
+ }
+
+ protected function createServiceStatistics(): array
+ {
+ if ($this->serviceSummary->services_total > 0) {
+ $services = new ServiceStatistics($this->serviceSummary);
+ $services->setBaseFilter(Filter::equal('host.name', $this->object->name));
+ } else {
+ $services = new EmptyState(t('This host has no services'));
+ }
+
+ return [Html::tag('h2', t('Services')), $services];
+ }
+
+ protected function assemble()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->add($this->createPrintHeader());
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $this->createPluginOutput(),
+ 190 => $this->createServiceStatistics(),
+ 300 => $this->createActions(),
+ 301 => $this->createNotes(),
+ 400 => $this->createComments(),
+ 401 => $this->createDowntimes(),
+ 500 => $this->createGroups(),
+ 501 => $this->createNotifications(),
+ 600 => $this->createCheckStatistics(),
+ 601 => $this->createPerformanceData(),
+ 700 => $this->createCustomVars(),
+ 701 => $this->createFeatureToggles()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostInspectionDetail.php b/library/Icingadb/Widget/Detail/HostInspectionDetail.php
new file mode 100644
index 0000000..93b35b8
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostInspectionDetail.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\ObjectInspectionDetail;
+
+class HostInspectionDetail extends ObjectInspectionDetail
+{
+ protected function assemble()
+ {
+ $this->add([
+ $this->createSourceLocation(),
+ $this->createLastCheckResult(),
+ $this->createAttributes(),
+ $this->createCustomVariables(),
+ $this->createRedisInfo()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostMetaInfo.php b/library/Icingadb/Widget/Detail/HostMetaInfo.php
new file mode 100644
index 0000000..15e7da6
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostMetaInfo.php
@@ -0,0 +1,75 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use ipl\Web\Widget\HorizontalKeyValue;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Web\Widget\Icon;
+
+class HostMetaInfo extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'object-meta-info'];
+
+ /** @var Host */
+ protected $host;
+
+ public function __construct(Host $host)
+ {
+ $this->host = $host;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml(
+ new VerticalKeyValue('host.name', $this->host->name),
+ new HtmlElement(
+ 'div',
+ null,
+ new HorizontalKeyValue(
+ 'host.address',
+ $this->host->address ?: new EmptyState(t('None', 'address'))
+ ),
+ new HorizontalKeyValue(
+ 'host.address6',
+ $this->host->address6 ?: new EmptyState(t('None', 'address'))
+ )
+ ),
+ new VerticalKeyValue(
+ 'last_state_change',
+ DateFormatter::formatDateTime($this->host->state->last_state_change)
+ )
+ );
+
+ $collapsible = new HtmlElement('div', Attributes::create([
+ 'class' => 'collapsible',
+ 'id' => 'object-meta-info',
+ 'data-toggle-element' => '.object-meta-info-control',
+ 'data-visible-height' => 0
+ ]));
+
+ $renderHelper = new HtmlDocument();
+ $renderHelper->addHtml(
+ $this,
+ new HtmlElement(
+ 'button',
+ Attributes::create(['class' => 'object-meta-info-control']),
+ new Icon('angle-double-up', ['class' => 'collapse-icon']),
+ new Icon('angle-double-down', ['class' => 'expand-icon'])
+ )
+ );
+
+ $this->addWrapper($collapsible);
+ $this->addWrapper($renderHelper);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/HostStatistics.php b/library/Icingadb/Widget/Detail/HostStatistics.php
new file mode 100644
index 0000000..53423be
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/HostStatistics.php
@@ -0,0 +1,62 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\HostStateBadges;
+use ipl\Html\ValidHtml;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\HtmlString;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Link;
+
+class HostStatistics extends ObjectStatistics
+{
+ protected $summary;
+
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function createDonut(): ValidHtml
+ {
+ $donut = (new Donut())
+ ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending']);
+
+ return HtmlString::create($donut->render());
+ }
+
+ protected function createTotal(): ValidHtml
+ {
+ $url = Links::hosts();
+ if ($this->hasBaseFilter()) {
+ $url->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter())));
+ }
+
+ return new Link(
+ new VerticalKeyValue(
+ tp('Host', 'Hosts', $this->summary->hosts_total),
+ $this->summary->hosts_total
+ ),
+ $url
+ );
+ }
+
+ protected function createBadges(): ValidHtml
+ {
+ $badges = new HostStateBadges($this->summary);
+ if ($this->hasBaseFilter()) {
+ $badges->setBaseFilter($this->getBaseFilter());
+ }
+
+ return $badges;
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/MultiselectQuickActions.php b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php
new file mode 100644
index 0000000..b945cc0
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php
@@ -0,0 +1,194 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\BaseFilter;
+use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+use ipl\Web\Widget\Icon;
+
+class MultiselectQuickActions extends BaseHtmlElement
+{
+ use BaseFilter;
+ use Auth;
+
+ protected $summary;
+
+ protected $type;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'quick-actions'];
+
+ public function __construct($type, $summary)
+ {
+ $this->summary = $summary;
+ $this->type = $type;
+ }
+
+ protected function assemble()
+ {
+ $unacknowledged = "{$this->type}s_problems_unacknowledged";
+ $acks = "{$this->type}s_acknowledged";
+ $activeChecks = "{$this->type}s_active_checks_enabled";
+
+ if (
+ $this->summary->$unacknowledged > $this->summary->$acks
+ && $this->isGrantedOnType(
+ 'icingadb/command/acknowledge-problem',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'acknowledge',
+ t('Acknowledge'),
+ 'check-circle',
+ t('Acknowledge this problem, suppress all future notifications for it and tag it as being handled')
+ );
+ }
+
+ if (
+ $this->summary->$acks > 0
+ && $this->isGrantedOnType(
+ 'icingadb/command/remove-acknowledgement',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $removeAckForm = (new RemoveAcknowledgementForm())
+ ->setAction($this->getLink('removeAcknowledgement'))
+ ->setObjects(array_fill(0, $this->summary->$acks, null));
+
+ $this->add(Html::tag('li', $removeAckForm));
+ }
+
+ if (
+ $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false)
+ || (
+ $this->summary->$activeChecks > 0
+ && $this->isGrantedOnType(
+ 'icingadb/command/schedule-check/active-only',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ )
+ ) {
+ $this->add(Html::tag('li', (new CheckNowForm())->setAction($this->getLink('checkNow'))));
+ }
+
+ if ($this->isGrantedOnType('icingadb/command/comment/add', $this->type, $this->getBaseFilter(), false)) {
+ $this->assembleAction(
+ 'addComment',
+ t('Comment'),
+ 'comment',
+ t('Add a new comment')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType(
+ 'icingadb/command/send-custom-notification',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'sendCustomNotification',
+ t('Notification'),
+ 'bell',
+ t('Send a custom notification')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType(
+ 'icingadb/command/downtime/schedule',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'scheduleDowntime',
+ t('Downtime'),
+ 'plug',
+ t('Schedule a downtime to suppress all problem notifications within a specific period of time')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false)
+ || (
+ $this->summary->$activeChecks > 0
+ && $this->isGrantedOnType(
+ 'icingadb/command/schedule-check/active-only',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ )
+ ) {
+ $this->assembleAction(
+ 'scheduleCheck',
+ t('Reschedule'),
+ 'calendar',
+ t('Schedule the next active check at a different time than the current one')
+ );
+ }
+
+ if (
+ $this->isGrantedOnType(
+ 'icingadb/command/process-check-result',
+ $this->type,
+ $this->getBaseFilter(),
+ false
+ )
+ ) {
+ $this->assembleAction(
+ 'processCheckresult',
+ t('Process check result'),
+ 'edit',
+ t('Submit passive check result')
+ );
+ }
+ }
+
+ protected function assembleAction(string $action, string $label, string $icon, string $title)
+ {
+ $link = Html::tag(
+ 'a',
+ [
+ 'href' => $this->getLink($action),
+ 'class' => 'action-link',
+ 'title' => $title,
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ],
+ [
+ new Icon($icon),
+ $label
+ ]
+ );
+
+ $this->add(Html::tag('li', $link));
+ }
+
+ protected function getLink(string $action): string
+ {
+ return Url::fromPath("icingadb/{$this->type}s/$action")
+ ->setQueryString(QueryString::render($this->getBaseFilter()))
+ ->getAbsoluteUrl();
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ObjectDetail.php b/library/Icingadb/Widget/Detail/ObjectDetail.php
new file mode 100644
index 0000000..b8760ee
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectDetail.php
@@ -0,0 +1,585 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Exception;
+use Icinga\Application\ClassLoader;
+use Icinga\Application\Hook;
+use Icinga\Application\Hook\GrapherHook;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\HostLinks;
+use Icinga\Module\Icingadb\Common\Icons;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Common\Macros;
+use Icinga\Module\Icingadb\Compat\CompatHost;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use Icinga\Module\Icingadb\Web\Navigation\Action;
+use Icinga\Module\Icingadb\Widget\MarkdownText;
+use Icinga\Module\Icingadb\Common\ServiceLinks;
+use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
+use Icinga\Module\Icingadb\Hook\ActionsHook\ObjectActionsHook;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Util\PluginOutput;
+use Icinga\Module\Icingadb\Widget\ItemList\DowntimeList;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use Icinga\Module\Icingadb\Widget\StateChange;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemList\CommentList;
+use Icinga\Module\Icingadb\Widget\PluginOutputContainer;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use Icinga\Module\Icingadb\Widget\TagList;
+use Icinga\Module\Monitoring\Hook\DetailviewExtensionHook;
+use Icinga\Web\Navigation\Navigation;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Text;
+use ipl\Orm\ResultSet;
+use ipl\Stdlib\Filter;
+use ipl\Web\Widget\Icon;
+use ipl\Web\Widget\StateBall;
+
+class ObjectDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+ use Macros;
+
+ protected $object;
+
+ protected $compatObject;
+
+ protected $objectType;
+
+ protected $defaultAttributes = [
+ // Class host-detail is kept as the grafana module's iframe.js depends on it
+ 'class' => ['object-detail', 'host-detail'],
+ 'data-pdfexport-page-breaks-at' => 'h2'
+ ];
+
+ protected $tag = 'div';
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ $this->objectType = $object instanceof Host ? 'host' : 'service';
+ }
+
+ protected function compatObject()
+ {
+ if ($this->compatObject === null) {
+ $this->compatObject = CompatHost::fromModel($this->object);
+ }
+
+ return $this->compatObject;
+ }
+
+ protected function createPrintHeader()
+ {
+ $info = [new HorizontalKeyValue(t('Name'), $this->object->name)];
+
+ if ($this->objectType === 'host') {
+ $info[] = new HorizontalKeyValue(
+ t('IPv4 Address'),
+ $this->object->address ?: new EmptyState(t('None', 'address'))
+ );
+ $info[] = new HorizontalKeyValue(
+ t('IPv6 Address'),
+ $this->object->address6 ?: new EmptyState(t('None', 'address'))
+ );
+ }
+
+ $info[] = new HorizontalKeyValue(t('State'), [
+ $this->object->state->getStateTextTranslated(),
+ ' ',
+ new StateBall($this->object->state->getStateText())
+ ]);
+
+ $info[] = new HorizontalKeyValue(
+ t('Last State Change'),
+ DateFormatter::formatDateTime($this->object->state->last_state_change)
+ );
+
+ return [
+ new HtmlElement('h2', null, Text::create(
+ $this->objectType === 'host' ? t('Host') : t('Service')
+ )),
+ $info
+ ];
+ }
+
+ protected function createActions()
+ {
+ $this->fetchCustomVars();
+
+ $navigation = new Navigation();
+ $navigation->load('icingadb-' . $this->objectType . '-action');
+ /** @var Action $item */
+ foreach ($navigation as $item) {
+ $item->setObject($this->object);
+ }
+
+ foreach ($this->object->action_url->first()->action_url ?? [] as $url) {
+ $url = $this->expandMacros($url, $this->object);
+ $navigation->addItem(
+ Html::wantHtml([
+ // Add warning to links that open in new tabs, as recommended by WCAG20 G201
+ new Icon('external-link-alt', ['title' => t('Link opens in a new window')]),
+ $url
+ ])->render(),
+ [
+ 'target' => '_blank',
+ 'url' => $url,
+ 'renderer' => [
+ 'NavigationItemRenderer',
+ 'escape_label' => false
+ ]
+ ]
+ );
+ }
+
+ $moduleActions = ObjectActionsHook::loadActions($this->object);
+
+ $nativeExtensionProviders = [];
+ foreach ($moduleActions->getContent() as $item) {
+ if ($item->getAttributes()->has('data-icinga-module')) {
+ $nativeExtensionProviders[$item->getAttributes()->get('data-icinga-module')->getValue()] = true;
+ }
+ }
+
+ if (Icinga::app()->getModuleManager()->hasInstalled('monitoring')) {
+ foreach (Hook::all('Monitoring\\' . ucfirst($this->objectType) . 'Actions') as $hook) {
+ $moduleName = ClassLoader::extractModuleName(get_class($hook));
+ if (! isset($nativeExtensionProviders[$moduleName])) {
+ try {
+ $navigation->merge($hook->getNavigation($this->compatObject()));
+ } catch (Exception $e) {
+ Logger::error("Failed to load legacy action hook: %s\n%s", $e, $e->getTraceAsString());
+ $navigation->addItem($moduleName, ['label' => IcingaException::describe($e), 'url' => '#']);
+ }
+ }
+ }
+ }
+
+ if ($moduleActions->isEmpty() && ($navigation->isEmpty() || ! $navigation->hasRenderableItems())) {
+ return null;
+ }
+
+ return [
+ Html::tag('h2', t('Actions')),
+ new HtmlString($navigation->getRenderer()->setCssClass('object-detail-actions')->render()),
+ $moduleActions->isEmpty() ? null : $moduleActions
+ ];
+ }
+
+ protected function createCheckStatistics(): array
+ {
+ return [
+ Html::tag('h2', t('Check Statistics')),
+ new CheckStatistics($this->object)
+ ];
+ }
+
+ protected function createComments(): array
+ {
+ if ($this->objectType === 'host') {
+ $link = HostLinks::comments($this->object);
+ $relations = ['host', 'host.state'];
+ } else {
+ $link = ServiceLinks::comments($this->object, $this->object->host);
+ $relations = ['service', 'service.state', 'service.host', 'service.host.state'];
+ }
+
+ $comments = $this->object->comment
+ ->with($relations)
+ ->limit(3)
+ ->peekAhead();
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $comments->filter(Filter::equal('object_type', $this->objectType));
+
+ $comments = $comments->execute();
+ /** @var ResultSet $comments */
+
+ $content = [Html::tag('h2', t('Comments'))];
+
+ if ($comments->hasResult()) {
+ $content[] = (new CommentList($comments))->setObjectLinkDisabled()->setTicketLinkEnabled();
+ $content[] = (new ShowMore($comments, $link))->setBaseTarget('_next');
+ } else {
+ $content[] = new EmptyState(t('No comments created.'));
+ }
+
+ return $content;
+ }
+
+ protected function createCustomVars(): array
+ {
+ $content = [Html::tag('h2', t('Custom Variables'))];
+
+ $this->fetchCustomVars();
+ $vars = (new CustomvarFlat())->unFlattenVars($this->object->customvar_flat);
+ if (! empty($vars)) {
+ $content[] = new HtmlElement('div', Attributes::create([
+ 'id' => $this->objectType . '-customvars',
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200
+ ]), new CustomVarTable($vars, $this->object));
+ } else {
+ $content[] = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return $content;
+ }
+
+ protected function createDowntimes(): array
+ {
+ if ($this->objectType === 'host') {
+ $link = HostLinks::downtimes($this->object);
+ $relations = ['host', 'host.state'];
+ } else {
+ $link = ServiceLinks::downtimes($this->object, $this->object->host);
+ $relations = ['service', 'service.state', 'service.host', 'service.host.state'];
+ }
+
+ $downtimes = $this->object->downtime
+ ->with($relations)
+ ->limit(3)
+ ->peekAhead();
+ // TODO: This should be automatically done by the model/resolver and added as ON condition
+ $downtimes->filter(Filter::equal('object_type', $this->objectType));
+
+ $downtimes = $downtimes->execute();
+ /** @var ResultSet $downtimes */
+
+ $content = [Html::tag('h2', t('Downtimes'))];
+
+ if ($downtimes->hasResult()) {
+ $content[] = (new DowntimeList($downtimes))->setObjectLinkDisabled()->setTicketLinkEnabled();
+ $content[] = (new ShowMore($downtimes, $link))->setBaseTarget('_next');
+ } else {
+ $content[] = new EmptyState(t('No downtimes scheduled.'));
+ }
+
+ return $content;
+ }
+
+ protected function createGroups(): array
+ {
+ $groups = [Html::tag('h2', t('Groups'))];
+
+ if ($this->objectType === 'host') {
+ $hostgroups = [];
+ if ($this->isPermittedRoute('hostgroups')) {
+ $hostgroups = $this->object->hostgroup;
+ $this->applyRestrictions($hostgroups);
+ }
+
+ $hostgroupList = new TagList();
+ foreach ($hostgroups as $hostgroup) {
+ $hostgroupList->addLink($hostgroup->display_name, Links::hostgroup($hostgroup));
+ }
+
+ $groups[] = $hostgroupList->hasContent()
+ ? $hostgroupList
+ : new EmptyState(t('Not a member of any host group.'));
+ } else {
+ $servicegroups = [];
+ if ($this->isPermittedRoute('servicegroups')) {
+ $servicegroups = $this->object->servicegroup;
+ $this->applyRestrictions($servicegroups);
+ }
+
+ $servicegroupList = new TagList();
+ foreach ($servicegroups as $servicegroup) {
+ $servicegroupList->addLink($servicegroup->display_name, Links::servicegroup($servicegroup));
+ }
+
+ $groups[] = $servicegroupList->hasContent()
+ ? $servicegroupList
+ : new EmptyState(t('Not a member of any service group.'));
+ }
+
+ return $groups;
+ }
+
+ protected function createNotes()
+ {
+ $navigation = new Navigation();
+ $notes = trim($this->object->notes);
+
+ foreach ($this->object->notes_url->first()->notes_url ?? [] as $url) {
+ $url = $this->expandMacros($url, $this->object);
+ $navigation->addItem(
+ Html::wantHtml([
+ // Add warning to links that open in new tabs, as recommended by WCAG20 G201
+ new Icon('external-link-alt', ['title' => t('Link opens in a new window')]),
+ $url
+ ])->render(),
+ [
+ 'target' => '_blank',
+ 'url' => $url,
+ 'renderer' => [
+ 'NavigationItemRenderer',
+ 'escape_label' => false
+ ]
+ ]
+ );
+ }
+
+ $content = [];
+
+ if (! $navigation->isEmpty() && $navigation->hasRenderableItems()) {
+ $content[] = new HtmlString($navigation->getRenderer()->setCssClass('object-detail-actions')->render());
+ }
+
+ if ($notes !== '') {
+ $content[] = (new MarkdownText($notes))
+ ->addAttributes([
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200,
+ 'id' => $this->objectType . '-notes'
+ ]);
+ }
+
+ if (empty($content)) {
+ return null;
+ }
+
+ array_unshift($content, Html::tag('h2', t('Notes')));
+
+ return $content;
+ }
+
+ protected function createNotifications(): array
+ {
+ list($users, $usergroups) = $this->getUsersAndUsergroups();
+
+ $userList = new TagList();
+ $usergroupList = new TagList();
+
+ foreach ($users as $user) {
+ $userList->addLink([new Icon(Icons::USER), $user->display_name], Links::user($user));
+ }
+
+ foreach ($usergroups as $usergroup) {
+ $usergroupList->addLink(
+ [new Icon(Icons::USERGROUP), $usergroup->display_name],
+ Links::usergroup($usergroup)
+ );
+ }
+
+ return [
+ Html::tag('h2', t('Notifications')),
+ new HorizontalKeyValue(
+ t('Users'),
+ $userList->hasContent() ? $userList : new EmptyState(t('No users configured.'))
+ ),
+ new HorizontalKeyValue(
+ t('User Groups'),
+ $usergroupList->hasContent()
+ ? $usergroupList
+ : new EmptyState(t('No user groups configured.'))
+ )
+ ];
+ }
+
+ protected function createPerformanceData(): array
+ {
+ $content[] = Html::tag('h2', t('Performance Data'));
+
+ if (empty($this->object->state->performance_data)) {
+ $content[] = new EmptyState(t('No performance data available.'));
+ } else {
+ $content[] = new HtmlElement(
+ 'div',
+ Attributes::create(['id' => 'check-perfdata-' . $this->object->checkcommand_name]),
+ new PerfDataTable($this->object->state->normalized_performance_data)
+ );
+ }
+
+ return $content;
+ }
+
+ protected function createPluginOutput(): array
+ {
+ if (empty($this->object->state->output) && empty($this->object->state->long_output)) {
+ $pluginOutput = new EmptyState(t('Output unavailable.'));
+ } else {
+ $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->object));
+ }
+
+ return [
+ Html::tag('h2', t('Plugin Output')),
+ Html::tag(
+ 'div',
+ [
+ 'id' => 'check-output-' . $this->object->checkcommand_name,
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100
+ ],
+ $pluginOutput
+ )
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ $extensions = ObjectDetailExtensionHook::loadExtensions($this->object);
+
+ $nativeExtensionProviders = [];
+ foreach ($extensions as $extension) {
+ if ($extension instanceof BaseHtmlElement && $extension->getAttributes()->has('data-icinga-module')) {
+ $nativeExtensionProviders[$extension->getAttributes()->get('data-icinga-module')->getValue()] = true;
+ }
+ }
+
+ if (! Icinga::app()->getModuleManager()->hasInstalled('monitoring')) {
+ return $extensions;
+ }
+
+ foreach (Hook::all('Grapher') as $grapher) {
+ /** @var GrapherHook $grapher */
+ $moduleName = ClassLoader::extractModuleName(get_class($grapher));
+
+ if (isset($nativeExtensionProviders[$moduleName])) {
+ continue;
+ }
+
+ try {
+ $graph = HtmlString::create($grapher->getPreviewHtml($this->compatObject()));
+ } catch (Exception $e) {
+ Logger::error("Failed to load legacy grapher: %s\n%s", $e, $e->getTraceAsString());
+ $graph = Text::create(IcingaException::describe($e));
+ }
+
+ $location = ObjectDetailExtensionHook::BASE_LOCATIONS[ObjectDetailExtensionHook::GRAPH_SECTION];
+ while (isset($extensions[$location])) {
+ $location++;
+ }
+
+ $extensions[$location] = $graph;
+ }
+
+ foreach (Hook::all('Monitoring\DetailviewExtension') as $extension) {
+ /** @var DetailviewExtensionHook $extension */
+ $moduleName = $extension->getModule()->getName();
+
+ if (isset($nativeExtensionProviders[$moduleName])) {
+ continue;
+ }
+
+ try {
+ $renderedExtension = $extension
+ ->setView(Icinga::app()->getViewRenderer()->view)
+ ->getHtmlForObject($this->compatObject());
+
+ $extensionHtml = new HtmlElement(
+ 'div',
+ Attributes::create([
+ 'class' => 'icinga-module module-' . $moduleName,
+ 'data-icinga-module' => $moduleName
+ ]),
+ HtmlString::create($renderedExtension)
+ );
+ } catch (Exception $e) {
+ Logger::error("Failed to load legacy detail extension: %s\n%s", $e, $e->getTraceAsString());
+ $extensionHtml = Text::create(IcingaException::describe($e));
+ }
+
+ $location = ObjectDetailExtensionHook::BASE_LOCATIONS[ObjectDetailExtensionHook::DETAIL_SECTION];
+ while (isset($extensions[$location])) {
+ $location++;
+ }
+
+ $extensions[$location] = $extensionHtml;
+ }
+
+ return $extensions;
+ }
+
+ protected function createFeatureToggles(): array
+ {
+ $form = new ToggleObjectFeaturesForm($this->object);
+
+ if ($this->objectType === 'host') {
+ $form->setAction(HostLinks::toggleFeatures($this->object)->getAbsoluteUrl());
+ } else {
+ $form->setAction(ServiceLinks::toggleFeatures($this->object, $this->object->host)->getAbsoluteUrl());
+ }
+
+ return [
+ Html::tag('h2', t('Feature Commands')),
+ $form
+ ];
+ }
+
+ protected function getUsersAndUsergroups(): array
+ {
+ $users = [];
+ $usergroups = [];
+ $groupBy = false;
+
+ if ($this->objectType === 'host') {
+ $objectFilter = Filter::all(
+ Filter::equal('notification.host_id', $this->object->id),
+ Filter::unlike('notification.service_id', '*')
+ );
+ $objectFilter->metaData()->set('forceOptimization', false);
+ $groupBy = true;
+ } else {
+ $objectFilter = Filter::equal(
+ 'notification.service_id',
+ $this->object->id
+ );
+ }
+
+ if ($this->isPermittedRoute('users')) {
+ $userQuery = User::on($this->getDb());
+ $userQuery->filter($objectFilter);
+ $this->applyRestrictions($userQuery);
+ if ($groupBy) {
+ $userQuery->getSelectBase()->groupBy(['user.id']);
+ }
+
+ foreach ($userQuery as $user) {
+ $users[$user->name] = $user;
+ }
+ }
+
+ if ($this->isPermittedRoute('usergroups')) {
+ $usergroupQuery = Usergroup::on($this->getDb());
+ $usergroupQuery->filter($objectFilter);
+ $this->applyRestrictions($usergroupQuery);
+ if ($groupBy) {
+ $userQuery->getSelectBase()->groupBy(['usergroup.id']);
+ }
+
+ foreach ($usergroupQuery as $usergroup) {
+ $usergroups[$usergroup->name] = $usergroup;
+ }
+ }
+
+ return [$users, $usergroups];
+ }
+
+ protected function fetchCustomVars()
+ {
+ $customvarFlat = $this->object->customvar_flat;
+ if (! $customvarFlat instanceof ResultSet) {
+ $this->applyRestrictions($customvarFlat);
+ $this->object->customvar_flat = $customvarFlat->execute();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ObjectStatistics.php b/library/Icingadb/Widget/Detail/ObjectStatistics.php
new file mode 100644
index 0000000..2142c8b
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectStatistics.php
@@ -0,0 +1,34 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\BaseFilter;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+
+abstract class ObjectStatistics extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'object-statistics'];
+
+ abstract protected function createDonut(): ValidHtml;
+
+ abstract protected function createTotal(): ValidHtml;
+
+ abstract protected function createBadges(): ValidHtml;
+
+ protected function assemble()
+ {
+ $this->add([
+ Html::tag('li', ['class' => 'object-statistics-graph'], $this->createDonut()),
+ Html::tag('li', ['class' => ['object-statistics-total', 'text-center']], $this->createTotal()),
+ Html::tag('li', $this->createBadges())
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ObjectsDetail.php b/library/Icingadb/Widget/Detail/ObjectsDetail.php
new file mode 100644
index 0000000..f30823a
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ObjectsDetail.php
@@ -0,0 +1,192 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Module\Icingadb\Common\BaseFilter;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectsDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use Icinga\Module\Icingadb\Util\FeatureStatus;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use Icinga\Module\Icingadb\Widget\HostStateBadges;
+use Icinga\Module\Icingadb\Widget\ServiceStateBadges;
+use ipl\Html\HtmlElement;
+use ipl\Orm\Query;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\ActionLink;
+
+class ObjectsDetail extends BaseHtmlElement
+{
+ use BaseFilter;
+
+ protected $summary;
+
+ protected $query;
+
+ protected $type;
+
+ protected $defaultAttributes = ['class' => 'objects-detail'];
+
+ protected $tag = 'div';
+
+ /**
+ * Construct an object detail summary widget
+ *
+ * @param string $type
+ * @param HoststateSummary|ServicestateSummary $summary
+ * @param Query $query
+ */
+ public function __construct(string $type, $summary, Query $query)
+ {
+ $this->summary = $summary;
+ $this->query = $query;
+ $this->type = $type;
+ }
+
+ protected function createChart(): BaseHtmlElement
+ {
+ $content = Html::tag('div', ['class' => 'multiselect-summary']);
+
+ if ($this->type === 'host') {
+ $hostsChart = (new Donut())
+ ->addSlice($this->summary->hosts_up, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->hosts_down_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->hosts_down_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->hosts_pending, ['class' => 'slice-state-pending']);
+
+ $badges = (new HostStateBadges($this->summary))
+ ->setBaseFilter($this->getBaseFilter());
+
+ $content->add([
+ HtmlString::create($hostsChart->render()),
+ new VerticalKeyValue(
+ tp('Host', 'Hosts', $this->summary->hosts_total),
+ $this->summary->hosts_total
+ ),
+ $badges
+ ]);
+ } else {
+ $servicesChart = (new Donut())
+ ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled'])
+ ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning'])
+ ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled'])
+ ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown'])
+ ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending']);
+
+ $badges = (new ServiceStateBadges($this->summary))
+ ->setBaseFilter($this->getBaseFilter());
+
+ $content->add([
+ HtmlString::create($servicesChart->render()),
+ new VerticalKeyValue(
+ tp('Service', 'Services', $this->summary->services_total),
+ $this->summary->services_total
+ ),
+ $badges
+ ]);
+ }
+
+ return $content;
+ }
+
+ protected function createComments(): array
+ {
+ $content = [Html::tag('h2', t('Comments'))];
+
+ if ($this->summary->comments_total > 0) {
+ $content[] = new ActionLink(
+ sprintf(
+ tp('Show %d comment', 'Show %d comments', $this->summary->comments_total),
+ $this->summary->comments_total
+ ),
+ Links::comments()->setQueryString(QueryString::render($this->getBaseFilter()))
+ );
+ } else {
+ $content[] = new EmptyState(t('No comments created.'));
+ }
+
+ return $content;
+ }
+
+ protected function createDowntimes(): array
+ {
+ $content = [Html::tag('h2', t('Downtimes'))];
+
+ if ($this->summary->downtimes_total > 0) {
+ $content[] = new ActionLink(
+ sprintf(
+ tp('Show %d downtime', 'Show %d downtimes', $this->summary->downtimes_total),
+ $this->summary->downtimes_total
+ ),
+ Links::downtimes()->setQueryString(QueryString::render($this->getBaseFilter()))
+ );
+ } else {
+ $content[] = new EmptyState(t('No downtimes scheduled.'));
+ }
+
+ return $content;
+ }
+
+ protected function createFeatureToggles(): array
+ {
+ $form = new ToggleObjectFeaturesForm(new FeatureStatus($this->type, $this->summary));
+
+ if ($this->type === 'host') {
+ $form->setAction(
+ Links::toggleHostsFeatures()
+ ->setQueryString(QueryString::render($this->getBaseFilter()))
+ ->getAbsoluteUrl()
+ );
+ } else {
+ $form->setAction(
+ Links::toggleServicesFeatures()
+ ->setQueryString(QueryString::render($this->getBaseFilter()))
+ ->getAbsoluteUrl()
+ );
+ }
+
+ return [
+ Html::tag('h2', t('Feature Commands')),
+ $form
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectsDetailExtensionHook::loadExtensions(
+ $this->type,
+ $this->query,
+ $this->getBaseFilter()
+ );
+ }
+
+ protected function createSummary(): array
+ {
+ return [
+ Html::tag('h2', t('Summary')),
+ $this->createChart()
+ ];
+ }
+
+ protected function assemble()
+ {
+ $this->add(ObjectsDetailExtensionHook::injectExtensions([
+ 190 => $this->createSummary(),
+ 400 => $this->createComments(),
+ 401 => $this->createDowntimes(),
+ 701 => $this->createFeatureToggles()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/PerfDataTable.php b/library/Icingadb/Widget/Detail/PerfDataTable.php
new file mode 100644
index 0000000..4e03089
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/PerfDataTable.php
@@ -0,0 +1,133 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Util\PerfData;
+use Icinga\Module\Icingadb\Util\PerfDataSet;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use ipl\Html\Attributes;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\Html\Table;
+use ipl\Html\Text;
+
+class PerfDataTable extends Table
+{
+ /** @var bool Whether the table contains a sparkline column */
+ protected $containsSparkline = false;
+
+ protected $defaultAttributes = [
+ 'class' => 'performance-data-table collapsible',
+ 'data-visible-rows' => 6
+ ];
+
+ /** @var string The perfdata string */
+ protected $perfdataStr;
+
+ /** @var int Max labels to show; 0 for no limit */
+ protected $limit;
+
+ /** @var string The color indicating the perfdata state */
+ protected $color;
+
+ /**
+ * Display the given perfdata string to the user
+ *
+ * @param string $perfdataStr The perfdata string
+ * @param int $limit Max labels to show; 0 for no limit
+ * @param string $color The color indicating the perfdata state
+ */
+ public function __construct(string $perfdataStr, int $limit = 0, string $color = PerfData::PERFDATA_OK)
+ {
+ $this->perfdataStr = $perfdataStr;
+ $this->limit = $limit;
+ $this->color = $color;
+ }
+
+ public function assemble()
+ {
+ $pieChartData = PerfDataSet::fromString($this->perfdataStr)->asArray();
+ $keys = ['', 'label', 'value', 'min', 'max', 'warn', 'crit'];
+ $columns = [];
+ $labels = array_combine(
+ $keys,
+ [
+ '',
+ t('Label'),
+ t('Value'),
+ t('Min'),
+ t('Max'),
+ t('Warning'),
+ t('Critical')
+ ]
+ );
+ foreach ($pieChartData as $perfdata) {
+ if ($perfdata->isVisualizable()) {
+ $columns[''] = '';
+ $this->containsSparkline = true;
+ }
+
+ foreach ($perfdata->toArray() as $column => $value) {
+ if (
+ empty($value) ||
+ $column === 'min' && floatval($value) === 0.0 ||
+ $column === 'max' && $perfdata->isPercentage() && floatval($value) === 100
+ ) {
+ continue;
+ }
+
+ $columns[$column] = $labels[$column];
+ }
+ }
+
+ $headerRow = new HtmlElement('tr');
+ foreach ($keys as $key => $col) {
+ if ((! $this->containsSparkline) && $col == '') {
+ unset($keys[$key]);
+ continue;
+ }
+ if (isset($col)) {
+ $headerRow->addHtml(new HtmlElement('th', Attributes::create([
+ 'class' => ($col == 'label' ? 'title' : null)
+ ]), Text::create($labels[$col])));
+ }
+ }
+
+ $this->getHeader()->addHtml($headerRow);
+
+ foreach ($pieChartData as $count => $perfdata) {
+ if ($this->limit != 0 && $count > $this->limit) {
+ break;
+ } else {
+ $cols = [];
+ if ($this->containsSparkline) {
+ if ($perfdata->isVisualizable()) {
+ $cols[] = Table::td(
+ HtmlString::create($perfdata->asInlinePie($this->color)->render()),
+ [ 'class' => 'sparkline-col']
+ );
+ } else {
+ $cols[] = Table::td('');
+ }
+ }
+
+ foreach ($perfdata->toArray() as $column => $value) {
+ $cols[] = Table::td(
+ new HtmlElement(
+ 'span',
+ Attributes::create([
+ 'class' => ($value ? '' : 'no-value')
+ ]),
+ $value ? Text::create($value) : new EmptyState(t('None', 'value'))
+ ),
+ [ 'class' => ($column === 'label' ? 'title' : null) ]
+ );
+ }
+
+ $this->addHtml(Table::tr([$cols]));
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/QuickActions.php b/library/Icingadb/Widget/Detail/QuickActions.php
new file mode 100644
index 0000000..2ea26c2
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/QuickActions.php
@@ -0,0 +1,148 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\HostLinks;
+use Icinga\Module\Icingadb\Common\ServiceLinks;
+use Icinga\Module\Icingadb\Forms\Command\Object\CheckNowForm;
+use Icinga\Module\Icingadb\Forms\Command\Object\RemoveAcknowledgementForm;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Web\Widget\Icon;
+
+class QuickActions extends BaseHtmlElement
+{
+ use Auth;
+
+ /** @var Host|Service */
+ protected $object;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = ['class' => 'quick-actions'];
+
+ public function __construct($object)
+ {
+ $this->object = $object;
+ }
+
+ protected function assemble()
+ {
+ if ($this->object->state->is_problem) {
+ if ($this->object->state->is_acknowledged) {
+ if ($this->isGrantedOn('icingadb/command/remove-acknowledgement', $this->object)) {
+ $removeAckForm = (new RemoveAcknowledgementForm())
+ ->setAction($this->getLink('removeAcknowledgement'))
+ ->setObjects([$this->object]);
+
+ $this->add(Html::tag('li', $removeAckForm));
+ }
+ } elseif ($this->isGrantedOn('icingadb/command/acknowledge-problem', $this->object)) {
+ $this->assembleAction(
+ 'acknowledge',
+ t('Acknowledge'),
+ 'check-circle',
+ t('Acknowledge this problem, suppress all future notifications for it and tag it as being handled')
+ );
+ }
+ }
+
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $this->object)
+ || (
+ $this->object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $this->object)
+ )
+ ) {
+ $this->add(Html::tag('li', (new CheckNowForm())->setAction($this->getLink('checkNow'))));
+ }
+
+ if ($this->isGrantedOn('icingadb/command/comment/add', $this->object)) {
+ $this->assembleAction(
+ 'addComment',
+ t('Comment', 'verb'),
+ 'comment',
+ t('Add a new comment')
+ );
+ }
+
+ if ($this->isGrantedOn('icingadb/command/send-custom-notification', $this->object)) {
+ $this->assembleAction(
+ 'sendCustomNotification',
+ t('Notification'),
+ 'bell',
+ t('Send a custom notification')
+ );
+ }
+
+ if ($this->isGrantedOn('icingadb/command/downtime/schedule', $this->object)) {
+ $this->assembleAction(
+ 'scheduleDowntime',
+ t('Downtime'),
+ 'plug',
+ t('Schedule a downtime to suppress all problem notifications within a specific period of time')
+ );
+ }
+
+ if (
+ $this->isGrantedOn('icingadb/command/schedule-check', $this->object)
+ || (
+ $this->object->active_checks_enabled
+ && $this->isGrantedOn('icingadb/command/schedule-check/active-only', $this->object)
+ )
+ ) {
+ $this->assembleAction(
+ 'scheduleCheck',
+ t('Reschedule'),
+ 'calendar',
+ t('Schedule the next active check at a different time than the current one')
+ );
+ }
+
+ if ($this->isGrantedOn('icingadb/command/process-check-result', $this->object)) {
+ $this->assembleAction(
+ 'processCheckresult',
+ t('Process check result'),
+ 'edit',
+ sprintf(
+ t('Submit a one time or so called passive result for the %s check'),
+ $this->object->checkcommand_name
+ )
+ );
+ }
+ }
+
+ protected function assembleAction(string $action, string $label, string $icon, string $title)
+ {
+ $link = Html::tag(
+ 'a',
+ [
+ 'href' => $this->getLink($action),
+ 'class' => 'action-link',
+ 'title' => $title,
+ 'data-icinga-modal' => true,
+ 'data-no-icinga-ajax' => true
+ ],
+ [
+ new Icon($icon),
+ $label
+ ]
+ );
+
+ $this->add(Html::tag('li', $link));
+ }
+
+ protected function getLink($action)
+ {
+ if ($this->object instanceof Host) {
+ return HostLinks::$action($this->object)->getAbsoluteUrl();
+ } else {
+ return ServiceLinks::$action($this->object, $this->object->host)->getAbsoluteUrl();
+ }
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceDetail.php b/library/Icingadb/Widget/Detail/ServiceDetail.php
new file mode 100644
index 0000000..8421e31
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceDetail.php
@@ -0,0 +1,37 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Service;
+
+class ServiceDetail extends ObjectDetail
+{
+ public function __construct(Service $object)
+ {
+ parent::__construct($object);
+ }
+
+ protected function assemble()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->add($this->createPrintHeader());
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 0 => $this->createPluginOutput(),
+ 300 => $this->createActions(),
+ 301 => $this->createNotes(),
+ 400 => $this->createComments(),
+ 401 => $this->createDowntimes(),
+ 500 => $this->createGroups(),
+ 501 => $this->createNotifications(),
+ 600 => $this->createCheckStatistics(),
+ 601 => $this->createPerformanceData(),
+ 700 => $this->createCustomVars(),
+ 701 => $this->createFeatureToggles()
+ ], $this->createExtensions()));
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php b/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php
new file mode 100644
index 0000000..f29ee9b
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceInspectionDetail.php
@@ -0,0 +1,21 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\ObjectInspectionDetail;
+
+class ServiceInspectionDetail extends ObjectInspectionDetail
+{
+ protected function assemble()
+ {
+ $this->add([
+ $this->createSourceLocation(),
+ $this->createLastCheckResult(),
+ $this->createAttributes(),
+ $this->createCustomVariables(),
+ $this->createRedisInfo()
+ ]);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceMetaInfo.php b/library/Icingadb/Widget/Detail/ServiceMetaInfo.php
new file mode 100644
index 0000000..448252f
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceMetaInfo.php
@@ -0,0 +1,61 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Icingadb\Model\Service;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use ipl\Web\Widget\Icon;
+
+class ServiceMetaInfo extends BaseHtmlElement
+{
+ protected $tag = 'div';
+
+ protected $defaultAttributes = ['class' => 'object-meta-info'];
+
+ /** @var Service */
+ protected $service;
+
+ public function __construct(Service $service)
+ {
+ $this->service = $service;
+ }
+
+ protected function assemble()
+ {
+ $this->addHtml(
+ new VerticalKeyValue('service.name', $this->service->name),
+ new VerticalKeyValue(
+ 'last_state_change',
+ DateFormatter::formatDateTime($this->service->state->last_state_change)
+ )
+ );
+
+ $collapsible = new HtmlElement('div', Attributes::create([
+ 'class' => 'collapsible',
+ 'id' => 'object-meta-info',
+ 'data-toggle-element' => '.object-meta-info-control',
+ 'data-visible-height' => 0
+ ]));
+
+ $renderHelper = new HtmlDocument();
+ $renderHelper->addHtml(
+ $this,
+ new HtmlElement(
+ 'button',
+ Attributes::create(['class' => 'object-meta-info-control']),
+ new Icon('angle-double-up', ['class' => 'collapse-icon']),
+ new Icon('angle-double-down', ['class' => 'expand-icon'])
+ )
+ );
+
+ $this->addWrapper($collapsible);
+ $this->addWrapper($renderHelper);
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/ServiceStatistics.php b/library/Icingadb/Widget/Detail/ServiceStatistics.php
new file mode 100644
index 0000000..0f570b4
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/ServiceStatistics.php
@@ -0,0 +1,66 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Chart\Donut;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Widget\ServiceStateBadges;
+use ipl\Html\ValidHtml;
+use ipl\Web\Widget\VerticalKeyValue;
+use ipl\Html\HtmlString;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Widget\Link;
+
+class ServiceStatistics extends ObjectStatistics
+{
+ protected $summary;
+
+ public function __construct($summary)
+ {
+ $this->summary = $summary;
+ }
+
+ protected function createDonut(): ValidHtml
+ {
+ $donut = (new Donut())
+ ->addSlice($this->summary->services_ok, ['class' => 'slice-state-ok'])
+ ->addSlice($this->summary->services_warning_handled, ['class' => 'slice-state-warning-handled'])
+ ->addSlice($this->summary->services_warning_unhandled, ['class' => 'slice-state-warning'])
+ ->addSlice($this->summary->services_critical_handled, ['class' => 'slice-state-critical-handled'])
+ ->addSlice($this->summary->services_critical_unhandled, ['class' => 'slice-state-critical'])
+ ->addSlice($this->summary->services_unknown_handled, ['class' => 'slice-state-unknown-handled'])
+ ->addSlice($this->summary->services_unknown_unhandled, ['class' => 'slice-state-unknown'])
+ ->addSlice($this->summary->services_pending, ['class' => 'slice-state-pending']);
+
+ return HtmlString::create($donut->render());
+ }
+
+ protected function createTotal(): ValidHtml
+ {
+ $url = Links::services();
+ if ($this->hasBaseFilter()) {
+ $url->addFilter(Filter::fromQueryString(QueryString::render($this->getBaseFilter())));
+ }
+
+ return new Link(
+ new VerticalKeyValue(
+ tp('Service', 'Services', $this->summary->services_total),
+ $this->summary->services_total
+ ),
+ $url
+ );
+ }
+
+ protected function createBadges(): ValidHtml
+ {
+ $badges = new ServiceStateBadges($this->summary);
+ if ($this->hasBaseFilter()) {
+ $badges->setBaseFilter($this->getBaseFilter());
+ }
+
+ return $badges;
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/UserDetail.php b/library/Icingadb/Widget/Detail/UserDetail.php
new file mode 100644
index 0000000..bfdfa46
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/UserDetail.php
@@ -0,0 +1,188 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\User;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use ipl\Html\Attributes;
+use ipl\Web\Widget\HorizontalKeyValue;
+use Icinga\Module\Icingadb\Widget\ItemList\UsergroupList;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+
+class UserDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+
+ /** @var User The given user */
+ protected $user;
+
+ protected $defaultAttributes = ['class' => 'object-detail'];
+
+ protected $tag = 'div';
+
+ public function __construct(User $user)
+ {
+ $this->user = $user;
+ }
+
+ protected function createCustomVars(): array
+ {
+ $content = [new HtmlElement('h2', null, Text::create(t('Custom Variables')))];
+ $flattenedVars = $this->user->customvar_flat;
+ $this->applyRestrictions($flattenedVars);
+
+ $vars = $this->user->customvar_flat->getModel()->unflattenVars($flattenedVars);
+ if (! empty($vars)) {
+ $content[] = new HtmlElement('div', Attributes::create([
+ 'id' => 'user-customvars',
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200
+ ]), new CustomVarTable($vars, $this->user));
+ } else {
+ $content[] = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return $content;
+ }
+
+ protected function createUserDetail(): array
+ {
+ list($hostStates, $serviceStates) = $this->separateStates($this->user->states);
+ $hostStates = implode(', ', $this->localizeStates($hostStates));
+ $serviceStates = implode(', ', $this->localizeStates($serviceStates));
+ $types = implode(', ', $this->localizeTypes($this->user->types));
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Details'))),
+ new HorizontalKeyValue(t('Name'), $this->user->name),
+ new HorizontalKeyValue(t('E-Mail'), $this->user->email ?: new EmptyState(t('None', 'address'))),
+ new HorizontalKeyValue(t('Pager'), $this->user->pager ?: new EmptyState(t('None', 'phone-number'))),
+ new HorizontalKeyValue(t('Host States'), $hostStates ?: t('All')),
+ new HorizontalKeyValue(t('Service States'), $serviceStates ?: t('All')),
+ new HorizontalKeyValue(t('Types'), $types ?: t('All'))
+ ];
+ }
+
+ protected function createUsergroupList(): array
+ {
+ $userGroups = $this->user->usergroup->limit(6)->peekAhead()->execute();
+
+ $showMoreLink = (new ShowMore(
+ $userGroups,
+ Links::usergroups()->addParams(['user.name' => $this->user->name])
+ ))->setBaseTarget('_next');
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Groups'))),
+ new UsergroupList($userGroups),
+ $showMoreLink
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectDetailExtensionHook::loadExtensions($this->user);
+ }
+
+ protected function assemble()
+ {
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 200 => $this->createUserDetail(),
+ 500 => $this->createUsergroupList(),
+ 700 => $this->createCustomVars()
+ ], $this->createExtensions()));
+ }
+
+ private function localizeTypes(array $types): array
+ {
+ $localizedTypes = [];
+ foreach ($types as $type) {
+ switch ($type) {
+ case 'problem':
+ $localizedTypes[] = t('Problem');
+ break;
+ case 'ack':
+ $localizedTypes[] = t('Acknowledgement');
+ break;
+ case 'recovery':
+ $localizedTypes[] = t('Recovery');
+ break;
+ case 'downtime_start':
+ $localizedTypes[] = t('Downtime Start');
+ break;
+ case 'downtime_end':
+ $localizedTypes[] = t('Downtime End');
+ break;
+ case 'downtime_removed':
+ $localizedTypes[] = t('Downtime Removed');
+ break;
+ case 'flapping_start':
+ $localizedTypes[] = t('Flapping Start');
+ break;
+ case 'flapping_end':
+ $localizedTypes[] = t('Flapping End');
+ break;
+ case 'custom':
+ $localizedTypes[] = t('Custom');
+ break;
+ }
+ }
+
+ return $localizedTypes;
+ }
+
+ private function localizeStates(array $states): array
+ {
+ $localizedState = [];
+ foreach ($states as $state) {
+ switch ($state) {
+ case 'up':
+ $localizedState[] = t('Up');
+ break;
+ case 'down':
+ $localizedState[] = t('Down');
+ break;
+ case 'ok':
+ $localizedState[] = t('Ok');
+ break;
+ case 'warning':
+ $localizedState[] = t('Warning');
+ break;
+ case 'critical':
+ $localizedState[] = t('Critical');
+ break;
+ case 'unknown':
+ $localizedState[] = t('Unknown');
+ break;
+ }
+ }
+
+ return $localizedState;
+ }
+
+ private function separateStates(array $states): array
+ {
+ $hostStates = [];
+ $serviceStates = [];
+
+ foreach ($states as $state) {
+ if ($state === 'Up' || $state === 'Down') {
+ $hostStates[] = $state;
+ } else {
+ $serviceStates[] = $state;
+ }
+ }
+
+ return [$hostStates, $serviceStates];
+ }
+}
diff --git a/library/Icingadb/Widget/Detail/UsergroupDetail.php b/library/Icingadb/Widget/Detail/UsergroupDetail.php
new file mode 100644
index 0000000..f2cdebe
--- /dev/null
+++ b/library/Icingadb/Widget/Detail/UsergroupDetail.php
@@ -0,0 +1,98 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Widget\Detail;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Hook\ExtensionHook\ObjectDetailExtensionHook;
+use Icinga\Module\Icingadb\Model\Usergroup;
+use Icinga\Module\Icingadb\Widget\EmptyState;
+use Icinga\Module\Icingadb\Widget\ItemList\UserList;
+use Icinga\Module\Icingadb\Widget\ShowMore;
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\Text;
+use ipl\Web\Widget\HorizontalKeyValue;
+
+class UsergroupDetail extends BaseHtmlElement
+{
+ use Auth;
+ use Database;
+
+ /** @var Usergroup The given user group */
+ protected $usergroup;
+
+ protected $defaultAttributes = ['class' => 'object-detail'];
+
+ protected $tag = 'div';
+
+ public function __construct(Usergroup $usergroup)
+ {
+ $this->usergroup = $usergroup;
+ }
+
+ protected function createPrintHeader()
+ {
+ return [
+ new HtmlElement('h2', null, Text::create(t('Details'))),
+ new HorizontalKeyValue(t('Name'), $this->usergroup->name)
+ ];
+ }
+
+ protected function createCustomVars(): array
+ {
+ $content = [new HtmlElement('h2', null, Text::create(t('Custom Variables')))];
+ $flattenedVars = $this->usergroup->customvar_flat;
+ $this->applyRestrictions($flattenedVars);
+
+ $vars = $this->usergroup->customvar_flat->getModel()->unflattenVars($flattenedVars);
+ if (! empty($vars)) {
+ $content[] = new HtmlElement('div', Attributes::create([
+ 'id' => 'usergroup-customvars',
+ 'class' => 'collapsible',
+ 'data-visible-height' => 200
+ ]), new CustomVarTable($vars, $this->usergroup));
+ } else {
+ $content[] = new EmptyState(t('No custom variables configured.'));
+ }
+
+ return $content;
+ }
+
+ protected function createUserList(): array
+ {
+ $users = $this->usergroup->user->limit(6)->peekAhead()->execute();
+
+ $showMoreLink = (new ShowMore(
+ $users,
+ Links::users()->addParams(['usergroup.name' => $this->usergroup->name])
+ ))->setBaseTarget('_next');
+
+ return [
+ new HtmlElement('h2', null, Text::create(t('Users'))),
+ new UserList($users),
+ $showMoreLink
+ ];
+ }
+
+ protected function createExtensions(): array
+ {
+ return ObjectDetailExtensionHook::loadExtensions($this->usergroup);
+ }
+
+ protected function assemble()
+ {
+ if (getenv('ICINGAWEB_EXPORT_FORMAT') === 'pdf') {
+ $this->add($this->createPrintHeader());
+ }
+
+ $this->add(ObjectDetailExtensionHook::injectExtensions([
+ 500 => $this->createUserList(),
+ 700 => $this->createCustomVars()
+ ], $this->createExtensions()));
+ }
+}