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/StateBadges.php b/library/Icingadb/Common/StateBadges.php index c9c5c89f7..b55eeeb3a 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,21 +61,36 @@ 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(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 @@ public function setUrl(Url $url): self * * @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 @@ 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 (empty($this->item->$key)) { + return null; + } - if (isset($this->item->$key) && $this->item->$key) { - return Html::tag('li', $this->createLink( - new StateBadge($this->item->$key, $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 null; + return new HtmlElement('li', null, $stateBadge); } /** @@ -156,34 +170,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..840479e19 --- /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; + } + + $resolver = $this->query->getResolver(); + if ($relation !== null) { + $serviceTableAlias = $resolver->getAlias($resolver->resolveRelation($relation)->getTarget()); + $column = $resolver->qualifyColumnAlias($column, $serviceTableAlias); + } else { + $serviceTableAlias = $resolver->getAlias($this->query->getModel()); + } + + $subQueryModel = new DependencyEdge(); + $subQuery = (new Query()) + ->setDb($this->query->getDb()) + ->setModel($subQueryModel) + ->columns([new Expression('1')]) + ->utilize('from') + ->limit(1) + ->filter(Filter::equal('dependency.state.failed', 'y')); + + $subQueryResolver = $subQuery->getResolver()->setAliasPrefix('hpp_'); + $subQueryTarget = $subQueryResolver->resolveRelation($subQueryModel->getTableName() . '.from')->getTarget(); + $targetForeignKey = $subQueryResolver->qualifyColumn( + 'service_id', + $subQueryResolver->getAlias($subQueryTarget) + ); + + $subQuery->getSelectBase() + ->where("$targetForeignKey = {$resolver->qualifyColumn('id', $serviceTableAlias)}"); + + [$select, $values] = $this->query->getDb() + ->getQueryBuilder() + ->assembleSelect($subQuery->assembleSelect()); + + return new AliasedExpression( + $this->query->getDb()->quoteIdentifier([$column]), + "($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) + { + $column = substr($condition->getColumn(), strlen($relation ?? '')); + + if ($this->isSelectableColumn($column)) { + throw new InvalidColumnException($column, $this->query->getModel()); + } + } +} 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..35ad03aaf --- /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 dea47a75d..d895789ec 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; @@ -194,6 +195,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/UnreachableParent.php b/library/Icingadb/Model/UnreachableParent.php index de3608a3c..647e989a7 100644 --- a/library/Icingadb/Model/UnreachableParent.php +++ b/library/Icingadb/Model/UnreachableParent.php @@ -32,7 +32,7 @@ * @property (?RedundancyGroup)|Query $redundancy_group * @property (?Dependency)|Query $dependency */ -class UnreachableParent extends Model +class UnreachableParent extends DependencyNode { public function getTableName(): string { diff --git a/library/Icingadb/Redis/VolatileStateResults.php b/library/Icingadb/Redis/VolatileStateResults.php index 089b93ca6..c56db2721 100644 --- a/library/Icingadb/Redis/VolatileStateResults.php +++ b/library/Icingadb/Redis/VolatileStateResults.php @@ -7,6 +7,7 @@ use Icinga\Application\Benchmark; use Icinga\Module\Icingadb\Common\Auth; use Icinga\Module\Icingadb\Common\IcingaRedis; +use Icinga\Module\Icingadb\Model\DependencyNode; use Icinga\Module\Icingadb\Model\Host; use Icinga\Module\Icingadb\Model\Service; use ipl\Orm\Query; @@ -108,7 +109,19 @@ protected function applyRedisUpdates($rows) $states = []; $hostStates = []; foreach ($rows as $row) { - if ($type === null) { + if ($row instanceof DependencyNode) { + if ($row->redundancy_group_id !== null) { + continue; + } elseif ($row->service_id !== null) { + $type = 'service'; + $row = $row->service; + } else { + $type = 'host'; + $row = $row->host; + } + + $behaviors = $this->resolver->getBehaviors($row->state); + } elseif ($type === null) { $behaviors = $this->resolver->getBehaviors($row->state); switch (true) { 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..d7c3a5d7a --- /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 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..35c017bc6 100644 --- a/library/Icingadb/Widget/Detail/ObjectDetail.php +++ b/library/Icingadb/Widget/Detail/ObjectDetail.php @@ -20,9 +20,12 @@ 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 @@ protected function createNotes() protected function createNotifications(): array { - list($users, $usergroups) = $this->getUsersAndUsergroups(); + [$users, $usergroups] = $this->getUsersAndUsergroups(); $userList = new TagList(); $usergroupList = new TagList(); @@ -602,4 +605,56 @@ protected function fetchCustomVars() $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 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..2b87723d2 --- /dev/null +++ b/library/Icingadb/Widget/ItemList/RedundancyGroupListItem.php @@ -0,0 +1,89 @@ + ['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/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..3a28d03b0 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 000000000..86139084d --- /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 000000000..dabeecf9a --- /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; + } + } +} diff --git a/test/php/library/Icingadb/Common/StateBadgesTest.php b/test/php/library/Icingadb/Common/StateBadgesTest.php index b535e655e..0b9fd5e22 100644 --- a/test/php/library/Icingadb/Common/StateBadgesTest.php +++ b/test/php/library/Icingadb/Common/StateBadgesTest.php @@ -9,6 +9,7 @@ use ipl\Stdlib\Filter; use ipl\Web\Filter\QueryString; use ipl\Web\Url; +use ipl\Web\Widget\Link; use PHPUnit\Framework\TestCase; class StateBadgesTest extends TestCase @@ -21,7 +22,7 @@ public function testCreateLinkRendersBaseFilterCorrectly() Filter::equal('bar', 'foo') )); - $link = $stateBadges->createLink('test', Filter::equal('rab', 'oof')); + $link = $stateBadges->generateLink('test', Filter::equal('rab', 'oof')); $this->assertSame( 'rab=oof&(foo=bar|bar=foo)', @@ -81,6 +82,11 @@ protected function getStateInt(string $state): int { return 0; } + + public function generateLink($content, Filter\Rule $filter = null): Link + { + return parent::createLink($content, $filter); + } }; } }