diff --git a/application/controllers/RedundancygroupController.php b/application/controllers/RedundancygroupController.php new file mode 100644 index 000000000..9e0c65fb0 --- /dev/null +++ b/application/controllers/RedundancygroupController.php @@ -0,0 +1,354 @@ +params->shift('child.redundancy_group.id'); + if ($groupId === null) { + $groupId = $this->params->shiftRequired('id'); + } + + $this->groupId = $groupId; + } + + /** + * Load the redundancy group + */ + protected function loadGroup(): void + { + $query = RedundancyGroup::on($this->getDb()) + ->with(['state']) + ->filter(Filter::equal('id', $this->groupId)); + + $this->applyRestrictions($query); + + $this->group = $query->first(); + + if ($this->group === null) { + $this->httpNotFound($this->translate('Redundancy Group not found')); + } + + $this->setTitleTab($this->getRequest()->getActionName()); + $this->setTitle($this->group->display_name); + + $summary = RedundancyGroupSummary::on($this->getDb()) + ->filter(Filter::equal('id', $this->groupId)); + + $this->applyRestrictions($summary); + + $this->groupSummary = $summary->first(); + + $this->addControl(new RedundancyGroupHeader($this->group, $this->groupSummary)); + } + + public function indexAction(): void + { + $this->loadGroup(); + + // The base filter is required to fetch the correct objects for MultiselectQuickActions::isGrantedOnType() check + $this->addControl( + (new MultiselectQuickActions('dependency_node', $this->groupSummary)) + ->setBaseFilter(Filter::equal('child.redundancy_group.id', $this->groupId)) + ->setAllowToProcessCheckResults(false) + ->setColumnPrefix('nodes') + ->setUrlPath('icingadb/redundancygroup') + ); + + $this->addContent(new RedundancyGroupDetail($this->group)); + } + + public function membersAction(): void + { + $this->loadGroup(); + $nodesQuery = $this->fetchNodes(true); + + $limitControl = $this->createLimitControl(); + $paginationControl = $this->createPaginationControl($nodesQuery); + $sortControl = $this->createSortControl( + $nodesQuery, + [ + 'name' => $this->translate('Name'), + 'severity desc, last_state_change desc' => $this->translate('Severity'), + 'state' => $this->translate('Current State'), + 'last_state_change desc' => $this->translate('Last State Change') + ] + ); + $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl); + + $searchBar = $this->createSearchBar( + $nodesQuery, + [ + $limitControl->getLimitParam(), + $sortControl->getSortParam(), + $viewModeSwitcher->getViewModeParam(), + 'id' + ] + ); + + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { + if ($searchBar->hasBeenSubmitted()) { + $filter = $this->getFilter(); + } else { + $this->addControl($searchBar); + $this->sendMultipartUpdate(); + return; + } + } else { + $filter = $searchBar->getFilter(); + } + + $nodesQuery->filter($filter); + + $this->addControl($paginationControl); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($viewModeSwitcher); + $this->addControl($searchBar); + + $this->addContent( + (new DependencyNodeList($nodesQuery)) + ->setViewMode($viewModeSwitcher->getViewMode()) + ); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); + } + + $this->setAutorefreshInterval(10); + } + + public function childrenAction(): void + { + $this->loadGroup(); + $nodesQuery = $this->fetchNodes(); + + $limitControl = $this->createLimitControl(); + $paginationControl = $this->createPaginationControl($nodesQuery); + $sortControl = $this->createSortControl( + $nodesQuery, + [ + 'name' => $this->translate('Name'), + 'severity desc, last_state_change desc' => $this->translate('Severity'), + 'state' => $this->translate('Current State'), + 'last_state_change desc' => $this->translate('Last State Change') + ] + ); + $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl); + + $searchBar = $this->createSearchBar( + $nodesQuery, + [ + $limitControl->getLimitParam(), + $sortControl->getSortParam(), + $viewModeSwitcher->getViewModeParam(), + 'id' + ] + ); + + $searchBar->getSuggestionUrl()->setParam('isChildrenTab'); + $searchBar->getEditorUrl() + ->setParams((clone $searchBar->getEditorUrl()->getParams())->set('isChildrenTab', true)); + + if ($searchBar->hasBeenSent() && ! $searchBar->isValid()) { + if ($searchBar->hasBeenSubmitted()) { + $filter = $this->getFilter(); + } else { + $this->addControl($searchBar); + $this->sendMultipartUpdate(); + return; + } + } else { + $filter = $searchBar->getFilter(); + } + + $nodesQuery->filter($filter); + + $this->addControl($paginationControl); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($viewModeSwitcher); + $this->addControl($searchBar); + + $this->addContent( + (new DependencyNodeList($nodesQuery)) + ->setViewMode($viewModeSwitcher->getViewMode()) + ); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); + } + + $this->setAutorefreshInterval(10); + } + + public function completeAction(): void + { + $isChildrenTab = $this->params->shift('isChildrenTab'); + $column = $isChildrenTab ? 'parent' : 'child'; + + $suggestions = (new ObjectSuggestions()) + ->setModel(DependencyNode::class) + ->setBaseFilter(Filter::equal("$column.redundancy_group.id", $this->groupId)) + ->forRequest($this->getServerRequest()); + + $this->getDocument()->add($suggestions); + } + + public function searchEditorAction(): void + { + $isChildrenTab = $this->params->shift('isChildrenTab'); + $redirectUrl = $isChildrenTab + ? Url::fromPath('icingadb/redundancygroup/children', ['id' => $this->groupId]) + : Url::fromPath('icingadb/redundancygroup/members', ['id' => $this->groupId]); + + $editor = $this->createSearchEditor( + DependencyNode::on($this->getDb()), + $redirectUrl, + [ + LimitControl::DEFAULT_LIMIT_PARAM, + SortControl::DEFAULT_SORT_PARAM, + ViewModeSwitcher::DEFAULT_VIEW_MODE_PARAM, + 'id' + ] + ); + + if ($isChildrenTab) { + $editor->getSuggestionUrl()->setParam('isChildrenTab'); + } + + $this->getDocument()->add($editor); + $this->setTitle($this->translate('Adjust Filter')); + } + + protected function createTabs(): Tabs + { + $tabs = $this->getTabs() + ->add('index', [ + 'label' => $this->translate('Redundancy Group'), + 'url' => Url::fromPath('icingadb/redundancygroup', ['id' => $this->groupId]) + ]) + ->add('members', [ + 'label' => $this->translate('Members'), + 'url' => Url::fromPath('icingadb/redundancygroup/members', ['id' => $this->groupId]) + ]) + ->add('children', [ + 'label' => $this->translate('Children'), + 'url' => Url::fromPath('icingadb/redundancygroup/children', ['id' => $this->groupId]) + ]); + + return $tabs; + } + + protected function setTitleTab(string $name): void + { + $tab = $this->createTabs()->get($name); + + if ($tab !== null) { + $this->getTabs()->activate($name); + } + } + + /** + * Fetch the nodes for the current group + * + * @param bool $fetchParents Whether to fetch the parents or the children + * + * @return Query + */ + private function fetchNodes(bool $fetchParents = false): Query + { + $filterColumn = sprintf( + '%s.redundancy_group.id', + $fetchParents ? 'child' : 'parent' + ); + + $query = DependencyNode::on($this->getDb()) + ->with([ + 'host', + 'host.state', + 'host.state.last_comment', + 'service', + 'service.state', + 'service.state.last_comment', + 'service.host', + 'service.host.state' + ]) + ->filter(Filter::equal($filterColumn, $this->groupId)); + + $this->applyRestrictions($query); + + return $query; + } + + protected function fetchCommandTargets() + { + $filter = Filter::all(Filter::equal('child.redundancy_group.id', $this->groupId)); + + if ($this->getRequest()->getActionName() === 'acknowledge') { + $filter->add( + Filter::any( + Filter::all( + Filter::unlike('child.service.id', '*'), + Filter::equal('host.state.is_problem', 'y'), + Filter::equal('host.state.is_acknowledged', 'n') + ), + Filter::all( + Filter::equal('service.state.is_problem', 'y'), + Filter::equal('service.state.is_acknowledged', 'n') + ) + ) + ); + } + + return new DependencyNodes($filter); + } + + protected function getCommandTargetsUrl(): Url + { + return Url::fromPath('icingadb/redundancygroup', ['id' => $this->groupId]); + } + + public function processCheckresultAction(): void + { + $this->httpBadRequest('Check result submission not implemented yet'); + } +} diff --git a/library/Icingadb/Authentication/ObjectAuthorization.php b/library/Icingadb/Authentication/ObjectAuthorization.php index 988e8f01f..d49342cb8 100644 --- a/library/Icingadb/Authentication/ObjectAuthorization.php +++ b/library/Icingadb/Authentication/ObjectAuthorization.php @@ -6,6 +6,7 @@ use Icinga\Module\Icingadb\Common\Auth; use Icinga\Module\Icingadb\Common\Database; +use Icinga\Module\Icingadb\Model\DependencyNode; use Icinga\Module\Icingadb\Model\Host; use Icinga\Module\Icingadb\Model\Service; use InvalidArgumentException; @@ -85,6 +86,9 @@ public static function grantsOnType(string $permission, string $type, Filter\Rul case 'service': $for = Service::class; break; + case 'dependency_node': + $for = DependencyNode::class; + break; default: throw new InvalidArgumentException(sprintf('Unknown type "%s"', $type)); } @@ -161,13 +165,16 @@ protected function loadGrants(string $model, Filter\Rule $filter, string $cacheK $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/objects')); } - if ($tableName === 'host' || $tableName === 'service') { + if ($tableName === 'host' || $tableName === 'service' || $tableName === 'dependency_node') { if (($restriction = $role->getRestrictions('icingadb/filter/hosts'))) { $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/hosts')); } } - if ($tableName === 'service' && ($restriction = $role->getRestrictions('icingadb/filter/services'))) { + if ( + ($tableName === 'dependency_node' || $tableName === 'service') + && ($restriction = $role->getRestrictions('icingadb/filter/services')) + ) { $roleFilter->add($this->parseRestriction($restriction, 'icingadb/filter/services')); } diff --git a/library/Icingadb/Data/DependencyNodes.php b/library/Icingadb/Data/DependencyNodes.php new file mode 100644 index 000000000..92ced025d --- /dev/null +++ b/library/Icingadb/Data/DependencyNodes.php @@ -0,0 +1,80 @@ +filter = $filter; + } + + public function getIterator(): ArrayIterator + { + if ($this->nodes === null) { + $membersQuery = DependencyNode::on($this->getDb()) + ->with([ + 'host', + 'host.state', + 'service', + 'service.state', + 'service.host' + ]) + ->filter($this->filter); + + $this->applyRestrictions($membersQuery); + + $nodes = []; + foreach ($membersQuery as $node) { + $nodes[] = $node->service_id !== null ? $node->service : $node->host; + } + + $this->nodes = new ArrayIterator($nodes); + } + + return $this->nodes; + } + + public function getFilter(): Filter\Rule + { + return $this->filter; + } + + public function count(): int + { + return $this->getIterator()->count(); + } + + public function getModel() + { + return new Host(); + } +} diff --git a/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php b/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php index 12b52fc39..08ac36762 100644 --- a/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php +++ b/library/Icingadb/Hook/ExtensionHook/ObjectDetailExtensionHook.php @@ -14,6 +14,7 @@ use Icinga\Module\Icingadb\Hook\UsergroupDetailExtensionHook; use Icinga\Module\Icingadb\Model\History; use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\RedundancyGroup; use Icinga\Module\Icingadb\Model\Service; use Icinga\Module\Icingadb\Model\User; use Icinga\Module\Icingadb\Model\Usergroup; @@ -48,6 +49,9 @@ final public static function loadExtensions(Model $object): array case $object instanceof Service: $hookName = 'Icingadb\\ServiceDetailExtension'; break; + case $object instanceof RedundancyGroup: + $hookName = 'Icingadb\\RedundancyGroupDetailExtension'; + break; case $object instanceof User: $hookName = 'Icingadb\\UserDetailExtension'; break; diff --git a/library/Icingadb/Hook/RedundancyGroupDetailExtensionHook.php b/library/Icingadb/Hook/RedundancyGroupDetailExtensionHook.php new file mode 100644 index 000000000..7cf417358 --- /dev/null +++ b/library/Icingadb/Hook/RedundancyGroupDetailExtensionHook.php @@ -0,0 +1,21 @@ + new Expression( + 'COALESCE(%s, %s, %s)', + ['service.display_name', 'host.display_name', 'redundancy_group.display_name'] + ), + 'severity' => new Expression( + 'COALESCE(%s, %s, %s)', + ['service.state.severity', 'host.state.severity', 'redundancy_group.state.failed'] + ), + 'state' => new Expression( + 'COALESCE(%s, %s, %s)', + ['service.state.soft_state', 'host.state.soft_state', 'redundancy_group.state.failed'] + ), + 'last_state_change' => new Expression( + 'COALESCE(%s, %s, %s)', + [ + 'service.state.last_state_change', + 'host.state.last_state_change', + 'redundancy_group.state.last_state_change' + ] + ), + ]; + } + + public function getSearchColumns(): array + { + return [ + 'host.name_ci', + 'service.name_ci', + 'redundancy_group.display_name' ]; } diff --git a/library/Icingadb/Model/RedundancyGroup.php b/library/Icingadb/Model/RedundancyGroup.php index 2965f0634..efe44d9b2 100644 --- a/library/Icingadb/Model/RedundancyGroup.php +++ b/library/Icingadb/Model/RedundancyGroup.php @@ -44,6 +44,13 @@ public function getColumns(): array ]; } + public function getColumnDefinitions(): array + { + return [ + 'display_name' => t('Redundancy Group Display Name') + ]; + } + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new Binary([ diff --git a/library/Icingadb/Model/RedundancyGroupSummary.php b/library/Icingadb/Model/RedundancyGroupSummary.php index 35ad03aaf..71a790d17 100644 --- a/library/Icingadb/Model/RedundancyGroupSummary.php +++ b/library/Icingadb/Model/RedundancyGroupSummary.php @@ -131,6 +131,32 @@ public function getSummaryColumns(): array 'from.to.service.state.is_handled', 'from.to.service.state.is_reachable' ] + ), + 'nodes_acknowledged' => new Expression( + 'SUM(CASE' + . " WHEN %s IS NOT NULL THEN (CASE WHEN %s = 'y' THEN 1 ELSE 0 END)" + . " WHEN %s = 'y' THEN 1" + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.is_acknowledged', + 'from.to.host.state.is_acknowledged', + ] + ), + 'nodes_problems_unacknowledged' => new Expression( + 'SUM(CASE' + . " WHEN %s IS NOT NULL THEN (CASE WHEN %s = 'y' AND %s = 'n' THEN 1 ELSE 0 END)" + . " WHEN %s = 'y' AND %s = 'n' THEN 1" + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.is_problem', + 'from.to.service.state.is_acknowledged', + 'from.to.host.state.is_problem', + 'from.to.host.state.is_acknowledged', + ] ) ]; } diff --git a/library/Icingadb/Model/UnreachableParent.php b/library/Icingadb/Model/UnreachableParent.php index 647e989a7..6ba633aac 100644 --- a/library/Icingadb/Model/UnreachableParent.php +++ b/library/Icingadb/Model/UnreachableParent.php @@ -126,8 +126,10 @@ private static function selectNodes(Connection $db, Model $root): Select Filter::equal('host_id', $root->host_id), Filter::equal('service_id', $root->id) )); + } elseif ($root instanceof RedundancyGroup) { + $rootQuery->filter(Filter::all(Filter::equal('redundancy_group_id', $root->id))); } else { - throw new InvalidArgumentException('Root node must be either a host or a service'); + throw new InvalidArgumentException('Root node must be either a host, service or a redundancy group'); } $nodeQuery = DependencyEdge::on($db) diff --git a/library/Icingadb/Widget/Detail/MultiselectQuickActions.php b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php index b80ec9df2..b9a9f0c84 100644 --- a/library/Icingadb/Widget/Detail/MultiselectQuickActions.php +++ b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php @@ -10,7 +10,6 @@ use ipl\Html\BaseHtmlElement; use ipl\Html\Html; use ipl\Stdlib\BaseFilter; -use ipl\Web\Filter\QueryString; use ipl\Web\Url; use ipl\Web\Widget\Icon; @@ -27,17 +26,100 @@ class MultiselectQuickActions extends BaseHtmlElement protected $defaultAttributes = ['class' => 'quick-actions']; + /** @var bool Whether to allow process check results */ + protected $allowToProcessCheckResults = true; + + /** @var ?string The summary column prefix */ + protected $columnPrefix; + + /** @var ?string The url path for {@see getLink()} method (default: `icingadb/$this->type . 's'`) */ + protected $urlPath; + public function __construct($type, $summary) { $this->summary = $summary; $this->type = $type; } + /** + * Set the summary column prefix + * + * @param string $columnPrefix + * + * @return $this + */ + public function setColumnPrefix(string $columnPrefix): self + { + $this->columnPrefix = $columnPrefix; + + return $this; + } + + /** + * Get the summary column prefix (default: `$this->type . 's'`) + * + * @return string + */ + public function getColumnPrefix(): string + { + if ($this->columnPrefix === null) { + $this->columnPrefix = $this->type . 's'; + } + + return $this->columnPrefix; + } + + /** + * Set the url path for {@see getLink()} method + * + * Omits the trailing slashes + * + * @param string $urlPath + * + * @return $this + */ + public function setUrlPath(string $urlPath): self + { + $this->urlPath = rtrim($urlPath, '/'); + + return $this; + } + + /** + * Get the url path for {@see getLink()} method + * + * If not set `icingadb/$this->type . 's'` is used + * + * @return string + */ + public function getUrlPath(): string + { + if ($this->urlPath === null) { + $this->urlPath = "icingadb/{$this->type}s"; + } + + return $this->urlPath; + } + + /** + * Set whether to allow process check results + * + * @param bool $state + * + * @return $this + */ + public function setAllowToProcessCheckResults(bool $state = true): self + { + $this->allowToProcessCheckResults = $state; + + return $this; + } + protected function assemble() { - $unacknowledged = "{$this->type}s_problems_unacknowledged"; - $acks = "{$this->type}s_acknowledged"; - $activeChecks = "{$this->type}s_active_checks_enabled"; + $unacknowledged = "{$this->getColumnPrefix()}_problems_unacknowledged"; + $acks = "{$this->getColumnPrefix()}_acknowledged"; + $activeChecks = "{$this->getColumnPrefix()}_active_checks_enabled"; if ( $this->summary->$unacknowledged > $this->summary->$acks @@ -76,7 +158,7 @@ protected function assemble() if ( $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false) || ( - $this->summary->$activeChecks > 0 + ! empty($this->summary->$activeChecks) && $this->isGrantedOnType( 'icingadb/command/schedule-check/active-only', $this->type, @@ -132,7 +214,7 @@ protected function assemble() if ( $this->isGrantedOnType('icingadb/command/schedule-check', $this->type, $this->getBaseFilter(), false) || ( - $this->summary->$activeChecks > 0 + ! empty($this->summary->$activeChecks) && $this->isGrantedOnType( 'icingadb/command/schedule-check/active-only', $this->type, @@ -150,7 +232,8 @@ protected function assemble() } if ( - $this->isGrantedOnType( + $this->allowToProcessCheckResults + && $this->isGrantedOnType( 'icingadb/command/process-check-result', $this->type, $this->getBaseFilter(), @@ -188,7 +271,7 @@ protected function assembleAction(string $action, string $label, string $icon, s protected function getLink(string $action): string { - return Url::fromPath("icingadb/{$this->type}s/$action") + return Url::fromPath($this->getUrlPath() . '/' . $action) ->setFilter($this->getBaseFilter()) ->getAbsoluteUrl(); } diff --git a/library/Icingadb/Widget/Detail/ObjectHeader.php b/library/Icingadb/Widget/Detail/ObjectHeader.php new file mode 100644 index 000000000..158622021 --- /dev/null +++ b/library/Icingadb/Widget/Detail/ObjectHeader.php @@ -0,0 +1,145 @@ + */ + protected $baseAttributes = ['class' => 'object-header']; + + /** @var Model The associated object */ + protected $object; + + protected $tag = 'div'; + + /** + * Create a new object header + * + * @param Model $object + */ + public function __construct(Model $object) + { + $this->object = $object; + + $this->addAttributes($this->baseAttributes); + + $this->init(); + } + + abstract protected function assembleHeader(BaseHtmlElement $header): void; + + abstract protected function assembleMain(BaseHtmlElement $main): void; + + protected function assembleCaption(BaseHtmlElement $caption): void + { + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + } + + protected function getStateBallSize(): string + { + return StateBall::SIZE_BIG; + } + + protected function createCaption(): BaseHtmlElement + { + $caption = new HtmlElement('section', Attributes::create(['class' => 'caption'])); + + $this->assembleCaption($caption); + + return $caption; + } + + protected function createHeader(): BaseHtmlElement + { + $header = new HtmlElement('header'); + + $this->assembleHeader($header); + + return $header; + } + + protected function createMain(): BaseHtmlElement + { + $main = new HtmlElement('div', Attributes::create(['class' => 'main'])); + + $this->assembleMain($main); + + return $main; + } + + protected function createTimestamp(): ?BaseHtmlElement + { + //TODO: add support for host/service + return new TimeSince($this->object->state->last_state_change->getTimestamp()); + } + + protected function createSubject(): BaseHtmlElement + { + return new HtmlElement( + 'span', + Attributes::create(['class' => 'subject']), + Text::create($this->object->display_name) + ); + } + + protected function createTitle(): BaseHtmlElement + { + $title = new HtmlElement('div', Attributes::create(['class' => 'title'])); + + $this->assembleTitle($title); + + return $title; + } + + /** + * @return ?BaseHtmlElement + */ + protected function createVisual(): ?BaseHtmlElement + { + $visual = new HtmlElement('div', Attributes::create(['class' => 'visual'])); + + $this->assembleVisual($visual); + if ($visual->isEmpty()) { + return null; + } + + return $visual; + } + + /** + * Initialize the list item + * + * If you want to adjust the object header after construction, override this method. + */ + protected function init(): void + { + } + + protected function assemble(): void + { + $this->add([ + $this->createVisual(), + $this->createMain() + ]); + } +} diff --git a/library/Icingadb/Widget/Detail/RedundancyGroupDetail.php b/library/Icingadb/Widget/Detail/RedundancyGroupDetail.php new file mode 100644 index 000000000..1c07e4c13 --- /dev/null +++ b/library/Icingadb/Widget/Detail/RedundancyGroupDetail.php @@ -0,0 +1,146 @@ + ['redundancygroup-detail'], + 'data-pdfexport-page-breaks-at' => 'h2' + ]; + + protected $tag = 'div'; + + /** + * Create a new redundancy group detail widget + * + * @param RedundancyGroup $group + */ + public function __construct(RedundancyGroup $group) + { + $this->group = $group; + } + + /** + * Create hook extensions + * + * @return array + */ + protected function createExtensions(): array + { + return ObjectDetailExtensionHook::loadExtensions($this->group); + } + + /** + * Create a list of root problems if the redundancy group fails + * + * @return ?BaseHtmlElement[] + */ + protected function createRootProblems(): ?array + { + if (! $this->group->state->failed) { + return null; + } + + $rootProblems = UnreachableParent::on($this->getDb(), $this->group) + ->with([ + 'redundancy_group', + 'redundancy_group.state', + 'host', + 'host.state', + 'host.icon_image', + 'host.state.last_comment', + 'service', + 'service.state', + 'service.icon_image', + 'service.state.last_comment', + 'service.host', + 'service.host.state', + ]) + ->setResultSetClass(VolatileStateResults::class) + ->orderBy([ + 'host.state.severity', + 'host.state.last_state_change', + 'service.state.severity', + 'service.state.last_state_change', + 'redundancy_group.state.failed', + 'redundancy_group.state.last_state_change' + ], SORT_DESC); + + $this->applyRestrictions($rootProblems); + + return [ + HtmlElement::create('h2', null, Text::create($this->translate('Root Problems'))), + (new DependencyNodeList($rootProblems))->setEmptyStateMessage( + $this->translate('You are not authorized to view these objects.') + ) + ]; + } + + /** + * Create a list of group members + * + * @return BaseHtmlElement[] + */ + protected function createGroupMembers(): array + { + $membersQuery = DependencyNode::on($this->getDb()) + ->with([ + 'host', + 'host.state', + 'service', + 'service.state', + 'service.host', + 'service.host.state' + ]) + ->filter(Filter::equal('child.redundancy_group.id', $this->group->id)) + ->limit(5) + ->peekAhead(); + + $this->applyRestrictions($membersQuery); + + // TODO: Do not execute at this time. The widget may be replaced by a hook in which case the result is unused. + $members = $membersQuery->execute(); + + return [ + HtmlElement::create('h2', null, Text::create($this->translate('Group Members'))), + (new DependencyNodeList($members)) + ->setEmptyStateMessage($this->translate('You are not authorized to view these objects.')), + (new ShowMore($members, Url::fromPath('icingadb/redundancygroup/members', ['id' => $this->group->id]))) + ->setBaseTarget('_self') + ]; + } + + protected function assemble(): void + { + $this->add(ObjectDetailExtensionHook::injectExtensions([ + 0 => $this->createRootProblems(), + 510 => $this->createGroupMembers(), + ], $this->createExtensions())); + } +} diff --git a/library/Icingadb/Widget/Detail/RedundancyGroupHeader.php b/library/Icingadb/Widget/Detail/RedundancyGroupHeader.php new file mode 100644 index 000000000..12154c348 --- /dev/null +++ b/library/Icingadb/Widget/Detail/RedundancyGroupHeader.php @@ -0,0 +1,63 @@ +summary = $summary; + + parent::__construct($object); + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + $visual->addHtml(new StateBall($this->object->state->getStateText(), $this->getStateBallSize())); + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml($this->createSubject()); + if ($this->object->state->failed) { + $text = $this->translate('has no working objects'); + } else { + $text = $this->translate('has working objects'); + } + + $title->addHtml(HtmlElement::create('span', null, Text::create($text))); + } + + protected function createStatistics(): BaseHtmlElement + { + return new DependencyNodeStatistics($this->summary); + } + + protected function assembleHeader(BaseHtmlElement $header): void + { + $header->add($this->createTitle()); + $header->add($this->createStatistics()); + $header->add($this->createTimestamp()); + } + + protected function assembleMain(BaseHtmlElement $main): void + { + $main->add($this->createHeader()); + } +} diff --git a/library/Icingadb/Widget/ItemList/DependencyNodeList.php b/library/Icingadb/Widget/ItemList/DependencyNodeList.php index 04dfc6ffa..c95f883fa 100644 --- a/library/Icingadb/Widget/ItemList/DependencyNodeList.php +++ b/library/Icingadb/Widget/ItemList/DependencyNodeList.php @@ -5,6 +5,7 @@ namespace Icinga\Module\Icingadb\Widget\ItemList; use Icinga\Module\Icingadb\Model\DependencyNode; +use Icinga\Module\Icingadb\Model\Host; use Icinga\Module\Icingadb\Model\UnreachableParent; use ipl\Web\Common\BaseListItem; @@ -30,10 +31,23 @@ protected function createListItem(object $data): BaseListItem /** @var UnreachableParent|DependencyNode $data */ if ($data->redundancy_group_id !== null) { return new RedundancyGroupListItem($data->redundancy_group, $this); - } elseif ($data->service_id !== null) { - return new ServiceListItem($data->service, $this); - } else { - return new HostListItem($data->host, $this); } + + $object = $data->service_id !== null ? $data->service : $data->host; + + switch ($this->getViewMode()) { + case 'minimal': + $class = $object instanceof Host ? HostListItemMinimal::class : ServiceListItemMinimal::class; + break; + case 'detailed': + $this->removeAttribute('class', 'default-layout'); + + $class = $object instanceof Host ? HostListItemDetailed::class : ServiceListItemDetailed::class; + break; + default: + $class = $object instanceof Host ? HostListItem::class : ServiceListItem::class; + } + + return new $class($object, $this); } } diff --git a/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php b/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php index 2b87723d2..eca3c9271 100644 --- a/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php +++ b/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php @@ -4,6 +4,7 @@ namespace Icinga\Module\Icingadb\Widget\ItemList; +use Icinga\Module\Icingadb\Common\Auth; use Icinga\Module\Icingadb\Common\Database; use Icinga\Module\Icingadb\Common\ListItemCommonLayout; use Icinga\Module\Icingadb\Model\RedundancyGroup; @@ -12,9 +13,10 @@ use Icinga\Module\Icingadb\Widget\DependencyNodeStatistics; use ipl\Html\BaseHtmlElement; use ipl\Stdlib\Filter; +use ipl\Web\Url; +use ipl\Web\Widget\Link; use ipl\Web\Widget\StateBall; use ipl\Html\HtmlElement; -use ipl\Html\Attributes; use ipl\Html\Text; use ipl\Web\Widget\TimeSince; @@ -22,16 +24,22 @@ * Redundancy group list item. Represents one database row. * * @property RedundancyGroup $item + * @property RedundancyGroupState $state */ class RedundancyGroupListItem extends StateListItem { use ListItemCommonLayout; use Database; + use Auth; protected $defaultAttributes = ['class' => ['redundancy-group-list-item']]; - /** @var RedundancyGroupState */ - protected $state; + protected function init(): void + { + parent::init(); + + $this->addAttributes(['data-action-item' => true]); + } protected function getStateBallSize(): string { @@ -43,12 +51,12 @@ protected function createTimestamp(): BaseHtmlElement return new TimeSince($this->state->last_state_change->getTimestamp()); } - protected function createSubject(): BaseHtmlElement + protected function createSubject(): Link { - return new HtmlElement( - 'span', - Attributes::create(['class' => 'subject']), - Text::create($this->item->display_name) + return new Link( + $this->item->display_name, + Url::fromPath('icingadb/redundancygroup', ['id' => bin2hex($this->item->id)]), + ['class' => 'subject'] ); } @@ -59,11 +67,12 @@ protected function assembleVisual(BaseHtmlElement $visual): void protected function assembleCaption(BaseHtmlElement $caption): void { - $caption->addHtml(new DependencyNodeStatistics( - RedundancyGroupSummary::on($this->getDb()) - ->filter(Filter::equal('id', $this->item->id)) - ->first() - )); + $summary = RedundancyGroupSummary::on($this->getDb()) + ->filter(Filter::equal('id', $this->item->id)); + + $this->applyRestrictions($summary); + + $caption->addHtml(new DependencyNodeStatistics($summary->first())); } protected function assembleTitle(BaseHtmlElement $title): void diff --git a/public/css/widget/object-header.less b/public/css/widget/object-header.less new file mode 100644 index 000000000..9c6be51a8 --- /dev/null +++ b/public/css/widget/object-header.less @@ -0,0 +1,57 @@ +// Layout +.object-header { + display: flex; + + .visual { + display: flex; + padding: 0.5em 0; + align-items: center; + } + + .main { + flex: 1 1 auto; + padding: 0.5em 0; + width: 0; + margin-left: .5em; + + header { + display: flex; + justify-content: space-between; + align-items: center; + + .title { + display: inline-flex; + align-items: baseline; + white-space: nowrap; + margin-right: 1em; + + & > * { + margin: 0 .28125em; // 0 calculated   width + } + + .subject { + .text-ellipsis(); + } + } + + time { + white-space: nowrap; + } + } + } +} + +.object-header { + color: @default-text-color-light; + + .title { + .subject { + color: @default-text-color; + } + } + + .object-statistics { + margin-left: auto; + margin-right: 1em; + } +}