diff --git a/application/controllers/ServiceController.php b/application/controllers/ServiceController.php index 8867e9185..2857718ae 100644 --- a/application/controllers/ServiceController.php +++ b/application/controllers/ServiceController.php @@ -38,13 +38,15 @@ public function init() $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/IcingaRedis.php b/library/Icingadb/Common/IcingaRedis.php index a22a0f03b..84264815b 100644 --- a/library/Icingadb/Common/IcingaRedis.php +++ b/library/Icingadb/Common/IcingaRedis.php @@ -8,6 +8,7 @@ use Generator; use Icinga\Application\Config; use Icinga\Application\Logger; +use ipl\Sql\Expression; use Predis\Client as Redis; class IcingaRedis @@ -163,7 +164,16 @@ protected static function fetchState(string $key, array $ids, array $columns): G foreach ($results as $i => $json) { if ($json !== null) { $data = json_decode($json, true); - $keyMap = array_fill_keys($columns, null); + $keyMap = []; + + foreach ($columns as $alias => $column) { + if ($column instanceof Expression) { + $keyMap[$alias] = $column->getStatement() ; + } else { + $keyMap[$alias] = null; + } + } + unset($keyMap['is_overdue']); // Is calculated by Icinga DB, not Icinga 2, hence it's never in redis // TODO: Remove once https://github.com/Icinga/icinga2/issues/9427 is fixed diff --git a/library/Icingadb/Common/StateBadges.php b/library/Icingadb/Common/StateBadges.php index c9c5c89f7..2836407bc 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 @@ public function __construct($item) $this->url = $this->getBaseUrl(); } - /** - * Get the badge base URL - * - * @return Url - */ - abstract protected function getBaseUrl(): Url; - /** * Get the type of the items * @@ -67,14 +61,29 @@ abstract protected function getType(): string; */ 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('%s is not a valid state', $state); + } /** * Get the badge URL @@ -135,15 +144,21 @@ public function createLink($content, Filter\Rule $filter = null): Link * * @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)) - )); + $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); } return null; @@ -156,34 +171,46 @@ protected function createBadge(string $state) * * @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/Behavior/HasProblematicParent.php b/library/Icingadb/Model/Behavior/HasProblematicParent.php new file mode 100644 index 000000000..99ee00800 --- /dev/null +++ b/library/Icingadb/Model/Behavior/HasProblematicParent.php @@ -0,0 +1,94 @@ +query = $query; + + return $this; + } + + public function rewriteColumn($column, ?string $relation = null): ?AliasedExpression + { + if (! $this->isSelectableColumn($column)) { + return null; + } + + $path = 'from.'; + $subQueryRelation = $relation !== null ? $relation . $path : $path; + $subQuery = $this->query->createSubQuery(new DependencyEdge(), $subQueryRelation, null, false) + ->limit(1) + ->columns([new Expression('1')]); + + $subQueryAlias = $subQuery->getResolver()->getAlias($subQuery->getModel()); + + $subQuery->getSelectBase() + ->join( + ['to_dependency_node' => 'dependency_node'], + ["to_dependency_node.id = $subQueryAlias.to_node_id"] + )->joinLeft( + ['root_dependency' => 'dependency'], + [ "$subQueryAlias.dependency_id = root_dependency.id"] + )->joinLeft( + ['root_dependency_state' => 'dependency_state'], + ['root_dependency.id = root_dependency_state.dependency_id'] + )->joinLeft( + ['root_group' => 'redundancy_group'], + ['root_group.id = to_dependency_node.redundancy_group_id'] + )->joinLeft( + ['root_group_state' => 'redundancy_group_state'], + ['root_group_state.redundancy_group_id = root_group.id'] + )->where( + new Expression("root_dependency_state.failed = 'y' OR root_group_state.failed = 'y'") + )->where($subQueryAlias . '_from.service_id = service.id'); + + $column = $relation !== null ? str_replace('.', '_', $relation) . "_$column" : $column; + + $alias = $this->query->getDb()->quoteIdentifier([$column]); + + list($select, $values) = $this->query->getDb() + ->getQueryBuilder() + ->assembleSelect($subQuery->assembleSelect()); + + return new AliasedExpression($alias, "($select)", null, ...$values); + } + + public function isSelectableColumn(string $name): bool + { + return $name === 'has_problematic_parent'; + } + + public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void + { + } + + public function rewriteCondition(Filter\Condition $condition, $relation = null): void + { + $column = substr($condition->getColumn(), strlen($relation)); + + if ($this->isSelectableColumn($column)) { + throw new InvalidColumnException($column, $this->query->getModel()); + } + } +} diff --git a/library/Icingadb/Model/Host.php b/library/Icingadb/Model/Host.php index 05c35bc07..57eef2b5a 100644 --- a/library/Icingadb/Model/Host.php +++ b/library/Icingadb/Model/Host.php @@ -13,6 +13,7 @@ use ipl\Orm\Model; use ipl\Orm\Relations; use ipl\Orm\ResultSet; +use ipl\Sql\Expression; /** * Host model. @@ -113,7 +114,7 @@ public function getColumns() 'zone_id', 'command_endpoint_name', 'command_endpoint_id', - 'affected_children' + 'affected_children' => new Expression('200000') ]; } diff --git a/library/Icingadb/Model/RedundancyGroupState.php b/library/Icingadb/Model/RedundancyGroupState.php index d6507d8e2..df30f581e 100644 --- a/library/Icingadb/Model/RedundancyGroupState.php +++ b/library/Icingadb/Model/RedundancyGroupState.php @@ -65,6 +65,7 @@ public function createRelations(Relations $relations): void public function getStateText(): string { - return $this->failed ? 'problem' : 'ok'; + // The method should only be called to fake state balls and not to show the group's state + return $this->failed ? 'unreachable' : 'reachable'; } } diff --git a/library/Icingadb/Model/RedundancyGroupSummary.php b/library/Icingadb/Model/RedundancyGroupSummary.php new file mode 100644 index 000000000..6a1cc52f0 --- /dev/null +++ b/library/Icingadb/Model/RedundancyGroupSummary.php @@ -0,0 +1,185 @@ + new Expression( + 'SUM(CASE' + . ' WHEN %s IS NOT NULL THEN 1' + . ' WHEN %s IS NOT NULL THEN 1' + . ' ELSE 0 END)', + [ + 'from.to.service_id', + 'from.to.host_id', + ] + ), + 'nodes_ok' => new Expression( + 'SUM(CASE' + . ' WHEN %s IS NOT NULL THEN (CASE WHEN %s = 0 THEN 1 ELSE 0 END)' + . ' WHEN %s IS NOT NULL THEN (CASE WHEN %s = 0 THEN 1 ELSE 0 END)' + . ' ELSE 0 END)', + [ + 'from.to.service.id', + 'from.to.service.state.soft_state', + 'from.to.host_id', + '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 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', + 'from.to.host_id', + '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 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', + 'from.to.host_id', + '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 IS NOT NULL THEN (CASE WHEN %s = 99 THEN 1 ELSE 0 END)' + . ' ELSE 0 END)', + [ + 'from.to.service.id', + 'from.to.service.state.soft_state', + 'from.to.host_id', + '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)->with([ + 'from', + 'from.to.host', + 'from.to.host.state', + 'from.to.service', + 'from.to.service.state' + ]); + + /** @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); + + // For PostgreSQL, ALL non-aggregate SELECT columns must appear in the GROUP BY clause: + if ($q->getDb()->getAdapter() instanceof Pgsql) { + /** + * Ignore Expressions, i.e. aggregate functions {@see getColumns()}, + * which do not need to be added to the GROUP BY. + */ + $candidates = array_filter($select->getColumns(), 'is_string'); + // Remove already considered columns for the GROUP BY, i.e. the primary key. + $candidates = array_diff_assoc($candidates, $groupBy); + $groupBy = array_merge($groupBy, $candidates); + } + + $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 dea47a75d..2cf7ab89b 100644 --- a/library/Icingadb/Model/Service.php +++ b/library/Icingadb/Model/Service.php @@ -6,6 +6,7 @@ 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; @@ -13,6 +14,7 @@ use ipl\Orm\Model; use ipl\Orm\Relations; use ipl\Orm\ResultSet; +use ipl\Sql\Expression; /** * @property string $id @@ -105,7 +107,7 @@ public function getColumns() 'zone_id', 'command_endpoint_name', 'command_endpoint_id', - 'affected_children' + 'affected_children' => new Expression('10') ]; } @@ -194,6 +196,8 @@ public function createBehaviors(Behaviors $behaviors) 'zone_id', 'command_endpoint_id' ])); + + $behaviors->add(new HasProblematicParent()); } public function createDefaults(Defaults $defaults) diff --git a/library/Icingadb/Model/State.php b/library/Icingadb/Model/State.php index a4556c1d5..fa2eab6e7 100644 --- a/library/Icingadb/Model/State.php +++ b/library/Icingadb/Model/State.php @@ -11,6 +11,7 @@ use ipl\Orm\Behavior\MillisecondTimestamp; use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Sql\Expression; use ipl\Web\Widget\Icon; /** @@ -100,7 +101,7 @@ public function getColumns() 'last_state_change', 'next_check', 'next_update', - 'affects_children' + 'affects_children' => new Expression("'y'") ]; } diff --git a/library/Icingadb/Widget/DependencyNodeStateBadges.php b/library/Icingadb/Widget/DependencyNodeStateBadges.php new file mode 100644 index 000000000..6415257d1 --- /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 000000000..53e8d5e23 --- /dev/null +++ b/library/Icingadb/Widget/DependencyNodeStatistics.php @@ -0,0 +1,51 @@ +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 8b80480ac..969b37ffb 100644 --- a/library/Icingadb/Widget/Detail/HostDetail.php +++ b/library/Icingadb/Widget/Detail/HostDetail.php @@ -41,7 +41,8 @@ protected function assemble() } $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 58edac6b2..9a95f297d 100644 --- a/library/Icingadb/Widget/Detail/ObjectDetail.php +++ b/library/Icingadb/Widget/Detail/ObjectDetail.php @@ -20,9 +20,11 @@ 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\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; @@ -602,4 +604,48 @@ protected function fetchCustomVars() $this->object->customvar_flat = $customvarFlat->execute(); } } + + 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 not 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 they exist. + 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' + ])->orderBy([ + 'host.state.severity' => SORT_DESC, + 'host.state.last_state_change' => SORT_DESC, + 'service.state.severity' => SORT_DESC, + 'service.state.last_state_change' => SORT_DESC, + 'service.host.state.severity' => SORT_DESC, + 'service.host.state.last_state_change' => SORT_DESC, + 'redundancy_group.state.last_state_change' => SORT_DESC + ]); + + $this->applyRestrictions($rootProblems); + + return [ + HtmlElement::create('h2', null, Text::create(t('Root Problems'))), + new DependencyNodeList($rootProblems) + ]; + } } diff --git a/library/Icingadb/Widget/Detail/ServiceDetail.php b/library/Icingadb/Widget/Detail/ServiceDetail.php index 8421e314f..86e7651ff 100644 --- a/library/Icingadb/Widget/Detail/ServiceDetail.php +++ b/library/Icingadb/Widget/Detail/ServiceDetail.php @@ -21,7 +21,8 @@ protected function assemble() } $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 000000000..04dfc6ffa --- /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 000000000..ed4f02b4c --- /dev/null +++ b/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php @@ -0,0 +1,173 @@ + ['list-item', 'redundancy-group-list-item']]; + + /** @var RedundancyGroupSummary Objects state summary */ + protected $summary; + + /** @var RedundancyGroupState */ + protected $state; + + /** @var bool Whether the redundancy group has been handled */ + protected $isHandled = false; + + protected function init(): void + { + parent::init(); + + $this->summary = RedundancyGroupSummary::on($this->getDb()) + ->filter(Filter::equal('id', $this->item->id)) + ->first(); + + $this->isHandled = $this->state->failed + && ( + $this->summary->nodes_problem_handled + || $this->summary->nodes_unknown_handled + || $this->summary->nodes_warning_handled + ); + } + + 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 + { + $stateBall = new StateBall($this->state->getStateText(), $this->getStateBallSize()); + if ($this->isHandled) { + $stateBall->getAttributes()->add('class', 'handled'); + } + + $visual->addHtml($stateBall); + } + + protected function assembleCaption(BaseHtmlElement $caption): void + { + $members = RedundancyGroup::on($this->getDb()) + ->columns([ + 'id' => 'id', + 'nodes_output' => new Expression( + 'CASE WHEN redundancy_group_from_to_service.id IS NOT NULL' + . ' THEN redundancy_group_from_to_service_state.output' + . ' ELSE redundancy_group_from_to_host_state.output END' + ), + 'nodes_long_output' => new Expression( + 'CASE WHEN redundancy_group_from_to_service.id IS NOT NULL' + . ' THEN redundancy_group_from_to_service_state.long_output' + . ' ELSE redundancy_group_from_to_host_state.long_output END' + ), + 'nodes_checkcommand_name' => new Expression( + 'CASE WHEN redundancy_group_from_to_service.id IS NOT NULL' + . ' THEN redundancy_group_from_to_service.checkcommand_name' + . ' ELSE redundancy_group_from_to_host.checkcommand_name END' + ), + 'nodes_last_state_change' => new Expression( + 'CASE WHEN redundancy_group_from_to_service.id IS NOT NULL' + . ' THEN redundancy_group_from_to_service_state.last_state_change' + . ' ELSE redundancy_group_from_to_host_state.last_state_change END' + ), + 'nodes_severity' => new Expression( + 'CASE WHEN redundancy_group_from_to_service.id IS NOT NULL' + . ' THEN redundancy_group_from_to_service_state.severity' + . ' ELSE redundancy_group_from_to_host_state.severity END' + ) + ]) + ->with([ + 'from', + 'from.to.host', + 'from.to.host.state', + 'from.to.service', + 'from.to.service.state' + ]) + ->filter(Filter::equal('id', $this->item->id)) + ->orderBy([ + 'nodes_severity', + 'nodes_last_state_change', + ], SORT_DESC); + + $this->applyRestrictions($members); + + /** @var RedundancyGroup $data */ + $data = $members->first(); + + if ($data) { + $caption->addHtml(new PluginOutputContainer( + (new PluginOutput($data->nodes_output . "\n" . $data->nodes_long_output)) + ->setCommandName($data->nodes_checkcommand_name) + )); + } + + $caption->addHtml(new DependencyNodeStatistics($this->summary)); + } + + protected function assembleTitle(BaseHtmlElement $title): void + { + $title->addHtml($this->createSubject()); + if ($this->state->failed) { + $title->addHtml(HtmlElement::create( + 'span', + null, + Text::create($this->translate('has no working objects')) + )); + } else { + $title->addHtml(HtmlElement::create('span', null, Text::create($this->translate('has working objects')))); + } + } + + protected function assemble(): void + { + $this->add([ + $this->createVisual(), + $this->createIconImage(), + $this->createMain() + ]); + } +} diff --git a/library/Icingadb/Widget/ItemList/StateListItem.php b/library/Icingadb/Widget/ItemList/StateListItem.php index 18e73dae4..f8704d8a6 100644 --- a/library/Icingadb/Widget/ItemList/StateListItem.php +++ b/library/Icingadb/Widget/ItemList/StateListItem.php @@ -12,6 +12,7 @@ use Icinga\Module\Icingadb\Widget\PluginOutputContainer; use ipl\Html\Attributes; use ipl\Html\HtmlElement; +use ipl\I18n\Translation; use ipl\Web\Common\BaseListItem; use ipl\Web\Widget\EmptyState; use ipl\Web\Widget\TimeSince; @@ -26,6 +27,8 @@ */ abstract class StateListItem extends BaseListItem { + use Translation; + /** @var StateList The list where the item is part of */ protected $list; @@ -66,10 +69,10 @@ protected function createIconImage(): ?BaseHtmlElement protected function assembleCaption(BaseHtmlElement $caption): void { if ($this->state->soft_state === null && $this->state->output === null) { - $caption->addHtml(Text::create(t('Waiting for Icinga DB to synchronize the state.'))); + $caption->addHtml(Text::create($this->translate('Waiting for Icinga DB to synchronize the state.'))); } else { if (empty($this->state->output)) { - $pluginOutput = new EmptyState(t('Output unavailable.')); + $pluginOutput = new EmptyState($this->translate('Output unavailable.')); } else { $pluginOutput = new PluginOutputContainer(PluginOutput::fromObject($this->item)); } @@ -90,16 +93,26 @@ protected function assembleIconImage(BaseHtmlElement $iconImage): void protected function assembleTitle(BaseHtmlElement $title): void { $title->addHtml(Html::sprintf( - t('%s is %s', ' is '), + $this->translate('%s is %s', ' is '), $this->createSubject(), Html::tag('span', ['class' => 'state-text'], $this->state->getStateTextTranslated()) )); if ($this->state->affects_children) { - $total = $this->item->affected_children; + $total = (int) $this->item->affected_children; - if ((int) $total > 1000) { + if ($total > 1000) { $total = '1000+'; + $tooltip = $this->translate('Up to 1000+ affected objects'); + } else { + $tooltip = sprintf( + $this->translatePlural( + '%d affected object', + 'Up to %d affected objects', + $total + ), + $total + ); } $icon = new Icon(Icons::UNREACHABLE); @@ -108,7 +121,7 @@ protected function assembleTitle(BaseHtmlElement $title): void 'span', Attributes::create([ 'class' => 'affected-objects', - 'title' => sprintf(t('Up to %s affected objects'), $total) + 'title' => $tooltip ]), $icon, Text::create($total) @@ -137,7 +150,7 @@ protected function createTimestamp(): ?BaseHtmlElement $since = null; if ($this->state->is_overdue) { $since = new TimeSince($this->state->next_update->getTimestamp()); - $since->prepend(t('Overdue') . ' '); + $since->prepend($this->translate('Overdue') . ' '); $since->prependHtml(new Icon(Icons::WARNING)); } elseif ($this->state->last_state_change !== null && $this->state->last_state_change->getTimestamp() > 0) { $since = new TimeSince($this->state->last_state_change->getTimestamp()); diff --git a/public/css/common.less b/public/css/common.less index 39da032f8..be596dd74 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,19 @@ 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; + } +} + +.state-badge { + &.state-problem { + background-color: @color-critical; + } +} 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 000000000..7826323da --- /dev/null +++ b/public/css/list/redundancy-group-list-item.less @@ -0,0 +1,20 @@ +.redundancy-group-list-item { + .caption { + display: flex; + justify-content: space-between; + + .plugin-output { + .line-clamp(2); + } + + .object-statistics { + ul { + display: flex; + } + + .state-badges { + font-size: 0.75em; + } + } + } +} 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 000000000..0a2e71e64 --- /dev/null +++ b/public/css/widget/dependency-node-state-badges.less @@ -0,0 +1,3 @@ +.dependency-node-state-badges { + .state-badges(); +}