diff --git a/application/controllers/ServiceController.php b/application/controllers/ServiceController.php index 8867e918..2857718a 100644 --- a/application/controllers/ServiceController.php +++ b/application/controllers/ServiceController.php @@ -38,13 +38,15 @@ class ServiceController extends Controller $name = $this->params->getRequired('name'); $hostName = $this->params->getRequired('host.name'); - $query = Service::on($this->getDb())->with([ - 'state', - 'icon_image', - 'host', - 'host.state', - 'timeperiod' - ]); + $query = Service::on($this->getDb()) + ->withColumns(['has_problematic_parent']) + ->with([ + 'state', + 'icon_image', + 'host', + 'host.state', + 'timeperiod' + ]); $query ->setResultSetClass(VolatileStateResults::class) ->filter(Filter::all( diff --git a/library/Icingadb/Common/StateBadges.php b/library/Icingadb/Common/StateBadges.php index c9c5c89f..b55eeeb3 100644 --- a/library/Icingadb/Common/StateBadges.php +++ b/library/Icingadb/Common/StateBadges.php @@ -4,11 +4,12 @@ namespace Icinga\Module\Icingadb\Common; +use InvalidArgumentException; use ipl\Html\BaseHtmlElement; use ipl\Html\Html; +use ipl\Html\HtmlElement; use ipl\Stdlib\BaseFilter; use ipl\Stdlib\Filter; -use ipl\Web\Filter\QueryString; use ipl\Web\Url; use ipl\Web\Widget\Link; use ipl\Web\Widget\StateBadge; @@ -26,7 +27,7 @@ abstract class StateBadges extends BaseHtmlElement /** @var string Prefix */ protected $prefix; - /** @var Url Badge link */ + /** @var ?Url Badge link */ protected $url; protected $tag = 'ul'; @@ -46,13 +47,6 @@ abstract class StateBadges extends BaseHtmlElement $this->url = $this->getBaseUrl(); } - /** - * Get the badge base URL - * - * @return Url - */ - abstract protected function getBaseUrl(): Url; - /** * Get the type of the items * @@ -67,21 +61,36 @@ abstract class StateBadges extends BaseHtmlElement */ abstract protected function getPrefix(): string; + /** + * Get the badge base URL + * + * @return ?Url + */ + protected function getBaseUrl(): ?Url + { + return null; + } + /** * Get the integer of the given state text * * @param string $state * * @return int + * + * @throws InvalidArgumentException if the given state is not valid */ - abstract protected function getStateInt(string $state): int; + protected function getStateInt(string $state): int + { + throw new InvalidArgumentException(sprintf('%s is not a valid state', $state)); + } /** * Get the badge URL * - * @return Url + * @return ?Url */ - public function getUrl(): Url + public function getUrl(): ?Url { return $this->url; } @@ -108,7 +117,7 @@ abstract class StateBadges extends BaseHtmlElement * * @return Link */ - public function createLink($content, Filter\Rule $filter = null): Link + protected function createLink($content, Filter\Rule $filter = null): Link { $url = clone $this->getUrl(); @@ -135,18 +144,23 @@ abstract class StateBadges extends BaseHtmlElement * * @return ?BaseHtmlElement */ - protected function createBadge(string $state) + protected function createBadge(string $state): ?BaseHtmlElement { $key = $this->prefix . "_{$state}"; - - if (isset($this->item->$key) && $this->item->$key) { - return Html::tag('li', $this->createLink( - new StateBadge($this->item->$key, $state), - Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)) - )); + if (empty($this->item->$key)) { + return null; } - return null; + $stateBadge = new StateBadge($this->item->$key, $state); + + if ($this->url !== null) { + $this->createLink( + $stateBadge, + Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)) + ); + } + + return new HtmlElement('li', null, $stateBadge); } /** @@ -156,34 +170,46 @@ abstract class StateBadges extends BaseHtmlElement * * @return ?BaseHtmlElement */ - protected function createGroup(string $state) + protected function createGroup(string $state): ?BaseHtmlElement { $content = []; $handledKey = $this->prefix . "_{$state}_handled"; $unhandledKey = $this->prefix . "_{$state}_unhandled"; if (isset($this->item->$unhandledKey) && $this->item->$unhandledKey) { - $content[] = Html::tag('li', $this->createLink( - new StateBadge($this->item->$unhandledKey, $state), - Filter::all( - Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)), - Filter::equal($this->type . '.state.is_handled', 'n'), - Filter::equal($this->type . '.state.is_reachable', 'y') - ) - )); + $unhandledStateBadge = new StateBadge($this->item->$unhandledKey, $state); + + if ($this->url !== null) { + $unhandledStateBadge = $this->createLink( + $unhandledStateBadge, + Filter::all( + Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)), + Filter::equal($this->type . '.state.is_handled', 'n'), + Filter::equal($this->type . '.state.is_reachable', 'y') + ) + ); + } + + $content[] = new HtmlElement('li', null, $unhandledStateBadge); } if (isset($this->item->$handledKey) && $this->item->$handledKey) { - $content[] = Html::tag('li', $this->createLink( - new StateBadge($this->item->$handledKey, $state, true), - Filter::all( - Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)), - Filter::any( - Filter::equal($this->type . '.state.is_handled', 'y'), - Filter::equal($this->type . '.state.is_reachable', 'n') + $handledStateBadge = new StateBadge($this->item->$handledKey, $state, true); + + if ($this->url !== null) { + $handledStateBadge = $this->createLink( + $handledStateBadge, + Filter::all( + Filter::equal($this->type . '.state.soft_state', $this->getStateInt($state)), + Filter::any( + Filter::equal($this->type . '.state.is_handled', 'y'), + Filter::equal($this->type . '.state.is_reachable', 'n') + ) ) - ) - )); + ); + } + + $content[] = new HtmlElement('li', null, $handledStateBadge); } if (empty($content)) { diff --git a/library/Icingadb/Model/RedundancyGroupSummary.php b/library/Icingadb/Model/RedundancyGroupSummary.php new file mode 100644 index 00000000..35ad03aa --- /dev/null +++ b/library/Icingadb/Model/RedundancyGroupSummary.php @@ -0,0 +1,159 @@ + new Expression('COUNT(*)'), + 'nodes_ok' => new Expression( + 'SUM(CASE' + . ' WHEN %s IS NOT NULL THEN (CASE WHEN %s = 0 THEN 1 ELSE 0 END)' + . ' WHEN %s = 0 THEN 1' + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.soft_state', + 'from.to.host.state.soft_state', + ] + ), + 'nodes_problem_handled' => new Expression( + 'SUM(CASE' + . " WHEN %s IS NOT NULL THEN (CASE WHEN %s = 2 AND (%s = 'y' OR %s = 'n') THEN 1 ELSE 0 END)" + . " WHEN %s = 1 AND (%s = 'y' OR %s = 'n') THEN 1" + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.soft_state', + 'from.to.service.state.is_handled', + 'from.to.service.state.is_reachable', + 'from.to.host.state.soft_state', + 'from.to.host.state.is_handled', + 'from.to.host.state.is_reachable', + ] + ), + 'nodes_problem_unhandled' => new Expression( + 'SUM(CASE' + . " WHEN %s IS NOT NULL THEN (CASE WHEN %s = 2 AND (%s = 'n' AND %s = 'y') THEN 1 ELSE 0 END)" + . " WHEN %s = 1 AND (%s = 'n' AND %s = 'y') THEN 1" + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.soft_state', + 'from.to.service.state.is_handled', + 'from.to.service.state.is_reachable', + 'from.to.host.state.soft_state', + 'from.to.host.state.is_handled', + 'from.to.host.state.is_reachable', + ] + ), + 'nodes_pending' => new Expression( + 'SUM(CASE' + . ' WHEN %s IS NOT NULL THEN (CASE WHEN %s = 99 THEN 1 ELSE 0 END)' + . ' WHEN %s = 99 THEN 1' + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.soft_state', + 'from.to.host.state.soft_state', + ] + ), + 'nodes_unknown_handled' => new Expression( + 'SUM(CASE' + . " WHEN %s IS NOT NULL THEN (CASE WHEN %s = 3 AND (%s = 'y' OR %s = 'n') THEN 1 ELSE 0 END)" + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.soft_state', + 'from.to.service.state.is_handled', + 'from.to.service.state.is_reachable' + ] + ), + 'nodes_unknown_unhandled' => new Expression( + 'SUM(CASE' + . " WHEN %s IS NOT NULL THEN (CASE WHEN %s = 3 AND (%s = 'n' AND %s = 'y') THEN 1 ELSE 0 END)" + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.soft_state', + 'from.to.service.state.is_handled', + 'from.to.service.state.is_reachable' + ] + ), + 'nodes_warning_handled' => new Expression( + 'SUM(CASE' + . " WHEN %s IS NOT NULL THEN (CASE WHEN %s = 1 AND (%s = 'y' OR %s = 'n') THEN 1 ELSE 0 END)" + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.soft_state', + 'from.to.service.state.is_handled', + 'from.to.service.state.is_reachable' + ] + ), + 'nodes_warning_unhandled' => new Expression( + 'SUM(CASE' + . " WHEN %s IS NOT NULL THEN (CASE WHEN %s = 1 AND (%s = 'n' AND %s = 'y') THEN 1 ELSE 0 END)" + . ' ELSE 0' + . ' END)', + [ + 'from.to.service_id', + 'from.to.service.state.soft_state', + 'from.to.service.state.is_handled', + 'from.to.service.state.is_reachable' + ] + ) + ]; + } + + public static function on(Connection $db): Query + { + $q = parent::on($db); + + /** @var static $m */ + $m = $q->getModel(); + $q->columns($m->getSummaryColumns()); + + $q->on($q::ON_SELECT_ASSEMBLED, function (Select $select) use ($q) { + $model = $q->getModel(); + $groupBy = $q->getResolver()->qualifyColumnsAndAliases((array) $model->getKeyName(), $model, false); + $select->groupBy($groupBy); + }); + + return $q; + } + + public function getColumns(): array + { + return array_merge(parent::getColumns(), $this->getSummaryColumns()); + } +} diff --git a/library/Icingadb/Model/Service.php b/library/Icingadb/Model/Service.php index dea47a75..d895789e 100644 --- a/library/Icingadb/Model/Service.php +++ b/library/Icingadb/Model/Service.php @@ -6,6 +6,7 @@ namespace Icinga\Module\Icingadb\Model; use Icinga\Module\Icingadb\Common\Auth; use Icinga\Module\Icingadb\Model\Behavior\BoolCast; +use Icinga\Module\Icingadb\Model\Behavior\HasProblematicParent; use Icinga\Module\Icingadb\Model\Behavior\ReRoute; use ipl\Orm\Behavior\Binary; use ipl\Orm\Behaviors; @@ -194,6 +195,8 @@ class Service extends Model 'zone_id', 'command_endpoint_id' ])); + + $behaviors->add(new HasProblematicParent()); } public function createDefaults(Defaults $defaults) diff --git a/library/Icingadb/Widget/DependencyNodeStateBadges.php b/library/Icingadb/Widget/DependencyNodeStateBadges.php new file mode 100644 index 00000000..6415257d --- /dev/null +++ b/library/Icingadb/Widget/DependencyNodeStateBadges.php @@ -0,0 +1,36 @@ +addAttributes(['class' => 'dependency-node-state-badges']); + + $this->add(array_filter([ + $this->createGroup('problem'), + $this->createGroup('warning'), + $this->createGroup('unknown'), + $this->createBadge('ok'), + $this->createBadge('pending') + ])); + } +} diff --git a/library/Icingadb/Widget/DependencyNodeStatistics.php b/library/Icingadb/Widget/DependencyNodeStatistics.php new file mode 100644 index 00000000..d7c3a5d7 --- /dev/null +++ b/library/Icingadb/Widget/DependencyNodeStatistics.php @@ -0,0 +1,49 @@ +summary = $summary; + } + + protected function createDonut(): ValidHtml + { + $donut = (new Donut()) + ->addSlice($this->summary->nodes_ok, ['class' => 'slice-state-ok']) + ->addSlice($this->summary->nodes_warning_handled, ['class' => 'slice-state-warning-handled']) + ->addSlice($this->summary->nodes_warning_unhandled, ['class' => 'slice-state-warning']) + ->addSlice($this->summary->nodes_problem_handled, ['class' => 'slice-state-critical-handled']) + ->addSlice($this->summary->nodes_problem_unhandled, ['class' => 'slice-state-critical']) + ->addSlice($this->summary->nodes_unknown_handled, ['class' => 'slice-state-unknown-handled']) + ->addSlice($this->summary->nodes_unknown_unhandled, ['class' => 'slice-state-unknown']) + ->addSlice($this->summary->nodes_pending, ['class' => 'slice-state-pending']); + + return HtmlString::create($donut->render()); + } + + protected function createTotal(): ValidHtml + { + return Text::create($this->shortenAmount($this->summary->nodes_total)); + } + + protected function createBadges(): ValidHtml + { + return new DependencyNodeStateBadges($this->summary); + } +} diff --git a/library/Icingadb/Widget/Detail/HostDetail.php b/library/Icingadb/Widget/Detail/HostDetail.php index 8b80480a..969b37ff 100644 --- a/library/Icingadb/Widget/Detail/HostDetail.php +++ b/library/Icingadb/Widget/Detail/HostDetail.php @@ -41,7 +41,8 @@ class HostDetail extends ObjectDetail } $this->add(ObjectDetailExtensionHook::injectExtensions([ - 0 => $this->createPluginOutput(), + 0 => $this->createRootProblems(), + 1 => $this->createPluginOutput(), 190 => $this->createServiceStatistics(), 300 => $this->createActions(), 301 => $this->createNotes(), diff --git a/library/Icingadb/Widget/Detail/ObjectDetail.php b/library/Icingadb/Widget/Detail/ObjectDetail.php index 58edac6b..35c017bc 100644 --- a/library/Icingadb/Widget/Detail/ObjectDetail.php +++ b/library/Icingadb/Widget/Detail/ObjectDetail.php @@ -20,9 +20,12 @@ use Icinga\Module\Icingadb\Common\Icons; use Icinga\Module\Icingadb\Common\Links; use Icinga\Module\Icingadb\Common\Macros; use Icinga\Module\Icingadb\Compat\CompatHost; -use Icinga\Module\Icingadb\Compat\CompatService; use Icinga\Module\Icingadb\Model\CustomvarFlat; +use Icinga\Module\Icingadb\Model\Service; +use Icinga\Module\Icingadb\Model\UnreachableParent; +use Icinga\Module\Icingadb\Redis\VolatileStateResults; use Icinga\Module\Icingadb\Web\Navigation\Action; +use Icinga\Module\Icingadb\Widget\ItemList\DependencyNodeList; use Icinga\Module\Icingadb\Widget\MarkdownText; use Icinga\Module\Icingadb\Common\ServiceLinks; use Icinga\Module\Icingadb\Forms\Command\Object\ToggleObjectFeaturesForm; @@ -373,7 +376,7 @@ class ObjectDetail extends BaseHtmlElement protected function createNotifications(): array { - list($users, $usergroups) = $this->getUsersAndUsergroups(); + [$users, $usergroups] = $this->getUsersAndUsergroups(); $userList = new TagList(); $usergroupList = new TagList(); @@ -602,4 +605,56 @@ class ObjectDetail extends BaseHtmlElement $this->object->customvar_flat = $customvarFlat->execute(); } } + + /** + * Create a list of root problems of the object that is unreachable because of dependency failure + * + * @return ?BaseHtmlElement[] + */ + protected function createRootProblems(): ?array + { + // If a dependency has failed, then the children are not reachable. Hence, the root problems should not be shown + // if the object is reachable. And in case of a service, since, it may be also be unreachable because of its + // host being down, only show its root problems if it's really caused by a dependency failure. + if ( + $this->object->state->is_reachable + || ($this->object instanceof Service && ! $this->object->has_problematic_parent) + ) { + return null; + } + + $rootProblems = UnreachableParent::on($this->getDb(), $this->object) + ->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(t('Root Problems'))), + (new DependencyNodeList($rootProblems))->setEmptyStateMessage( + t('You are not authorized to view these objects.') + ) + ]; + } } diff --git a/library/Icingadb/Widget/Detail/ServiceDetail.php b/library/Icingadb/Widget/Detail/ServiceDetail.php index 8421e314..86e7651f 100644 --- a/library/Icingadb/Widget/Detail/ServiceDetail.php +++ b/library/Icingadb/Widget/Detail/ServiceDetail.php @@ -21,7 +21,8 @@ class ServiceDetail extends ObjectDetail } $this->add(ObjectDetailExtensionHook::injectExtensions([ - 0 => $this->createPluginOutput(), + 0 => $this->createRootProblems(), + 1 => $this->createPluginOutput(), 300 => $this->createActions(), 301 => $this->createNotes(), 400 => $this->createComments(), diff --git a/library/Icingadb/Widget/ItemList/DependencyNodeList.php b/library/Icingadb/Widget/ItemList/DependencyNodeList.php new file mode 100644 index 00000000..04dfc6ff --- /dev/null +++ b/library/Icingadb/Widget/ItemList/DependencyNodeList.php @@ -0,0 +1,39 @@ + ['dependency-node-list']]; + + protected function init(): void + { + $this->initializeDetailActions(); + } + + protected function getItemClass(): string + { + return ''; + } + + 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); + } + } +} diff --git a/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php b/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php new file mode 100644 index 00000000..b370c432 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php @@ -0,0 +1,91 @@ + ['redundancy-group-list-item']]; + + /** @var RedundancyGroupState */ + protected $state; + + protected function getStateBallSize(): string + { + return StateBall::SIZE_LARGE; + } + + protected function createTimestamp(): BaseHtmlElement + { + return new TimeSince($this->state->last_state_change->getTimestamp()); + } + + protected function createSubject(): BaseHtmlElement + { + return new HtmlElement( + 'span', + Attributes::create(['class' => 'subject']), + Text::create($this->item->display_name) + ); + } + + protected function assembleVisual(BaseHtmlElement $visual): void + { + $visual->addHtml(new StateBall($this->state->getStateText(), $this->getStateBallSize())); + } + + protected function assembleCaption(BaseHtmlElement $caption): void + { + $caption->addHtml(new DependencyNodeStatistics( + RedundancyGroupSummary::on($this->getDb()) + ->filter(Filter::equal('id', $this->item->id)) + ->first() + )); + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml($this->createSubject()); + if ($this->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 assemble(): void + { + $this->add([ + $this->createVisual(), + $this->createIconImage(), + $this->createMain() + ]); + } +} diff --git a/public/css/common.less b/public/css/common.less index 39da032f..3a28d03b 100644 --- a/public/css/common.less +++ b/public/css/common.less @@ -197,7 +197,7 @@ div.show-more { margin-left: 1em / 1.333em; // 1em / h2 font size } -.object-detail .plugin-output { +.object-detail :not(.caption) > .plugin-output { .rounded-corners(.25em); background-color: @gray-lighter; padding: .5em; @@ -412,3 +412,13 @@ form[name="form_confirm_removal"] { padding: 0 0.25em; .rounded-corners(); } + +.state-ball { + &.state-unreachable { + background-color: @color-critical; + } + + &.state-reachable { + background-color: @color-ok; + } +} diff --git a/public/css/list/redundancy-group-list-item.less b/public/css/list/redundancy-group-list-item.less new file mode 100644 index 00000000..86139084 --- /dev/null +++ b/public/css/list/redundancy-group-list-item.less @@ -0,0 +1,6 @@ +.redundancy-group-list-item { + .caption { + display: flex; + justify-content: end; + } +} diff --git a/public/css/widget/dependency-node-state-badges.less b/public/css/widget/dependency-node-state-badges.less new file mode 100644 index 00000000..dabeecf9 --- /dev/null +++ b/public/css/widget/dependency-node-state-badges.less @@ -0,0 +1,9 @@ +.dependency-node-state-badges { + .state-badges(); + + .state-badge { + &.state-problem { + background-color: @color-critical; + } + } +}