diff options
Diffstat (limited to 'library/Icingadb/Widget/Detail')
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())); + } +} |