From e426edde859e50ee27df88ed6b3eacd0b3ae43c6 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 16 Oct 2024 16:27:57 +0200 Subject: [PATCH 1/9] Models: Add missing columns and methods RedundancyGroupSummary: Add columns for `(un)acknowledged` count --- library/Icingadb/Model/DependencyNode.php | 9 +++++++ library/Icingadb/Model/RedundancyGroup.php | 7 +++++ .../Icingadb/Model/RedundancyGroupSummary.php | 26 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/library/Icingadb/Model/DependencyNode.php b/library/Icingadb/Model/DependencyNode.php index c30677ad3..635d3a834 100644 --- a/library/Icingadb/Model/DependencyNode.php +++ b/library/Icingadb/Model/DependencyNode.php @@ -47,6 +47,15 @@ public function getColumns(): array ]; } + public function getSearchColumns(): array + { + return [ + 'host.name_ci', + 'service.name_ci', + 'redundancy_group.display_name' + ]; + } + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new Binary([ 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', + ] ) ]; } From db77a90827dc06710d7ff6791952554f0b3f60ed Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Tue, 12 Nov 2024 09:39:36 +0100 Subject: [PATCH 2/9] Add RedundancyGroupDetailExtentionHook --- .../ObjectDetailExtensionHook.php | 4 ++++ .../RedundancyGroupDetailExtensionHook.php | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 library/Icingadb/Hook/RedundancyGroupDetailExtensionHook.php 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 @@ + Date: Tue, 12 Nov 2024 09:43:43 +0100 Subject: [PATCH 3/9] Introduce class `RedundancyGroupDetail` Let UnreachableParent take RedundancyGroup Model as root --- library/Icingadb/Model/UnreachableParent.php | 4 +- .../Widget/Detail/RedundancyGroupDetail.php | 145 ++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 library/Icingadb/Widget/Detail/RedundancyGroupDetail.php 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/RedundancyGroupDetail.php b/library/Icingadb/Widget/Detail/RedundancyGroupDetail.php new file mode 100644 index 000000000..b19230bd6 --- /dev/null +++ b/library/Icingadb/Widget/Detail/RedundancyGroupDetail.php @@ -0,0 +1,145 @@ + ['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); + + $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())); + } +} From 791bc9b2d48478aa4380d2e6339571ca723a7d2e Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Tue, 12 Nov 2024 19:26:31 +0100 Subject: [PATCH 4/9] Introduce RedundancygroupController --- .../controllers/RedundancygroupController.php | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 application/controllers/RedundancygroupController.php diff --git a/application/controllers/RedundancygroupController.php b/application/controllers/RedundancygroupController.php new file mode 100644 index 000000000..e2ce406d7 --- /dev/null +++ b/application/controllers/RedundancygroupController.php @@ -0,0 +1,285 @@ +groupId = $this->params->shiftRequired('id'); + + $query = RedundancyGroup::on($this->getDb()) + ->with(['state']) + ->filter(Filter::equal('id', $this->groupId)); + + $this->applyRestrictions($query); + + $this->group = $query->first(); + + if ($this->group === null) { + throw new NotFoundError(t('Redundancy Group not found')); + } + + $this->setTitleTab($this->getRequest()->getActionName()); + $this->setTitle($this->group->display_name); + + $this->addControl(new HtmlElement('div', null, Text::create($this->group->display_name))); + $this->addFooter( + new DependencyNodeStatistics( + RedundancyGroupSummary::on($this->getDb()) + ->filter(Filter::equal('id', $this->groupId)) + ->first() + ) + ); + } + + public function indexAction(): void + { + $this->addContent(new RedundancyGroupDetail($this->group)); + } + + public function membersAction(): \Generator + { + $nodesQuery = $this->fetchNodes(true); + + $limitControl = $this->createLimitControl(); + $paginationControl = $this->createPaginationControl($nodesQuery); + $sortControl = $this->createSortControl( + $nodesQuery, + [ + 'name' => t('Name'), + 'severity desc, last_state_change desc' => t('Severity'), + 'state' => t('Current State'), + 'last_state_change desc' => t('Last State Change') + ] + ); + $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl); + + $searchBar = $this->createSearchBar($nodesQuery, + Links::redundancyGroupMembers($this->group), + [ + $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); + + yield $this->export($nodesQuery); + + $this->addControl($paginationControl); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($viewModeSwitcher); + $this->addControl($searchBar); + + $this->addContent( + (new DependencyNodeList($nodesQuery->execute())) + ->setViewMode($viewModeSwitcher->getViewMode()) + ); + + if (! $searchBar->hasBeenSubmitted() && $searchBar->hasBeenSent()) { + $this->sendMultipartUpdate(); + } + + $this->setAutorefreshInterval(10); + } + + public function childrenAction() + { + $nodesQuery = $this->fetchNodes(); + + $limitControl = $this->createLimitControl(); + $paginationControl = $this->createPaginationControl($nodesQuery); + $sortControl = $this->createSortControl( + $nodesQuery, + [ + 'name' => t('Name'), + 'severity desc, last_state_change desc' => t('Severity'), + 'state' => t('Current State'), + 'last_state_change desc' => t('Last State Change') + ] + ); + $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl); + + $searchBar = $this->createSearchBar( + $nodesQuery, + Links::redundancyGroupChildren($this->group), + [ + $limitControl->getLimitParam(), + $sortControl->getSortParam(), + $viewModeSwitcher->getViewModeParam(), + 'id' + ] + ); + + $searchBar->getSuggestionUrl()->setParam('isChildrenTab'); + $searchBar->getEditorUrl()->setParam('isChildrenTab'); + + 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); + + yield $this->export($nodesQuery); + + $this->addControl($paginationControl); + $this->addControl($sortControl); + $this->addControl($limitControl); + $this->addControl($viewModeSwitcher); + $this->addControl($searchBar); + + $this->addContent( + (new DependencyNodeList($nodesQuery->execute())) + ->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 + ? Links::redundancyGroupChildren($this->group) + : Links::redundancyGroupMembers($this->group); + + $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(t('Adjust Filter')); + } + + protected function createTabs(): Tabs + { + $tabs = $this->getTabs() + ->add('index', [ + 'label' => t('Redundancy Group'), + 'url' => Links::redundancyGroup($this->group) + ]) + ->add('members', [ + 'label' => t('Members'), + 'url' => Links::redundancyGroupMembers($this->group) + ]) + ->add('children', [ + 'label' => t('Children'), + 'url' => Links::redundancyGroupChildren($this->group) + ]); + + 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' + ); + + return DependencyNode::on($this->getDb()) + ->with([ + 'host', + 'host.state', + 'service', + 'service.state', + 'service.host', + 'service.host.state' + ]) + ->filter(Filter::equal($filterColumn, $this->groupId)); + } +} From f310b48bc2146a30c4d5492a5b5f59eec6b22da5 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Tue, 12 Nov 2024 17:53:17 +0100 Subject: [PATCH 5/9] Add MultiSelectQuickActions support --- .../controllers/RedundancygroupController.php | 59 ++++++++++- .../Authentication/ObjectAuthorization.php | 8 +- library/Icingadb/Data/DependencyNodes.php | 79 +++++++++++++++ .../Widget/Detail/MultiselectQuickActions.php | 97 +++++++++++++++++-- 4 files changed, 232 insertions(+), 11 deletions(-) create mode 100644 library/Icingadb/Data/DependencyNodes.php diff --git a/application/controllers/RedundancygroupController.php b/application/controllers/RedundancygroupController.php index e2ce406d7..c65d3e723 100644 --- a/application/controllers/RedundancygroupController.php +++ b/application/controllers/RedundancygroupController.php @@ -5,7 +5,9 @@ namespace Icinga\Module\Icingadb\Controllers; use Icinga\Exception\NotFoundError; +use Icinga\Module\Icingadb\Common\CommandActions; use Icinga\Module\Icingadb\Common\Links; +use Icinga\Module\Icingadb\Data\DependencyNodes; use Icinga\Module\Icingadb\Model\DependencyNode; use Icinga\Module\Icingadb\Model\RedundancyGroup; use Icinga\Module\Icingadb\Model\RedundancyGroupSummary; @@ -13,6 +15,7 @@ use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher; use Icinga\Module\Icingadb\Web\Controller; use Icinga\Module\Icingadb\Widget\DependencyNodeStatistics; +use Icinga\Module\Icingadb\Widget\Detail\MultiselectQuickActions; use Icinga\Module\Icingadb\Widget\Detail\RedundancyGroupDetail; use Icinga\Module\Icingadb\Widget\ItemList\DependencyNodeList; use ipl\Html\HtmlElement; @@ -21,10 +24,13 @@ use ipl\Stdlib\Filter; use ipl\Web\Control\LimitControl; use ipl\Web\Control\SortControl; +use ipl\Web\Url; use ipl\Web\Widget\Tabs; class RedundancygroupController extends Controller { + use CommandActions; + /** @var string */ protected $groupId; @@ -33,7 +39,13 @@ class RedundancygroupController extends Controller public function init(): void { - $this->groupId = $this->params->shiftRequired('id'); + // in case of quick actions, param id is not given + $groupId = $this->params->shift('child.redundancy_group.id'); + if ($groupId === null) { + $groupId = $this->params->shiftRequired('id'); + } + + $this->groupId = $groupId; $query = RedundancyGroup::on($this->getDb()) ->with(['state']) @@ -62,6 +74,19 @@ public function init(): void public function indexAction(): void { + $summary = RedundancyGroupSummary::on($this->getDb()) + ->filter(Filter::equal('id', $this->groupId)); + + $this->filter($summary); + + $this->addControl( + (new MultiselectQuickActions('dependency_node', $summary->first())) + ->setBaseFilter(Filter::equal('child.redundancy_group.id', $this->groupId)) + ->allowToProcessCheckResults(false) + ->setColumnPrefix('nodes') + ->setUrlPath('icingadb/redundancygroup') + ); + $this->addContent(new RedundancyGroupDetail($this->group)); } @@ -282,4 +307,36 @@ private function fetchNodes(bool $fetchParents = false): Query ]) ->filter(Filter::equal($filterColumn, $this->groupId)); } + + 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 Links::redundancyGroup($this->group); + } + + public function processCheckresultAction(): void + { + } } diff --git a/library/Icingadb/Authentication/ObjectAuthorization.php b/library/Icingadb/Authentication/ObjectAuthorization.php index 988e8f01f..c8e27723b 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,13 @@ 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..6edda7028 --- /dev/null +++ b/library/Icingadb/Data/DependencyNodes.php @@ -0,0 +1,79 @@ +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/Widget/Detail/MultiselectQuickActions.php b/library/Icingadb/Widget/Detail/MultiselectQuickActions.php index b80ec9df2..1d2829d7c 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,98 @@ 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 $allowToProcessCheckResults + * + * @return $this + */ + public function allowToProcessCheckResults(bool $allowToProcessCheckResults = true): self + { + $this->allowToProcessCheckResults = $allowToProcessCheckResults; + + 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 +156,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 +212,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 +230,8 @@ protected function assemble() } if ( - $this->isGrantedOnType( + $this->allowToProcessCheckResults + && $this->isGrantedOnType( 'icingadb/command/process-check-result', $this->type, $this->getBaseFilter(), @@ -188,7 +269,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(); } From a81be5d6b4a831ed4ab30fc1a1c9f532eb7c53b3 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Wed, 13 Nov 2024 16:29:11 +0100 Subject: [PATCH 6/9] RedundancygroupController:: add some optimizations Use Translation trait's translate() method --- .../controllers/RedundancygroupController.php | 97 ++++++++++--------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/application/controllers/RedundancygroupController.php b/application/controllers/RedundancygroupController.php index c65d3e723..56f410d67 100644 --- a/application/controllers/RedundancygroupController.php +++ b/application/controllers/RedundancygroupController.php @@ -6,7 +6,6 @@ use Icinga\Exception\NotFoundError; use Icinga\Module\Icingadb\Common\CommandActions; -use Icinga\Module\Icingadb\Common\Links; use Icinga\Module\Icingadb\Data\DependencyNodes; use Icinga\Module\Icingadb\Model\DependencyNode; use Icinga\Module\Icingadb\Model\RedundancyGroup; @@ -14,7 +13,6 @@ use Icinga\Module\Icingadb\Web\Control\SearchBar\ObjectSuggestions; use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher; use Icinga\Module\Icingadb\Web\Controller; -use Icinga\Module\Icingadb\Widget\DependencyNodeStatistics; use Icinga\Module\Icingadb\Widget\Detail\MultiselectQuickActions; use Icinga\Module\Icingadb\Widget\Detail\RedundancyGroupDetail; use Icinga\Module\Icingadb\Widget\ItemList\DependencyNodeList; @@ -46,7 +44,13 @@ public function init(): void } $this->groupId = $groupId; + } + /** + * Load the redundancy group + */ + protected function loadGroup(): void + { $query = RedundancyGroup::on($this->getDb()) ->with(['state']) ->filter(Filter::equal('id', $this->groupId)); @@ -56,24 +60,18 @@ public function init(): void $this->group = $query->first(); if ($this->group === null) { - throw new NotFoundError(t('Redundancy Group not found')); + throw new NotFoundError($this->translate('Redundancy Group not found')); } $this->setTitleTab($this->getRequest()->getActionName()); $this->setTitle($this->group->display_name); $this->addControl(new HtmlElement('div', null, Text::create($this->group->display_name))); - $this->addFooter( - new DependencyNodeStatistics( - RedundancyGroupSummary::on($this->getDb()) - ->filter(Filter::equal('id', $this->groupId)) - ->first() - ) - ); } public function indexAction(): void { + $this->loadGroup(); $summary = RedundancyGroupSummary::on($this->getDb()) ->filter(Filter::equal('id', $this->groupId)); @@ -90,25 +88,19 @@ public function indexAction(): void $this->addContent(new RedundancyGroupDetail($this->group)); } - public function membersAction(): \Generator + public function membersAction(): void { + $this->loadGroup(); $nodesQuery = $this->fetchNodes(true); $limitControl = $this->createLimitControl(); $paginationControl = $this->createPaginationControl($nodesQuery); - $sortControl = $this->createSortControl( - $nodesQuery, - [ - 'name' => t('Name'), - 'severity desc, last_state_change desc' => t('Severity'), - 'state' => t('Current State'), - 'last_state_change desc' => t('Last State Change') - ] - ); + $sortControl = $this->createSortControl($nodesQuery); $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl); - $searchBar = $this->createSearchBar($nodesQuery, - Links::redundancyGroupMembers($this->group), + $searchBar = $this->createSearchBar( + $nodesQuery, + Url::fromPath('icingadb/redundancygroup/members', ['id' => $this->groupId]), [ $limitControl->getLimitParam(), $sortControl->getSortParam(), @@ -131,8 +123,6 @@ public function membersAction(): \Generator $nodesQuery->filter($filter); - yield $this->export($nodesQuery); - $this->addControl($paginationControl); $this->addControl($sortControl); $this->addControl($limitControl); @@ -151,26 +141,19 @@ public function membersAction(): \Generator $this->setAutorefreshInterval(10); } - public function childrenAction() + public function childrenAction(): void { + $this->loadGroup(); $nodesQuery = $this->fetchNodes(); $limitControl = $this->createLimitControl(); $paginationControl = $this->createPaginationControl($nodesQuery); - $sortControl = $this->createSortControl( - $nodesQuery, - [ - 'name' => t('Name'), - 'severity desc, last_state_change desc' => t('Severity'), - 'state' => t('Current State'), - 'last_state_change desc' => t('Last State Change') - ] - ); + $sortControl = $this->createSortControl($nodesQuery); $viewModeSwitcher = $this->createViewModeSwitcher($paginationControl, $limitControl); $searchBar = $this->createSearchBar( $nodesQuery, - Links::redundancyGroupChildren($this->group), + Url::fromPath('icingadb/redundancygroup/children', ['id' => $this->groupId]), [ $limitControl->getLimitParam(), $sortControl->getSortParam(), @@ -196,8 +179,6 @@ public function childrenAction() $nodesQuery->filter($filter); - yield $this->export($nodesQuery); - $this->addControl($paginationControl); $this->addControl($sortControl); $this->addControl($limitControl); @@ -233,10 +214,11 @@ public function searchEditorAction(): void { $isChildrenTab = $this->params->shift('isChildrenTab'); $redirectUrl = $isChildrenTab - ? Links::redundancyGroupChildren($this->group) - : Links::redundancyGroupMembers($this->group); + ? Url::fromPath('icingadb/redundancygroup/children', ['id' => $this->groupId]) + : Url::fromPath('icingadb/redundancygroup/members', ['id' => $this->groupId]); - $editor = $this->createSearchEditor(DependencyNode::on($this->getDb()), + $editor = $this->createSearchEditor( + DependencyNode::on($this->getDb()), $redirectUrl, [ LimitControl::DEFAULT_LIMIT_PARAM, @@ -251,23 +233,23 @@ public function searchEditorAction(): void } $this->getDocument()->add($editor); - $this->setTitle(t('Adjust Filter')); + $this->setTitle($this->translate('Adjust Filter')); } protected function createTabs(): Tabs { $tabs = $this->getTabs() ->add('index', [ - 'label' => t('Redundancy Group'), - 'url' => Links::redundancyGroup($this->group) + 'label' => $this->translate('Redundancy Group'), + 'url' => Url::fromPath('icingadb/redundancygroup', ['id' => $this->groupId]) ]) ->add('members', [ - 'label' => t('Members'), - 'url' => Links::redundancyGroupMembers($this->group) + 'label' => $this->translate('Members'), + 'url' => Url::fromPath('icingadb/redundancygroup/members', ['id' => $this->groupId]) ]) ->add('children', [ - 'label' => t('Children'), - 'url' => Links::redundancyGroupChildren($this->group) + 'label' => $this->translate('Children'), + 'url' => Url::fromPath('icingadb/redundancygroup/children', ['id' => $this->groupId]) ]); return $tabs; @@ -282,6 +264,25 @@ protected function setTitleTab(string $name): void } } + public function createSortControl(Query $query, array $columns = null): SortControl + { + $sortRules = [ + 'host.display_name, service.display_name, redundancy_group.display_name' => $this->translate('Name'), + 'service.state.severity desc, service.state.last_state_change desc, ' + . 'host.state.severity desc, host.state.last_state_change desc, ' + . 'redundancy_group.state.failed desc, redundancy_group.state.last_state_change desc' => $this->translate( + 'Severity' + ), + 'service.state.soft_state, host.state.soft_state, redundancy_group.state.failed' => $this->translate( + 'Current State' + ), + 'service.state.last_state_change desc, host.state.last_state_change desc, ' + . 'redundancy_group.state.last_state_change desc' => $this->translate('Last State Change') + ]; + + return parent::createSortControl($query, $sortRules); + } + /** * Fetch the nodes for the current group * @@ -333,7 +334,7 @@ protected function fetchCommandTargets() protected function getCommandTargetsUrl(): Url { - return Links::redundancyGroup($this->group); + return Url::fromPath('icingadb/redundancygroup', ['id' => $this->groupId]); } public function processCheckresultAction(): void From c462879d8471ccb0625c95669fd273395f674c07 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Thu, 14 Nov 2024 16:34:00 +0100 Subject: [PATCH 7/9] Introduce `ObjectHeader` class and use it --- .../controllers/RedundancygroupController.php | 8 +- .../Icingadb/Widget/Detail/ObjectHeader.php | 145 ++++++++++++++++++ .../Widget/Detail/RedundancyGroupHeader.php | 63 ++++++++ public/css/widget/object-header.less | 57 +++++++ 4 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 library/Icingadb/Widget/Detail/ObjectHeader.php create mode 100644 library/Icingadb/Widget/Detail/RedundancyGroupHeader.php create mode 100644 public/css/widget/object-header.less diff --git a/application/controllers/RedundancygroupController.php b/application/controllers/RedundancygroupController.php index 56f410d67..82b1ec416 100644 --- a/application/controllers/RedundancygroupController.php +++ b/application/controllers/RedundancygroupController.php @@ -15,6 +15,7 @@ use Icinga\Module\Icingadb\Web\Controller; use Icinga\Module\Icingadb\Widget\Detail\MultiselectQuickActions; use Icinga\Module\Icingadb\Widget\Detail\RedundancyGroupDetail; +use Icinga\Module\Icingadb\Widget\Detail\RedundancyGroupHeader; use Icinga\Module\Icingadb\Widget\ItemList\DependencyNodeList; use ipl\Html\HtmlElement; use ipl\Html\Text; @@ -66,7 +67,12 @@ protected function loadGroup(): void $this->setTitleTab($this->getRequest()->getActionName()); $this->setTitle($this->group->display_name); - $this->addControl(new HtmlElement('div', null, Text::create($this->group->display_name))); + $summary = RedundancyGroupSummary::on($this->getDb()) + ->filter(Filter::equal('id', $this->groupId)); + + $this->applyRestrictions($summary); + + $this->addControl(new RedundancyGroupHeader($this->group, $summary->first())); } public function indexAction(): void diff --git a/library/Icingadb/Widget/Detail/ObjectHeader.php b/library/Icingadb/Widget/Detail/ObjectHeader.php new file mode 100644 index 000000000..bce9b2878 --- /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() + ]); + } +} \ No newline at end of file diff --git a/library/Icingadb/Widget/Detail/RedundancyGroupHeader.php b/library/Icingadb/Widget/Detail/RedundancyGroupHeader.php new file mode 100644 index 000000000..7e3c277dd --- /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()); + } +} \ No newline at end of file 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; + } +} From 89c24051fadeab176826d5d4c69db367f48dfb45 Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Fri, 15 Nov 2024 13:42:07 +0100 Subject: [PATCH 8/9] RedundancyGroupListItem: Make list item clickable --- .../ItemList/RedundancyGroupListItem.php | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php b/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php index 2b87723d2..88aa1108d 100644 --- a/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php +++ b/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php @@ -12,9 +12,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; @@ -33,6 +34,14 @@ class RedundancyGroupListItem extends StateListItem /** @var RedundancyGroupState */ protected $state; + protected function init(): void + { + parent::init(); + + $this->getAttributes() + ->registerAttributeCallback('data-action-item', function () {return true;}); + } + protected function getStateBallSize(): string { return StateBall::SIZE_LARGE; @@ -43,12 +52,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'] ); } From 7332d2c8ecee0282383bb188256aaf9a024c7ddd Mon Sep 17 00:00:00 2001 From: Sukhwinder Dhillon Date: Fri, 15 Nov 2024 13:44:35 +0100 Subject: [PATCH 9/9] RedundancyGroupListItem: Apply restrictions to summary query - Add type hint for $state with @property tag instead --- .../Widget/ItemList/RedundancyGroupListItem.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php b/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php index 88aa1108d..9780053e2 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; @@ -23,17 +24,16 @@ * 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(); @@ -68,11 +68,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