summaryrefslogtreecommitdiffstats
path: root/library/Icingadb/Web
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icingadb/Web')
-rw-r--r--library/Icingadb/Web/Control/GridViewModeSwitcher.php38
-rw-r--r--library/Icingadb/Web/Control/ProblemToggle.php74
-rw-r--r--library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php406
-rw-r--r--library/Icingadb/Web/Control/ViewModeSwitcher.php219
-rw-r--r--library/Icingadb/Web/Controller.php542
-rw-r--r--library/Icingadb/Web/Navigation/Action.php134
-rw-r--r--library/Icingadb/Web/Navigation/IcingadbHostAction.php9
-rw-r--r--library/Icingadb/Web/Navigation/IcingadbServiceAction.php9
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php35
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php173
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php36
-rw-r--r--library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php66
12 files changed, 1741 insertions, 0 deletions
diff --git a/library/Icingadb/Web/Control/GridViewModeSwitcher.php b/library/Icingadb/Web/Control/GridViewModeSwitcher.php
new file mode 100644
index 0000000..df5524b
--- /dev/null
+++ b/library/Icingadb/Web/Control/GridViewModeSwitcher.php
@@ -0,0 +1,38 @@
+<?php
+
+/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control;
+
+/**
+ * View mode switcher to toggle between grid and list view
+ */
+class GridViewModeSwitcher extends ViewModeSwitcher
+{
+ /** @var string Default view mode */
+ public const DEFAULT_VIEW_MODE = 'list';
+
+ /** @var array View mode-icon pairs */
+ public static $viewModes = [
+ 'list' => 'default',
+ 'grid' => 'grid'
+ ];
+
+ protected function getTitle(string $viewMode): string
+ {
+ $active = null;
+ $inactive = null;
+ switch ($viewMode) {
+ case 'list':
+ $active = t('List view active');
+ $inactive = t('Switch to list view');
+ break;
+ case 'grid':
+ $active = t('Grid view active');
+ $inactive = t('Switch to grid view');
+ break;
+ }
+
+ return $viewMode === $this->getViewMode() ? $active : $inactive;
+ }
+}
diff --git a/library/Icingadb/Web/Control/ProblemToggle.php b/library/Icingadb/Web/Control/ProblemToggle.php
new file mode 100644
index 0000000..c5aed82
--- /dev/null
+++ b/library/Icingadb/Web/Control/ProblemToggle.php
@@ -0,0 +1,74 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control;
+
+use ipl\Web\Common\FormUid;
+use ipl\Web\Compat\CompatForm;
+
+class ProblemToggle extends CompatForm
+{
+ use FormUid;
+
+ protected $filter;
+
+ protected $protector;
+
+ protected $defaultAttributes = [
+ 'name' => 'problem-toggle',
+ 'class' => 'icinga-form icinga-controls inline'
+ ];
+
+ public function __construct($filter)
+ {
+ $this->filter = $filter;
+ }
+
+ /**
+ * Set callback to protect ids with
+ *
+ * @param callable $protector
+ *
+ * @return $this
+ */
+ public function setIdProtector(callable $protector): self
+ {
+ $this->protector = $protector;
+
+ return $this;
+ }
+
+ /**
+ * Get whether the toggle is checked
+ *
+ * @return bool
+ */
+ public function isChecked(): bool
+ {
+ $this->ensureAssembled();
+
+ return $this->getElement('problems')->isChecked();
+ }
+
+ protected function assemble()
+ {
+ $this->addElement('checkbox', 'problems', [
+ 'class' => 'autosubmit',
+ 'id' => $this->protectId('problems'),
+ 'label' => t('Problems Only'),
+ 'value' => $this->filter !== null
+ ]);
+
+ $this->add($this->createUidElement());
+ }
+
+ private function protectId($id)
+ {
+ if (is_callable($this->protector)) {
+ return call_user_func($this->protector, $id);
+ }
+
+ return $id;
+ }
+}
diff --git a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
new file mode 100644
index 0000000..b89e729
--- /dev/null
+++ b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php
@@ -0,0 +1,406 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control\SearchBar;
+
+use Generator;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Model\Behavior\ReRoute;
+use Icinga\Module\Icingadb\Model\CustomvarFlat;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Module\Icingadb\Util\ObjectSuggestionsCursor;
+use ipl\Html\HtmlElement;
+use ipl\Orm\Exception\InvalidColumnException;
+use ipl\Orm\Exception\InvalidRelationException;
+use ipl\Orm\Model;
+use ipl\Orm\Relation;
+use ipl\Orm\Relation\BelongsToMany;
+use ipl\Orm\Relation\HasOne;
+use ipl\Orm\Resolver;
+use ipl\Orm\UnionModel;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Seq;
+use ipl\Web\Control\SearchBar\SearchException;
+use ipl\Web\Control\SearchBar\Suggestions;
+use PDO;
+
+class ObjectSuggestions extends Suggestions
+{
+ use Auth;
+ use Database;
+
+ /** @var Model */
+ protected $model;
+
+ /** @var array */
+ protected $customVarSources;
+
+ public function __construct()
+ {
+ $this->customVarSources = [
+ 'checkcommand' => t('Checkcommand %s', '..<customvar-name>'),
+ 'eventcommand' => t('Eventcommand %s', '..<customvar-name>'),
+ 'host' => t('Host %s', '..<customvar-name>'),
+ 'hostgroup' => t('Hostgroup %s', '..<customvar-name>'),
+ 'notification' => t('Notification %s', '..<customvar-name>'),
+ 'notificationcommand' => t('Notificationcommand %s', '..<customvar-name>'),
+ 'service' => t('Service %s', '..<customvar-name>'),
+ 'servicegroup' => t('Servicegroup %s', '..<customvar-name>'),
+ 'timeperiod' => t('Timeperiod %s', '..<customvar-name>'),
+ 'user' => t('User %s', '..<customvar-name>'),
+ 'usergroup' => t('Usergroup %s', '..<customvar-name>')
+ ];
+ }
+
+ /**
+ * Set the model to show suggestions for
+ *
+ * @param string|Model $model
+ *
+ * @return $this
+ */
+ public function setModel($model): self
+ {
+ if (is_string($model)) {
+ $model = new $model();
+ }
+
+ $this->model = $model;
+
+ return $this;
+ }
+
+ /**
+ * Get the model to show suggestions for
+ *
+ * @return Model
+ */
+ public function getModel(): Model
+ {
+ if ($this->model === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->model;
+ }
+
+ protected function shouldShowRelationFor(string $column): bool
+ {
+ if (strpos($column, '.vars.') !== false) {
+ return false;
+ }
+
+ $tableName = $this->getModel()->getTableName();
+ $columnPath = explode('.', $column);
+
+ switch (count($columnPath)) {
+ case 3:
+ if ($columnPath[1] !== 'state' || ! in_array($tableName, ['host', 'service'])) {
+ return true;
+ }
+
+ // For host/service state relation columns apply the same rules
+ case 2:
+ return $columnPath[0] !== $tableName;
+ default:
+ return true;
+ }
+ }
+
+ protected function createQuickSearchFilter($searchTerm)
+ {
+ $model = $this->getModel();
+ $resolver = $model::on($this->getDb())->getResolver();
+
+ $quickFilter = Filter::any();
+ foreach ($model->getSearchColumns() as $column) {
+ $where = Filter::like($resolver->qualifyColumn($column, $model->getTableName()), $searchTerm);
+ $where->metaData()->set('columnLabel', $resolver->getColumnDefinition($where->getColumn())->getLabel());
+ $quickFilter->add($where);
+ }
+
+ return $quickFilter;
+ }
+
+ protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $searchFilter)
+ {
+ $model = $this->getModel();
+ $query = $model::on($this->getDb());
+ $query->limit(static::DEFAULT_LIMIT);
+
+ if (strpos($column, ' ') !== false) {
+ // $column may be a label
+ list($path, $_) = Seq::find(
+ self::collectFilterColumns($query->getModel(), $query->getResolver()),
+ $column,
+ false
+ );
+ if ($path !== null) {
+ $column = $path;
+ }
+ }
+
+ $columnPath = $query->getResolver()->qualifyPath($column, $model->getTableName());
+ list($targetPath, $columnName) = preg_split('/(?<=vars)\.|\.(?=[^.]+$)/', $columnPath, 2);
+
+ $isCustomVar = false;
+ if (substr($targetPath, -5) === '.vars') {
+ $isCustomVar = true;
+ $targetPath = substr($targetPath, 0, -4) . 'customvar_flat';
+ }
+
+ if (strpos($targetPath, '.') !== false) {
+ try {
+ $query->with($targetPath); // TODO: Remove this, once ipl/orm does it as early
+ } catch (InvalidRelationException $e) {
+ throw new SearchException(sprintf(t('"%s" is not a valid relation'), $e->getRelation()));
+ }
+ }
+
+ if ($isCustomVar) {
+ $columnPath = $targetPath . '.flatvalue';
+ $query->filter(Filter::like($targetPath . '.flatname', $columnName));
+ }
+
+ $inputFilter = Filter::like($columnPath, $searchTerm);
+ $query->columns($columnPath);
+ $query->orderBy($columnPath);
+
+ // This had so many iterations, if it still doesn't work, consider removing it entirely :(
+ if ($searchFilter instanceof Filter\None) {
+ $query->filter($inputFilter);
+ } elseif ($searchFilter instanceof Filter\All) {
+ $searchFilter->add($inputFilter);
+
+ // There may be columns part of $searchFilter which target the base table. These must be
+ // optimized, otherwise they influence what we'll suggest to the user. (i.e. less)
+ // The $inputFilter on the other hand must not be optimized, which it wouldn't, but since
+ // we force optimization on its parent chain, we have to negate that.
+ $searchFilter->metaData()->set('forceOptimization', true);
+ $inputFilter->metaData()->set('forceOptimization', false);
+ } else {
+ $searchFilter = $inputFilter;
+ }
+
+ $query->filter($searchFilter);
+ $this->applyRestrictions($query);
+
+ try {
+ return (new ObjectSuggestionsCursor($query->getDb(), $query->assembleSelect()->distinct()))
+ ->setFetchMode(PDO::FETCH_COLUMN);
+ } catch (InvalidColumnException $e) {
+ throw new SearchException(sprintf(t('"%s" is not a valid column'), $e->getColumn()));
+ }
+ }
+
+ protected function fetchColumnSuggestions($searchTerm)
+ {
+ $model = $this->getModel();
+ $query = $model::on($this->getDb());
+
+ // Ordinary columns first
+ foreach (self::collectFilterColumns($model, $query->getResolver()) as $columnName => $columnMeta) {
+ yield $columnName => $columnMeta;
+ }
+
+ // Custom variables only after the columns are exhausted and there's actually a chance the user sees them
+ $titleAdded = false;
+ $parsedArrayVars = [];
+ foreach ($this->getDb()->select($this->queryCustomvarConfig($searchTerm)) as $customVar) {
+ $search = $name = $customVar->flatname;
+ if (preg_match('/\w+(?:\[(\d*)])+$/', $search, $matches)) {
+ $name = substr($search, 0, -(strlen($matches[1]) + 2));
+ if (isset($parsedArrayVars[$name])) {
+ continue;
+ }
+
+ $parsedArrayVars[$name] = true;
+ $search = $name . '[*]';
+ }
+
+ foreach ($this->customVarSources as $relation => $label) {
+ if (isset($customVar->$relation)) {
+ if (! $titleAdded) {
+ $titleAdded = true;
+ $this->addHtml(HtmlElement::create(
+ 'li',
+ ['class' => static::SUGGESTION_TITLE_CLASS],
+ t('Custom Variables')
+ ));
+ }
+
+ yield $relation . '.vars.' . $search => sprintf($label, $name);
+ }
+ }
+ }
+ }
+
+ protected function matchSuggestion($path, $label, $searchTerm)
+ {
+ if (preg_match('/[_.](id|bin|checksum)$/', $path)) {
+ // Only suggest exotic columns if the user knows about them
+ $trimmedSearch = trim($searchTerm, ' *');
+ return substr($path, -strlen($trimmedSearch)) === $trimmedSearch;
+ }
+
+ return parent::matchSuggestion($path, $label, $searchTerm);
+ }
+
+ /**
+ * Create a query to fetch all available custom variables matching the given term
+ *
+ * @param string $searchTerm
+ *
+ * @return Select
+ */
+ protected function queryCustomvarConfig(string $searchTerm): Select
+ {
+ $customVars = CustomvarFlat::on($this->getDb());
+ $tableName = $customVars->getModel()->getTableName();
+ $resolver = $customVars->getResolver();
+
+ $scalarQueries = [];
+ $aggregates = ['flatname'];
+ foreach ($resolver->getRelations($customVars->getModel()) as $name => $relation) {
+ if (isset($this->customVarSources[$name]) && $relation instanceof BelongsToMany) {
+ $query = $customVars->createSubQuery(
+ $relation->getTarget(),
+ $resolver->qualifyPath($name, $tableName)
+ );
+
+ $this->applyRestrictions($query);
+
+ $aggregates[$name] = new Expression("MAX($name)");
+ $scalarQueries[$name] = $query->assembleSelect()
+ ->resetColumns()->columns(new Expression('1'))
+ ->limit(1);
+ }
+ }
+
+ $customVars->columns('flatname');
+ $this->applyRestrictions($customVars);
+ $customVars->filter(Filter::like('flatname', $searchTerm));
+ $idColumn = $resolver->qualifyColumn('id', $resolver->getAlias($customVars->getModel()));
+ $customVars = $customVars->assembleSelect();
+
+ $customVars->columns($scalarQueries);
+ $customVars->groupBy($idColumn);
+ $customVars->limit(static::DEFAULT_LIMIT);
+
+ // This outer query exists only because there's no way to combine aggregates and sub queries (yet)
+ return (new Select())->columns($aggregates)->from(['results' => $customVars])->groupBy('flatname');
+ }
+
+ /**
+ * Collect all columns of this model and its relations that can be used for filtering
+ *
+ * @param Model $model
+ * @param Resolver $resolver
+ *
+ * @return Generator
+ */
+ public static function collectFilterColumns(Model $model, Resolver $resolver): Generator
+ {
+ if ($model instanceof UnionModel) {
+ $models = [];
+ foreach ($model->getUnions() as $union) {
+ /** @var Model $unionModel */
+ $unionModel = new $union[0]();
+ $models[$unionModel->getTableName()] = $unionModel;
+ self::collectRelations($resolver, $unionModel, $models, []);
+ }
+ } else {
+ $models = [$model->getTableName() => $model];
+ self::collectRelations($resolver, $model, $models, []);
+ }
+
+ /** @var Model $targetModel */
+ foreach ($models as $path => $targetModel) {
+ foreach ($resolver->getColumnDefinitions($targetModel) as $columnName => $definition) {
+ yield $path . '.' . $columnName => $definition->getLabel();
+ }
+ }
+
+ foreach ($resolver->getBehaviors($model) as $behavior) {
+ if ($behavior instanceof ReRoute) {
+ foreach ($behavior->getRoutes() as $name => $route) {
+ $relation = $resolver->resolveRelation(
+ $resolver->qualifyPath($route, $model->getTableName()),
+ $model
+ );
+ foreach ($resolver->getColumnDefinitions($relation->getTarget()) as $columnName => $definition) {
+ yield $name . '.' . $columnName => $definition->getLabel();
+ }
+ }
+ }
+ }
+
+ if ($model instanceof UnionModel) {
+ $queries = $model->getUnions();
+ $baseModelClass = end($queries)[0];
+ $model = new $baseModelClass();
+ }
+
+ $foreignMetaDataSources = [];
+ if (! $model instanceof Host) {
+ $foreignMetaDataSources[] = 'host.user';
+ $foreignMetaDataSources[] = 'host.usergroup';
+ }
+
+ if (! $model instanceof Service) {
+ $foreignMetaDataSources[] = 'service.user';
+ $foreignMetaDataSources[] = 'service.usergroup';
+ }
+
+ foreach ($foreignMetaDataSources as $path) {
+ $foreignColumnDefinitions = $resolver->getColumnDefinitions($resolver->resolveRelation(
+ $resolver->qualifyPath($path, $model->getTableName()),
+ $model
+ )->getTarget());
+ foreach ($foreignColumnDefinitions as $columnName => $columnDefinition) {
+ yield "$path.$columnName" => $columnDefinition->getLabel();
+ }
+ }
+ }
+
+ /**
+ * Collect all direct relations of the given model
+ *
+ * A direct relation is either a direct descendant of the model
+ * or a descendant of such related in a to-one cardinality.
+ *
+ * @param Resolver $resolver
+ * @param Model $subject
+ * @param array $models
+ * @param array $path
+ */
+ protected static function collectRelations(Resolver $resolver, Model $subject, array &$models, array $path)
+ {
+ foreach ($resolver->getRelations($subject) as $name => $relation) {
+ /** @var Relation $relation */
+ if (
+ empty($path) || (
+ ($name === 'state' && $path[count($path) - 1] !== 'last_comment')
+ || $name === 'last_comment'
+ || $name === 'notificationcommand' && $path[0] === 'notification'
+ )
+ ) {
+ $relationPath = [$name];
+ if ($relation instanceof HasOne && empty($path)) {
+ array_unshift($relationPath, $subject->getTableName());
+ }
+
+ $relationPath = array_merge($path, $relationPath);
+ $models[join('.', $relationPath)] = $relation->getTarget();
+ self::collectRelations($resolver, $relation->getTarget(), $models, $relationPath);
+ }
+ }
+ }
+}
diff --git a/library/Icingadb/Web/Control/ViewModeSwitcher.php b/library/Icingadb/Web/Control/ViewModeSwitcher.php
new file mode 100644
index 0000000..8068aee
--- /dev/null
+++ b/library/Icingadb/Web/Control/ViewModeSwitcher.php
@@ -0,0 +1,219 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Control;
+
+use ipl\Html\Attributes;
+use ipl\Html\Form;
+use ipl\Html\FormElement\HiddenElement;
+use ipl\Html\FormElement\InputElement;
+use ipl\Html\HtmlElement;
+use ipl\Web\Common\FormUid;
+use ipl\Web\Widget\IcingaIcon;
+
+class ViewModeSwitcher extends Form
+{
+ use FormUid;
+
+ protected $defaultAttributes = [
+ 'class' => 'view-mode-switcher',
+ 'name' => 'view-mode-switcher'
+ ];
+
+ /** @var string Default view mode */
+ const DEFAULT_VIEW_MODE = 'common';
+
+ /** @var string Default view mode param */
+ const DEFAULT_VIEW_MODE_PARAM = 'view';
+
+ /** @var array View mode-icon pairs */
+ public static $viewModes = [
+ 'minimal' => 'minimal',
+ 'common' => 'default',
+ 'detailed' => 'detailed',
+ 'tabular' => 'tabular'
+ ];
+
+ /** @var string */
+ protected $defaultViewMode;
+
+ /** @var string */
+ protected $method = 'POST';
+
+ /** @var callable */
+ protected $protector;
+
+ /** @var string */
+ protected $viewModeParam = self::DEFAULT_VIEW_MODE_PARAM;
+
+ /**
+ * Get the default mode
+ *
+ * @return string
+ */
+ public function getDefaultViewMode(): string
+ {
+ return $this->defaultViewMode ?: static::DEFAULT_VIEW_MODE;
+ }
+
+ /**
+ * Set the default view mode
+ *
+ * @param string $defaultViewMode
+ *
+ * @return $this
+ */
+ public function setDefaultViewMode(string $defaultViewMode): self
+ {
+ $this->defaultViewMode = $defaultViewMode;
+
+ return $this;
+ }
+
+ /**
+ * Get the view mode URL parameter
+ *
+ * @return string
+ */
+ public function getViewModeParam(): string
+ {
+ return $this->viewModeParam;
+ }
+
+ /**
+ * Set the view mode URL parameter
+ *
+ * @param string $viewModeParam
+ *
+ * @return $this
+ */
+ public function setViewModeParam(string $viewModeParam): self
+ {
+ $this->viewModeParam = $viewModeParam;
+
+ return $this;
+ }
+
+ /**
+ * Get the view mode
+ *
+ * @return string
+ */
+ public function getViewMode(): string
+ {
+ $viewMode = $this->getPopulatedValue($this->getViewModeParam(), $this->getDefaultViewMode());
+
+ if (array_key_exists($viewMode, static::$viewModes)) {
+ return $viewMode;
+ }
+
+ return $this->getDefaultViewMode();
+ }
+
+ /**
+ * Set the view mode
+ *
+ * @param string $name
+ *
+ * @return $this
+ */
+ public function setViewMode(string $name)
+ {
+ $this->populate([$this->getViewModeParam() => $name]);
+
+ return $this;
+ }
+
+ /**
+ * Set callback to protect ids with
+ *
+ * @param callable $protector
+ *
+ * @return $this
+ */
+ public function setIdProtector(callable $protector): self
+ {
+ $this->protector = $protector;
+
+ return $this;
+ }
+
+ private function protectId($id)
+ {
+ if (is_callable($this->protector)) {
+ return call_user_func($this->protector, $id);
+ }
+
+ return $id;
+ }
+
+ protected function assemble()
+ {
+ $viewModeParam = $this->getViewModeParam();
+
+ $this->addElement($this->createUidElement());
+ $this->addElement(new HiddenElement($viewModeParam));
+
+ foreach (static::$viewModes as $viewMode => $icon) {
+ if ($viewMode === 'tabular') {
+ continue;
+ }
+
+ $protectedId = $this->protectId('view-mode-switcher-' . $icon);
+ $input = new InputElement($viewModeParam, [
+ 'class' => 'autosubmit',
+ 'id' => $protectedId,
+ 'name' => $viewModeParam,
+ 'type' => 'radio',
+ 'value' => $viewMode
+ ]);
+ $input->getAttributes()->registerAttributeCallback('checked', function () use ($viewMode) {
+ return $viewMode === $this->getViewMode();
+ });
+
+ $label = new HtmlElement(
+ 'label',
+ Attributes::create([
+ 'for' => $protectedId
+ ]),
+ new IcingaIcon($icon)
+ );
+ $label->getAttributes()->registerAttributeCallback('title', function () use ($viewMode) {
+
+ return $this->getTitle($viewMode);
+ });
+
+ $this->addHtml($input, $label);
+ }
+ }
+
+ /**
+ * Return the title for the view mode when it is active and inactive
+ *
+ * @param string $viewMode
+ *
+ * @return string Title for the view mode when it is active and inactive
+ */
+ protected function getTitle(string $viewMode): string
+ {
+ $active = null;
+ $inactive = null;
+ switch ($viewMode) {
+ case 'minimal':
+ $active = t('Minimal view active');
+ $inactive = t('Switch to minimal view');
+ break;
+ case 'common':
+ $active = t('Common view active');
+ $inactive = t('Switch to common view');
+ break;
+ case 'detailed':
+ $active = t('Detailed view active');
+ $inactive = t('Switch to detailed view');
+ break;
+ }
+
+ return $viewMode === $this->getViewMode() ? $active : $inactive;
+ }
+}
diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php
new file mode 100644
index 0000000..ad9f07e
--- /dev/null
+++ b/library/Icingadb/Web/Controller.php
@@ -0,0 +1,542 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web;
+
+use Exception;
+use Generator;
+use GuzzleHttp\Psr7\ServerRequest;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Application\Version;
+use Icinga\Application\Web;
+use Icinga\Data\ConfigObject;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\Http\HttpBadRequestException;
+use Icinga\Exception\Json\JsonDecodeException;
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Module\Icingadb\Common\SearchControls;
+use Icinga\Module\Icingadb\Data\CsvResultSet;
+use Icinga\Module\Icingadb\Data\JsonResultSet;
+use Icinga\Module\Icingadb\Web\Control\GridViewModeSwitcher;
+use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher;
+use Icinga\Module\Icingadb\Widget\ItemTable\StateItemTable;
+use Icinga\Module\Pdfexport\PrintableHtmlDocument;
+use Icinga\Module\Pdfexport\ProvidedHook\Pdfexport;
+use Icinga\Security\SecurityException;
+use Icinga\User\Preferences;
+use Icinga\User\Preferences\PreferencesStore;
+use Icinga\Util\Environment;
+use Icinga\Util\Json;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use ipl\Orm\Query;
+use ipl\Orm\UnionQuery;
+use ipl\Stdlib\Filter;
+use ipl\Web\Common\BaseItemList;
+use ipl\Web\Common\BaseItemTable;
+use ipl\Web\Compat\CompatController;
+use ipl\Web\Control\LimitControl;
+use ipl\Web\Control\PaginationControl;
+use ipl\Web\Filter\QueryString;
+use ipl\Web\Url;
+
+class Controller extends CompatController
+{
+ use Auth;
+ use Database;
+ use SearchControls;
+
+ /** @var Filter\Rule Filter from query string parameters */
+ private $filter;
+
+ /** @var string|null */
+ private $format;
+
+ /** @var bool */
+ private $formatProcessed = false;
+
+ /**
+ * Get the filter created from query string parameters
+ *
+ * @return Filter\Rule
+ */
+ public function getFilter(): Filter\Rule
+ {
+ if ($this->filter === null) {
+ $this->filter = QueryString::parse((string) $this->params);
+ }
+
+ return $this->filter;
+ }
+
+ /**
+ * Create column control
+ *
+ * @param Query $query
+ * @param ViewModeSwitcher $viewModeSwitcher
+ *
+ * @return array provided columns
+ *
+ * @throws HttpBadRequestException
+ */
+ public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwitcher): array
+ {
+ // All of that is essentially what `ColumnControl::apply()` should do
+ $viewMode = $this->getRequest()->getUrl()->getParam($viewModeSwitcher->getViewModeParam());
+ $columnsDef = $this->params->shift('columns');
+ if (! $columnsDef) {
+ if ($viewMode === 'tabular') {
+ $this->httpBadRequest('Missing parameter "columns"');
+ }
+
+ return [];
+ }
+
+ $columns = [];
+ foreach (explode(',', $columnsDef) as $column) {
+ if ($column = trim($column)) {
+ $columns[] = $column;
+ }
+ }
+
+ $query->withColumns($columns);
+
+ if (! $viewMode) {
+ $viewModeSwitcher->setViewMode('tabular');
+ }
+
+ // For now this also returns the columns, but they should be accessible
+ // by calling `ColumnControl::getColumns()` in the future
+ return $columns;
+ }
+
+ /**
+ * Create and return the ViewModeSwitcher
+ *
+ * This automatically shifts the view mode URL parameter from {@link $params}.
+ *
+ * @param PaginationControl $paginationControl
+ * @param LimitControl $limitControl
+ * @param bool $verticalPagination
+ *
+ * @return ViewModeSwitcher|GridViewModeSwitcher
+ */
+ public function createViewModeSwitcher(
+ PaginationControl $paginationControl,
+ LimitControl $limitControl,
+ bool $verticalPagination = false
+ ): ViewModeSwitcher {
+ $controllerName = $this->getRequest()->getControllerName();
+
+ // TODO: Make this configurable somehow. The route shouldn't be checked to choose the view modes!
+ if ($controllerName === 'hostgroups' || $controllerName === 'servicegroups') {
+ $viewModeSwitcher = new GridViewModeSwitcher();
+ } else {
+ $viewModeSwitcher = new ViewModeSwitcher();
+ }
+
+ $viewModeSwitcher->setIdProtector([$this->getRequest(), 'protectId']);
+
+ $user = $this->Auth()->getUser();
+ if (($preferredModes = $user->getAdditional('icingadb.view_modes')) === null) {
+ try {
+ $preferredModes = Json::decode(
+ $user->getPreferences()->getValue('icingadb', 'view_modes', '[]'),
+ true
+ );
+ } catch (JsonDecodeException $e) {
+ Logger::error('Failed to load preferred view modes for user "%s": %s', $user->getUsername(), $e);
+ $preferredModes = [];
+ }
+
+ $user->setAdditional('icingadb.view_modes', $preferredModes);
+ }
+
+ $requestRoute = $this->getRequest()->getUrl()->getPath();
+ if (isset($preferredModes[$requestRoute])) {
+ $viewModeSwitcher->setDefaultViewMode($preferredModes[$requestRoute]);
+ }
+
+ $viewModeSwitcher->populate([
+ $viewModeSwitcher->getViewModeParam() => $this->params->shift($viewModeSwitcher->getViewModeParam())
+ ]);
+
+ $session = $this->Window()->getSessionNamespace(
+ 'icingadb-viewmode-' . $this->Window()->getContainerId()
+ );
+
+ $viewModeSwitcher->on(
+ ViewModeSwitcher::ON_SUCCESS,
+ function (ViewModeSwitcher $viewModeSwitcher) use (
+ $user,
+ $preferredModes,
+ $paginationControl,
+ $verticalPagination,
+ &$session
+ ) {
+ $viewMode = $viewModeSwitcher->getValue($viewModeSwitcher->getViewModeParam());
+ $requestUrl = Url::fromRequest();
+
+ $preferredModes[$requestUrl->getPath()] = $viewMode;
+ $user->setAdditional('icingadb.view_modes', $preferredModes);
+
+ try {
+ $preferencesStore = PreferencesStore::create(new ConfigObject([
+ //TODO: Don't set store key as it will no longer be needed once we drop support for
+ // lower version of icingaweb2 then v2.11.
+ //https://github.com/Icinga/icingaweb2/pull/4765
+ 'store' => Config::app()->get('global', 'config_backend', 'db'),
+ 'resource' => Config::app()->get('global', 'config_resource')
+ ]), $user);
+ $preferencesStore->load();
+ $preferencesStore->save(
+ new Preferences(['icingadb' => ['view_modes' => Json::encode($preferredModes)]])
+ );
+ } catch (Exception $e) {
+ Logger::error('Failed to save preferred view mode for user "%s": %s', $user->getUsername(), $e);
+ }
+
+ $pageParam = $paginationControl->getPageParam();
+ $limitParam = LimitControl::DEFAULT_LIMIT_PARAM;
+ $currentPage = $paginationControl->getCurrentPageNumber();
+
+ $requestUrl->setParam($viewModeSwitcher->getViewModeParam(), $viewMode);
+ if (! $requestUrl->hasParam($limitParam)) {
+ if ($viewMode === 'minimal' || $viewMode === 'grid') {
+ $session->set('previous_page', $currentPage);
+ $session->set('request_path', $requestUrl->getPath());
+
+ $limit = $paginationControl->getLimit();
+ if (! $verticalPagination) {
+ // We are computing it based on the first element being rendered on this current page
+ $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit * 2)) + 1);
+ } else {
+ $currentPage = (int) (round($currentPage * $limit / ($limit * 2)));
+ }
+
+ $session->set('current_page', $currentPage);
+ } elseif (
+ $viewModeSwitcher->getDefaultViewMode() === 'minimal'
+ || $viewModeSwitcher->getDefaultViewMode() === 'grid'
+ ) {
+ $limit = $paginationControl->getLimit();
+ if ($currentPage === $session->get('current_page')) {
+ // No other page numbers have been selected, i.e the user only
+ // switches back and forth without changing the page numbers
+ $currentPage = $session->get('previous_page');
+ } elseif (! $verticalPagination) {
+ $currentPage = (int) (floor((($currentPage * $limit) - $limit) / ($limit / 2)) + 1);
+ } else {
+ $currentPage = (int) (floor($currentPage * $limit / ($limit / 2)));
+ }
+
+ $session->clear();
+ }
+
+ if (($requestUrl->hasParam($pageParam) && $currentPage > 1) || $currentPage > 1) {
+ $requestUrl->setParam($pageParam, $currentPage);
+ } else {
+ $requestUrl->remove($pageParam);
+ }
+ }
+
+ $this->redirectNow($requestUrl);
+ }
+ )->handleRequest(ServerRequest::fromGlobals());
+
+ $viewMode = $viewModeSwitcher->getViewMode();
+ if ($viewMode === 'minimal' || $viewMode === 'grid') {
+ $hasLimitParam = Url::fromRequest()->hasParam($limitControl->getLimitParam());
+
+ if ($paginationControl->getDefaultPageSize() <= LimitControl::DEFAULT_LIMIT && ! $hasLimitParam) {
+ $paginationControl->setDefaultPageSize($paginationControl->getDefaultPageSize() * 2);
+ $limitControl->setDefaultLimit($limitControl->getDefaultLimit() * 2);
+
+ $paginationControl->apply();
+ }
+ }
+
+ $requestPath = $session->get('request_path');
+ if ($requestPath && $requestPath !== $requestRoute) {
+ $session->clear();
+ }
+
+ return $viewModeSwitcher;
+ }
+
+ /**
+ * Process a search request
+ *
+ * @param Query $query
+ * @param array $additionalColumns
+ *
+ * @return void
+ */
+ public function handleSearchRequest(Query $query, array $additionalColumns = [])
+ {
+ $q = trim($this->params->shift('q', ''), ' *');
+ if (! $q) {
+ return;
+ }
+
+ $filter = Filter::any();
+ $this->prepareSearchFilter($query, $q, $filter, $additionalColumns);
+
+ $redirectUrl = Url::fromRequest();
+ $redirectUrl->setParams($this->params)->setFilter($filter);
+
+ $this->getResponse()->redirectAndExit($redirectUrl);
+ }
+
+ /**
+ * Prepare the given search filter
+ *
+ * @param Query $query
+ * @param string $search
+ * @param Filter\Any $filter
+ * @param array $additionalColumns
+ *
+ * @return void
+ */
+ protected function prepareSearchFilter(Query $query, string $search, Filter\Any $filter, array $additionalColumns)
+ {
+ $columns = array_merge($query->getModel()->getSearchColumns(), $additionalColumns);
+ foreach ($columns as $column) {
+ $filter->add(Filter::like(
+ $query->getResolver()->qualifyColumn($column, $query->getModel()->getTableName()),
+ "*$search*"
+ ));
+ }
+ }
+
+ /**
+ * Require permission to access the given route
+ *
+ * @param string $name If NULL, the current controller name is used
+ *
+ * @throws SecurityException
+ */
+ public function assertRouteAccess(string $name = null)
+ {
+ if (! $name) {
+ $name = $this->getRequest()->getControllerName();
+ }
+
+ if (! $this->isPermittedRoute($name)) {
+ throw new SecurityException('No permission to access this route');
+ }
+ }
+
+ public function export(Query ...$queries)
+ {
+ if ($this->format === 'sql') {
+ foreach ($queries as $query) {
+ list($sql, $values) = $query->getDb()->getQueryBuilder()->assembleSelect($query->assembleSelect());
+
+ $unused = [];
+ foreach ($values as $value) {
+ $pos = strpos($sql, '?');
+ if ($pos !== false) {
+ if (is_string($value)) {
+ $value = "'" . $value . "'";
+ }
+
+ $sql = substr_replace($sql, $value, $pos, 1);
+ } else {
+ $unused[] = $value;
+ }
+ }
+
+ if (!empty($unused)) {
+ $sql .= ' /* Unused values: "' . join('", "', $unused) . '" */';
+ }
+
+ $this->content->add(Html::tag('pre', $sql));
+ }
+
+ return true;
+ }
+
+ // It only makes sense to export a single result to CSV or JSON
+ $query = $queries[0];
+
+ // No matter the format, a limit should only apply if set
+ if ($this->format !== null) {
+ $query->limit(Url::fromRequest()->getParam('limit'));
+ }
+
+ if ($this->format === 'json' || $this->format === 'csv') {
+ $response = $this->getResponse();
+ $fileName = $this->view->title;
+
+ ob_end_clean();
+ Environment::raiseExecutionTime();
+
+ if ($this->format === 'json') {
+ $response
+ ->setHeader('Content-Type', 'application/json')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $fileName . '.json'
+ )
+ ->sendResponse();
+
+ JsonResultSet::stream($query);
+ } else {
+ $response
+ ->setHeader('Content-Type', 'text/csv')
+ ->setHeader('Cache-Control', 'no-store')
+ ->setHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . $fileName . '.csv'
+ )
+ ->sendResponse();
+
+ CsvResultSet::stream($query);
+ }
+ }
+
+ $this->getTabs()->enableDataExports();
+ }
+
+ /**
+ * @todo Remove once support for Icinga Web 2 v2.9.x is dropped
+ */
+ protected function sendAsPdf()
+ {
+ if (! Icinga::app()->getModuleManager()->has('pdfexport')) {
+ throw new ConfigurationError('The pdfexport module is required for exports to PDF');
+ }
+
+ if (version_compare(Version::VERSION, '2.10.0', '>=')) {
+ parent::sendAsPdf();
+ return;
+ }
+
+ putenv('ICINGAWEB_EXPORT_FORMAT=pdf');
+ Environment::raiseMemoryLimit('512M');
+ Environment::raiseExecutionTime(300);
+
+ $time = DateFormatter::formatDateTime(time());
+
+ $doc = (new PrintableHtmlDocument())
+ ->setTitle($this->view->title ?? '')
+ ->setHeader(Html::wantHtml([
+ Html::tag('span', ['class' => 'title']),
+ Html::tag('time', null, $time)
+ ]))
+ ->setFooter(Html::wantHtml([
+ Html::tag('span', null, [
+ t('Page') . ' ',
+ Html::tag('span', ['class' => 'pageNumber']),
+ ' / ',
+ Html::tag('span', ['class' => 'totalPages'])
+ ]),
+ Html::tag('p', null, Url::fromRequest()->setParams($this->params))
+ ]))
+ ->addHtml($this->content);
+ $doc->getAttributes()->add('class', 'icinga-module module-icingadb');
+
+ Pdfexport::first()->streamPdfFromHtml($doc, sprintf(
+ '%s-%s',
+ $this->view->title ?: $this->getRequest()->getActionName(),
+ $time
+ ));
+ }
+
+ public function dispatch($action)
+ {
+ // Notify helpers of action preDispatch state
+ $this->_helper->notifyPreDispatch();
+
+ $this->preDispatch();
+
+ if ($this->getRequest()->isDispatched()) {
+ // If pre-dispatch hooks introduced a redirect then stop dispatch
+ // @see ZF-7496
+ if (! $this->getResponse()->isRedirect()) {
+ $interceptable = $this->$action();
+ if ($interceptable instanceof Generator) {
+ foreach ($interceptable as $stopSignal) {
+ if ($stopSignal === true) {
+ $this->formatProcessed = true;
+ break;
+ }
+ }
+ }
+ }
+ $this->postDispatch();
+ }
+
+ // whats actually important here is that this action controller is
+ // shutting down, regardless of dispatching; notify the helpers of this
+ // state
+ $this->_helper->notifyPostDispatch();
+ }
+
+ protected function addContent(ValidHtml $content)
+ {
+ if ($content instanceof BaseItemList || $content instanceof BaseItemTable) {
+ $this->content->getAttributes()->add('class', 'full-width');
+ } elseif ($content instanceof StateItemTable) {
+ $this->content->getAttributes()->add('class', 'full-height');
+ }
+
+ return parent::addContent($content);
+ }
+
+ public function filter(Query $query, Filter\Rule $filter = null): self
+ {
+ if ($this->format !== 'sql' || $this->hasPermission('config/authentication/roles/show')) {
+ $this->applyRestrictions($query);
+ }
+
+ if ($query instanceof UnionQuery) {
+ foreach ($query->getUnions() as $query) {
+ $query->filter($filter ?: $this->getFilter());
+ }
+ } else {
+ $query->filter($filter ?: $this->getFilter());
+ }
+
+ return $this;
+ }
+
+ public function preDispatch()
+ {
+ parent::preDispatch();
+
+ $this->format = $this->params->shift('format');
+ }
+
+ public function postDispatch()
+ {
+ if (! $this->formatProcessed && $this->format !== null && $this->format !== 'pdf') {
+ // The purpose of this is not only to show that a requested format isn't supported.
+ // It's main purpose is to not allow to bypass restrictions with `?format=sql` as
+ // it may be possible that an action applies restrictions, but doesn't support any
+ // output formats. Since the restrictions are bypassed in method `$this->filter()`
+ // for the SQL output format and the actual format processing is part of a different
+ // method (`$this->export()`) which needs to be called explicitly by an action,
+ // it's otherwise possible for bad individuals to access unrestricted data.
+ $this->httpBadRequest(t('This route does not support the requested output format'));
+ }
+
+ parent::postDispatch();
+ }
+
+ protected function moduleInit()
+ {
+ /** @var Web $app */
+ $app = Icinga::app();
+ $app->getFrontController()
+ ->getPlugin('Zend_Controller_Plugin_ErrorHandler')
+ ->setErrorHandlerModule('icingadb');
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Action.php b/library/Icingadb/Web/Navigation/Action.php
new file mode 100644
index 0000000..d02f933
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Action.php
@@ -0,0 +1,134 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Macros;
+use Icinga\Module\Icingadb\Model\Host;
+use Icinga\Module\Icingadb\Model\Service;
+use Icinga\Web\Navigation\NavigationItem;
+use ipl\Web\Url;
+
+class Action extends NavigationItem
+{
+ use Auth;
+ use Macros;
+
+ /**
+ * Whether this action's macros were already resolved
+ *
+ * @var bool
+ */
+ protected $resolved = false;
+
+ /**
+ * This action's object
+ *
+ * @var Host|Service
+ */
+ protected $object;
+
+ /**
+ * The filter to use when being asked whether to render this action
+ *
+ * @var string
+ */
+ protected $filter;
+
+ /**
+ * This action's raw url attribute
+ *
+ * @var string
+ */
+ protected $rawUrl;
+
+ /**
+ * Set this action's object
+ *
+ * @param Host|Service $object
+ *
+ * @return $this
+ */
+ public function setObject($object): self
+ {
+ $this->object = $object;
+
+ return $this;
+ }
+
+ /**
+ * Get this action's object
+ *
+ * @return Host|Service
+ */
+ protected function getObject()
+ {
+ return $this->object;
+ }
+
+ /**
+ * Set the filter to use when being asked whether to render this action
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setFilter(string $filter): self
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Get the filter to use when being asked whether to render this action
+ *
+ * @return ?string
+ */
+ public function getFilter(): ?string
+ {
+ return $this->filter;
+ }
+
+ /**
+ * Set this item's url
+ *
+ * @param \Icinga\Web\Url|string $url
+ *
+ * @return $this
+ */
+ public function setUrl($url): self
+ {
+ if (is_string($url)) {
+ $this->rawUrl = $url;
+ } else {
+ parent::setUrl($url);
+ }
+
+ return $this;
+ }
+
+ public function getUrl(): ?\Icinga\Web\Url
+ {
+ $url = parent::getUrl();
+ if (! $this->resolved && $url === null && $this->rawUrl !== null) {
+ $this->setUrl(Url::fromPath($this->expandMacros($this->rawUrl, $this->getObject())));
+ $this->resolved = true;
+ return parent::getUrl();
+ } else {
+ return $url;
+ }
+ }
+
+ public function getRender(): bool
+ {
+ if ($this->render === null) {
+ $filter = $this->getFilter();
+ $this->render = ! $filter || $this->isMatchedOn($filter, $this->getObject());
+ }
+
+ return $this->render;
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/IcingadbHostAction.php b/library/Icingadb/Web/Navigation/IcingadbHostAction.php
new file mode 100644
index 0000000..a5fc256
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/IcingadbHostAction.php
@@ -0,0 +1,9 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation;
+
+class IcingadbHostAction extends Action
+{
+}
diff --git a/library/Icingadb/Web/Navigation/IcingadbServiceAction.php b/library/Icingadb/Web/Navigation/IcingadbServiceAction.php
new file mode 100644
index 0000000..d623951
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/IcingadbServiceAction.php
@@ -0,0 +1,9 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation;
+
+class IcingadbServiceAction extends Action
+{
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
new file mode 100644
index 0000000..fc64c7d
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/HostProblemsBadge.php
@@ -0,0 +1,35 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\HoststateSummary;
+use ipl\Web\Url;
+
+class HostProblemsBadge extends ProblemsBadge
+{
+ use Auth;
+
+ protected function fetchProblemsCount()
+ {
+ $summary = HoststateSummary::on($this->getDb());
+ $this->applyRestrictions($summary);
+ $count = (int) $summary->first()->hosts_down_unhandled;
+ if ($count) {
+ $this->setTitle(sprintf(
+ tp('One unhandled host down', '%d unhandled hosts down', $count),
+ $count
+ ));
+ }
+
+ return $count;
+ }
+
+ protected function getUrl(): Url
+ {
+ return Links::hosts()->setParams(['host.state.is_problem' => 'y', 'sort' => 'host.state.severity desc']);
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
new file mode 100644
index 0000000..658fa1c
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/ProblemsBadge.php
@@ -0,0 +1,173 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Exception;
+use Icinga\Application\Logger;
+use Icinga\Module\Icingadb\Common\Database;
+use Icinga\Web\Navigation\NavigationItem;
+use Icinga\Web\Navigation\Renderer\NavigationItemRenderer;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use ipl\Web\Widget\Link;
+use ipl\Web\Widget\StateBadge;
+
+abstract class ProblemsBadge extends NavigationItemRenderer
+{
+ use Database;
+
+ const STATE_CRITICAL = 'critical';
+ const STATE_UNKNOWN = 'unknown';
+
+ /** @var int Count cache */
+ protected $count;
+
+ /** @var string State text */
+ protected $state;
+
+ /** @var string Title */
+ protected $title;
+
+ protected $linkDisabled;
+
+ abstract protected function fetchProblemsCount();
+
+ abstract protected function getUrl();
+
+ public function getProblemsCount()
+ {
+ if ($this->count === null) {
+ try {
+ $count = $this->fetchProblemsCount();
+ } catch (Exception $e) {
+ Logger::debug($e);
+
+ $this->count = 1;
+
+ $this->setState(static::STATE_UNKNOWN);
+ $this->setTitle($e->getMessage());
+
+ return $this->count;
+ }
+
+ $this->count = $this->round($count);
+
+ $this->setState(static::STATE_CRITICAL);
+ }
+
+ return $this->count;
+ }
+
+ /**
+ * Set the state text
+ *
+ * @param string $state
+ *
+ * @return $this
+ */
+ public function setState(string $state): self
+ {
+ $this->state = $state;
+
+ return $this;
+ }
+
+ /**
+ * Get the state text
+ *
+ * @return string
+ */
+ public function getState(): string
+ {
+ if ($this->state === null) {
+ throw new \LogicException(
+ 'You are accessing an unset property. Please make sure to set it beforehand.'
+ );
+ }
+
+ return $this->state;
+ }
+
+ /**
+ * Set the title
+ *
+ * @param string $title
+ *
+ * @return $this
+ */
+ public function setTitle(string $title): self
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ /**
+ * Get the title
+ *
+ * @return ?string
+ */
+ public function getTitle()
+ {
+ return $this->title;
+ }
+
+ public function render(NavigationItem $item = null): string
+ {
+ if ($item === null) {
+ $item = $this->getItem();
+ }
+
+ $item->setCssClass('badge-nav-item icinga-module module-icingadb');
+
+ $html = new HtmlDocument();
+
+ $badge = $this->createBadge();
+ if ($badge !== null) {
+ if ($this->linkDisabled) {
+ $badge->addAttributes(['class' => 'disabled']);
+ $this->setEscapeLabel(false);
+ $label = $this->view()->escape($item->getLabel());
+ $item->setLabel($badge . $label);
+ } else {
+ $html->add(new Link($badge, $this->getUrl(), ['title' => $this->getTitle()]));
+ }
+ }
+
+ return $html
+ ->prepend(new HtmlString(parent::render($item)))
+ ->render();
+ }
+
+ protected function createBadge()
+ {
+ $count = $this->getProblemsCount();
+
+ if ($count) {
+ return (new StateBadge($count, $this->getState()))
+ ->addAttributes(['class' => 'badge', 'title' => $this->getTitle()]);
+ }
+
+ return null;
+ }
+
+ protected function round($count)
+ {
+ if ($count > 1000000) {
+ $count = round($count, -6) / 1000000 . 'M';
+ } elseif ($count > 1000) {
+ $count = round($count, -3) / 1000 . 'k';
+ }
+
+ return $count;
+ }
+
+ public function disableLink()
+ {
+ $this->linkDisabled = true;
+
+ return $this;
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
new file mode 100644
index 0000000..b2f2cae
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/ServiceProblemsBadge.php
@@ -0,0 +1,36 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Icinga\Module\Icingadb\Common\Auth;
+use Icinga\Module\Icingadb\Common\Links;
+use Icinga\Module\Icingadb\Model\ServicestateSummary;
+use ipl\Web\Url;
+
+class ServiceProblemsBadge extends ProblemsBadge
+{
+ use Auth;
+
+ protected function fetchProblemsCount()
+ {
+ $summary = ServicestateSummary::on($this->getDb());
+ $this->applyRestrictions($summary);
+ $count = (int) $summary->first()->services_critical_unhandled;
+ if ($count) {
+ $this->setTitle(sprintf(
+ tp('One unhandled service critical', '%d unhandled services critical', $count),
+ $count
+ ));
+ }
+
+ return $count;
+ }
+
+ protected function getUrl(): Url
+ {
+ return Links::services()
+ ->setParams(['service.state.is_problem' => 'y', 'sort' => 'service.state.severity desc']);
+ }
+}
diff --git a/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php b/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php
new file mode 100644
index 0000000..703db65
--- /dev/null
+++ b/library/Icingadb/Web/Navigation/Renderer/TotalProblemsBadge.php
@@ -0,0 +1,66 @@
+<?php
+
+/* Icinga DB Web | (c) 2021 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Web\Navigation\Renderer;
+
+use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
+
+class TotalProblemsBadge extends BadgeNavigationItemRenderer
+{
+ /**
+ * Cached count
+ *
+ * @var int
+ */
+ protected $count;
+
+ /**
+ * State to severity map
+ *
+ * @var array
+ */
+ protected static $stateSeverityMap = [
+ self::STATE_OK => 0,
+ self::STATE_PENDING => 1,
+ self::STATE_UNKNOWN => 2,
+ self::STATE_WARNING => 3,
+ self::STATE_CRITICAL => 4,
+ ];
+
+ /**
+ * Severity to state map
+ *
+ * @var array
+ */
+ protected static $severityStateMap = [
+ self::STATE_OK,
+ self::STATE_PENDING,
+ self::STATE_UNKNOWN,
+ self::STATE_WARNING,
+ self::STATE_CRITICAL
+ ];
+
+ public function getCount()
+ {
+ if ($this->count === null) {
+ $countMap = array_fill(0, 5, 0);
+ $maxSeverity = 0;
+ foreach ($this->getItem()->getChildren() as $child) {
+ $renderer = $child->getRenderer();
+ if ($renderer instanceof ProblemsBadge) {
+ $count = $renderer->getProblemsCount();
+ if ($count) {
+ $severity = static::$stateSeverityMap[$renderer->getState()];
+ $countMap[$severity] += $count;
+ $maxSeverity = max($maxSeverity, $severity);
+ }
+ }
+ }
+ $this->count = $countMap[$maxSeverity];
+ $this->state = static::$severityStateMap[$maxSeverity];
+ }
+
+ return $this->count;
+ }
+}