diff options
Diffstat (limited to '')
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; + } +} |