From b18bc644404e02b57635bfcc8258e85abb141146 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sat, 13 Apr 2024 13:44:46 +0200 Subject: Adding upstream version 1.1.1. Signed-off-by: Daniel Baumann --- .../Icingadb/Web/Control/GridViewModeSwitcher.php | 38 ++ library/Icingadb/Web/Control/ProblemToggle.php | 74 ++++ .../Web/Control/SearchBar/ObjectSuggestions.php | 406 +++++++++++++++++++++ library/Icingadb/Web/Control/ViewModeSwitcher.php | 219 +++++++++++ 4 files changed, 737 insertions(+) create mode 100644 library/Icingadb/Web/Control/GridViewModeSwitcher.php create mode 100644 library/Icingadb/Web/Control/ProblemToggle.php create mode 100644 library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php create mode 100644 library/Icingadb/Web/Control/ViewModeSwitcher.php (limited to 'library/Icingadb/Web/Control') 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 @@ + '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 @@ + '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 @@ +customVarSources = [ + 'checkcommand' => t('Checkcommand %s', '..'), + 'eventcommand' => t('Eventcommand %s', '..'), + 'host' => t('Host %s', '..'), + 'hostgroup' => t('Hostgroup %s', '..'), + 'notification' => t('Notification %s', '..'), + 'notificationcommand' => t('Notificationcommand %s', '..'), + 'service' => t('Service %s', '..'), + 'servicegroup' => t('Servicegroup %s', '..'), + 'timeperiod' => t('Timeperiod %s', '..'), + 'user' => t('User %s', '..'), + 'usergroup' => t('Usergroup %s', '..') + ]; + } + + /** + * 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 @@ + '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; + } +} -- cgit v1.2.3