diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index 69b255925..145dfc367 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -5,31 +5,37 @@ namespace Icinga\Module\Notifications\Controllers; -use Icinga\Application\Hook; use Icinga\Application\Logger; +use Icinga\Exception\ConfigurationError; use Icinga\Exception\Http\HttpNotFoundException; use Icinga\Module\Notifications\Common\Auth; use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; +use Icinga\Module\Notifications\Common\SourceHookLocator; use Icinga\Module\Notifications\Forms\EventRuleConfigElements\NotificationConfigProvider; use Icinga\Module\Notifications\Forms\EventRuleConfigForm; use Icinga\Module\Notifications\Forms\EventRuleForm; -use Icinga\Module\Notifications\Hook\V1\SourceHook; +use Icinga\Module\Notifications\Hook\V2\SourceHook; use Icinga\Module\Notifications\Model\Rule; use Icinga\Module\Notifications\Model\Source; +use Icinga\Module\Notifications\Util\RuleSerializer; use Icinga\Module\Notifications\Web\Control\SearchBar\ExtraTagSuggestions; +use Icinga\Module\Notifications\Web\Control\SearchEditor\RuleFilterSuggestions; use Icinga\Web\Notification; use Icinga\Web\Session; use ipl\Html\Contract\Form; use ipl\Html\Html; use ipl\Stdlib\Filter; +use ipl\Stdlib\Filter\Condition; use ipl\Web\Compat\CompatController; -use ipl\Web\Compat\CompatForm; +use ipl\Web\Control\SearchBar\SearchException; +use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\QueryString; use ipl\Web\Url; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; +use JsonException; use Psr\Http\Message\ServerRequestInterface; -use Throwable; class EventRuleController extends CompatController { @@ -195,7 +201,64 @@ public function searchEditorAction(): void { $ruleId = (int) $this->params->getRequired('id'); $filter = $this->params->get('object_filter', $this->session->get('object_filter')); + $hook = $this->resolveSourceHook($ruleId); + $parsedFilter = null; + if ($filter) { + try { + $parsedFilter = json_decode($filter, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + Logger::error('Failed to parse rule filter configuration: %s (Error: %s)', $filter, $e); + throw new ConfigurationError($this->translate( + 'Failed to parse rule filter configuration. Please contact your system administrator.' + )); + } + } + + $editor = (new SearchEditor()) + ->setQueryString($parsedFilter['qs'] ?? '') + ->setSuggestionUrl( + Url::fromPath( + 'notifications/event-rule/suggest', + ['id' => $ruleId, '_disableLayout' => true, 'showCompact' => true] + ) + ) + ->setAction(Url::fromRequest()->with('object_filter', $filter)->getAbsoluteUrl()) + ->on( + SearchEditor::ON_VALIDATE_COLUMN, + function (Condition $condition) use ($hook) { + if (! $hook->isValidCondition($condition)) { + throw new SearchException($this->translate('Is not a valid column')); + } + } + ) + ->on(Form::ON_SUBMIT, function (SearchEditor $form) use ($ruleId, $hook) { + $filter = $form->getFilter(); + + $this->session->set( + 'object_filter', + (new RuleSerializer($filter, $hook->getJsonPaths($this->collectColumns($filter))))->getJson() + ); + $this->redirectNow(Links::eventRule($ruleId)->setParam('_filterOnly')); + }); + + $editor->getParser()->on(QueryString::ON_CONDITION, $hook->enrichCondition(...)); + $editor->handleRequest($this->getServerRequest()); + + $this->getDocument()->addHtml($editor); + + $this->setTitle($this->translate('Adjust Filter')); + } + + public function suggestAction(): void + { + $hook = $this->resolveSourceHook((int) $this->params->getRequired('id')); + $suggestions = (new RuleFilterSuggestions($hook))->forRequest($this->getServerRequest()); + $this->getDocument()->addHtml($suggestions); + } + + protected function resolveSourceHook(int $ruleId): SourceHook + { $source = null; if ($ruleId !== -1) { $source = Rule::on(Database::get()) @@ -216,19 +279,7 @@ public function searchEditorAction(): void $this->httpNotFound($this->translate('Rule not found')); } - $hook = null; - foreach (Hook::all('Notifications/v1/Source') as $h) { - /** @var SourceHook $h */ - try { - if ($h->getSourceType() === $source->type) { - $hook = $h; - - break; - } - } catch (Throwable $e) { - Logger::error('Failed to load source integration %s: %s', $h::class, $e); - } - } + $hook = SourceHookLocator::forType($source->type); if ($hook === null) { $this->httpNotFound(sprintf($this->translate( @@ -237,51 +288,7 @@ public function searchEditorAction(): void ), $source->type)); } - if (! $filter) { - $targets = $hook->getRuleFilterTargets($source->id); - if (count($targets) === 1 && ! is_array(reset($targets))) { - $filter = key($targets); - } else { - $target = null; - $form = (new CompatForm()) - ->applyDefaultElementDecorators() - ->setAction(Url::fromRequest()->getAbsoluteUrl()) - ->addElement('select', 'target', [ - 'required' => true, - 'label' => $this->translate('Filter Target'), - 'options' => ['' => ' - ' . $this->translate('Please choose') . ' - '] + $targets, - 'disabledOptions' => [''] - ]) - ->addElement('submit', 'btn_submit', [ - // translators: shown on a submit button to proceed to the next step of a form wizard - 'label' => $this->translate('Next') - ]) - ->on(Form::ON_SUBMIT, function (CompatForm $form) use (&$target) { - $target = $form->getValue('target'); - }) - ->handleRequest($this->getServerRequest()); - - if ($target !== null) { - $filter = $target; - } else { - $this->addContent($form); - } - } - } - - if ($filter) { - $form = $hook->getRuleFilterEditor($filter) - ->setAction(Url::fromRequest()->with('object_filter', $filter)->getAbsoluteUrl()) - ->on(Form::ON_SUBMIT, function (Form $form) use ($ruleId, $hook) { - $this->session->set('object_filter', $hook->serializeRuleFilter($form)); - $this->redirectNow(Links::eventRule($ruleId)->setParam('_filterOnly')); - }) - ->handleRequest($this->getServerRequest()); - - $this->getDocument()->addHtml($form); - } - - $this->setTitle($this->translate('Adjust Filter')); + return $hook; } public function editAction(): void @@ -338,4 +345,31 @@ private function fetchRule(int $ruleId): Rule return $rule; } + + /** + * Get every distinct column used by the filter + * + * @param Filter\Rule $rule + * + * @return array + */ + private function collectColumns(Filter\Rule $rule): array + { + $columns = []; + $this->walkFilterTree($rule, $columns); + + return array_keys($columns); + } + + private function walkFilterTree(Filter\Rule $rule, array &$columns): void + { + if ($rule instanceof Filter\Chain) { + foreach ($rule as $element) { + $this->walkFilterTree($element, $columns); + } + } else { + /** @var Condition $rule */ + $columns[$rule->getColumn()] = true; + } + } } diff --git a/application/forms/SourceForm.php b/application/forms/SourceForm.php index 15fa3ab8c..cbdf71e57 100644 --- a/application/forms/SourceForm.php +++ b/application/forms/SourceForm.php @@ -6,10 +6,7 @@ namespace Icinga\Module\Notifications\Forms; use DateTime; -use Icinga\Application\Hook; -use Icinga\Application\Logger; use Icinga\Exception\Http\HttpNotFoundException; -use Icinga\Module\Notifications\Hook\V1\SourceHook; use Icinga\Module\Notifications\Model\Source; use ipl\Html\Attributes; use ipl\Html\HtmlDocument; @@ -24,7 +21,6 @@ use ipl\Web\Widget\ButtonLink; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; -use Throwable; class SourceForm extends CompatForm { @@ -51,22 +47,6 @@ protected function assemble(): void { $this->applyDefaultElementDecorators(); $this->addCsrfCounterMeasure(); - - $types = [ - '' => ' - ' . $this->translate('Please choose') . ' - ', - self::TYPE_GENERIC => $this->translate('Generic') - ]; - - foreach (Hook::all('Notifications/v1/Source') as $hook) { - /** @var SourceHook $hook */ - try { - $type = $hook->getSourceType(); - $types[$type] = $hook->getSourceLabel(); - } catch (Throwable $e) { - Logger::error('Failed to load source integration %s: %s', $hook::class, $e); - } - } - $this->addHtml(new HtmlElement( 'p', Attributes::create(['class' => 'description']), @@ -74,8 +54,7 @@ protected function assemble(): void 'Sources are the most vital part of Icinga Notifications. They submit events that will be' . ' processed to notify users about incidents. You can either configure sources that provide an' . ' integration in Icinga Web or use the Generic type for sources that communicate directly with the' - . ' Icinga Notifications API. If you cannot choose the desired source below, consult its documentation' - . ' on how to integrate it.' + . ' Icinga Notifications API. Refer to the source\'s documentation for the correct source type.' )) )); @@ -88,14 +67,12 @@ protected function assemble(): void ] ); $this->addElement( - 'select', + 'text', 'type', [ 'required' => true, 'label' => $this->translate('Source Type'), 'class' => 'autosubmit', - 'options' => $types, - 'disabledOptions' => [''] ] ); diff --git a/library/Notifications/Common/SourceHookLocator.php b/library/Notifications/Common/SourceHookLocator.php new file mode 100644 index 000000000..b5ecb277d --- /dev/null +++ b/library/Notifications/Common/SourceHookLocator.php @@ -0,0 +1,38 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Notifications\Common; + +use Icinga\Application\Hook; +use Icinga\Module\Notifications\Hook\V2\SourceHook; +use ipl\Stdlib\Str; + +class SourceHookLocator +{ + /** + * Get the source hook responsible for the given source type + * + * Returns `null` if no module providing such a hook is enabled. + * + * {@see Hook::assertValidHook()} derives the expected base class of a hook from its name. Since the hook's name + * carries the source type, a class alias of {@see SourceHook} matching the expected class name is created + * so the validation passes. + * + * @param string $type The source type as stored in the `source` table + * + * @return ?SourceHook + */ + public static function forType(string $type): ?SourceHook + { + $name = ucfirst(Str::camel($type)); + + $alias = 'Icinga\Module\Notifications\Hook\V2\\' . $name . 'SourceHook'; + if (! interface_exists($alias)) { + class_alias(SourceHook::class, $alias); + } + + return Hook::first('Notifications/v2/' . $name . 'Source'); + } +} diff --git a/library/Notifications/Hook/V2/SourceHook.php b/library/Notifications/Hook/V2/SourceHook.php new file mode 100644 index 000000000..3b524592c --- /dev/null +++ b/library/Notifications/Hook/V2/SourceHook.php @@ -0,0 +1,75 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Notifications\Hook\V2; + +use ipl\Stdlib\Filter\Chain; +use ipl\Stdlib\Filter\Condition; +use ipl\Web\Widget\Icon; +use Traversable; + +interface SourceHook +{ + /** + * Get the label of the source this integration is responsible for + * + * @return string + */ + public function getSourceLabel(): string; + + /** + * Get the icon of the source this integration is responsible for + * + * @return Icon + */ + public function getSourceIcon(): Icon; + + /** + * Get whether the condition is valid + * + * @param Condition $condition + * + * @return bool + */ + public function isValidCondition(Condition $condition): bool; + + /** + * Enrich the given condition with metadata like the columnLabel + * + * @param Condition $condition + * + * @return void + */ + public function enrichCondition(Condition $condition): void; + + /** + * Get all JsonPaths for all given columns, keyed by the column + * + * @param string ...$columns + * + * @return array> + */ + public function getJsonPaths(string ...$columns): array; + + /** + * Get suggestions for a value field + * + * @param string $column + * @param string $searchTerm + * @param Chain $searchFilter + * + * @return Traversable Values to be suggested as `search` => `label` + */ + public function getValueSuggestions(string $column, string $searchTerm, Chain $searchFilter): Traversable; + + /** + * Get suggestions for a column field + * + * @param string $searchTerm + * + * @return Traversable Columns to be suggested as `search` => `label` + */ + public function getColumnSuggestions(string $searchTerm): Traversable; +} diff --git a/library/Notifications/Model/Source.php b/library/Notifications/Model/Source.php index d69bbee2e..af245249b 100644 --- a/library/Notifications/Model/Source.php +++ b/library/Notifications/Model/Source.php @@ -6,9 +6,9 @@ namespace Icinga\Module\Notifications\Model; use DateTime; -use Icinga\Application\Hook; use Icinga\Application\Logger; -use Icinga\Module\Notifications\Hook\V1\SourceHook; +use Icinga\Module\Notifications\Common\SourceHookLocator; +use Icinga\Module\Notifications\Hook\V2\SourceHook; use ipl\Orm\Behavior\BoolCast; use ipl\Orm\Behavior\MillisecondTimestamp; use ipl\Orm\Behaviors; @@ -103,22 +103,17 @@ public function getIcon(): Icon // Fallback, in case an integration is inactive or missing $icon = new Icon('share-nodes'); - foreach (Hook::all('Notifications/v1/Source') as $hook) { + $hook = SourceHookLocator::forType($this->type); + if ($hook !== null) { /** @var SourceHook $hook */ try { - if ($hook->getSourceType() === $this->type) { - $icon = $hook->getSourceIcon(); - - break; - } + $icon = $hook->getSourceIcon(); } catch (Throwable $e) { Logger::error( 'Failed to retrieve source icon for source type "%s": %s', $this->type, $e ); - - break; } } diff --git a/library/Notifications/Util/RuleSerializer.php b/library/Notifications/Util/RuleSerializer.php new file mode 100644 index 000000000..046547d6c --- /dev/null +++ b/library/Notifications/Util/RuleSerializer.php @@ -0,0 +1,127 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Notifications\Util; + +use Icinga\Exception\Json\JsonEncodeException; +use Icinga\Util\Json; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Filter\Chain; +use ipl\Web\Filter\QueryString; + +class RuleSerializer +{ + /** @var Filter\Condition|Filter\Chain */ + protected Filter\Rule $filter; + + /** @var array> JSON paths keyed by column name */ + protected array $jsonPaths; + + /** + * Create an object that can be used to serialize a rule to JSON + * + * @param Filter\Rule $filter + * @param array> $jsonPaths JSON paths keyed by column name + */ + public function __construct(Filter\Rule $filter, array $jsonPaths) + { + $this->filter = $filter; + $this->jsonPaths = $jsonPaths; + } + + /** + * Serialize the filter as Json + * + * @return string + * + * @throws JsonEncodeException + */ + public function getJson(): string + { + $result = ['qs' => QueryString::render($this->filter)]; + if ($this->filter instanceof Filter\Chain) { + $result['ast'] = $this->serializeChain($this->filter); + } else { + $result['ast'] = $this->serializeCondition($this->filter); + } + + return Json::encode($result); + } + + /** + * Create an array with keys `op` and `rules` from a chain + * + * @param Chain $chain + * + * @return array{op: string, rules: list>} + */ + protected function serializeChain(Chain $chain): array + { + $result = [ + 'op' => match (true) { + $chain instanceof Filter\All => '&', + $chain instanceof Filter\None => '!', + $chain instanceof Filter\Any => '|' + } + ]; + + $rules = []; + foreach ($chain as $rule) { + if ($rule instanceof Chain) { + $rules[] = $this->serializeChain($rule); + } else { + $rules[] = $this->serializeCondition($rule); + } + } + + $result['rules'] = $rules; + + return $result; + } + + /** + * Create an array with the keys `op`, `attributes` and either `value` or `regex` + * + * @param Filter\Condition $condition + * + * @return array{op: string, attributes: list, value: string} + * | array{op: string, attributes: list, regex: string} + */ + protected function serializeCondition(Filter\Condition $condition): array + { + $op = match (true) { + $condition instanceof Filter\Unlike, $condition instanceof Filter\Unequal => '!=', + $condition instanceof Filter\Like, $condition instanceof Filter\Equal => '=', + $condition instanceof Filter\GreaterThan => '>', + $condition instanceof Filter\LessThan => '<', + $condition instanceof Filter\GreaterThanOrEqual => '>=', + $condition instanceof Filter\LessThanOrEqual => '<=', + }; + + $value = $condition instanceof Filter\Like || $condition instanceof Filter\Unlike + ? ['regex' => $this->createRegularExpression($condition->getValue())] + : ['value' => $condition->getValue()]; + + return [ + 'op' => $op, + 'attributes' => $this->jsonPaths[$condition->getColumn()], + ...$value, + ]; + } + + /** + * Get the preprocessed value of a condition + * + * Creates a regex for Like and Unlike rules + * + * @param string $value + * + * @return string + */ + protected function createRegularExpression(string $value): string + { + return '^' . str_replace('\*', '.*', preg_quote($value)) . '$'; + } +} diff --git a/library/Notifications/Web/Control/SearchEditor/RuleFilterSuggestions.php b/library/Notifications/Web/Control/SearchEditor/RuleFilterSuggestions.php new file mode 100644 index 000000000..9ac28fe29 --- /dev/null +++ b/library/Notifications/Web/Control/SearchEditor/RuleFilterSuggestions.php @@ -0,0 +1,80 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Notifications\Web\Control\SearchEditor; + +use EmptyIterator; +use Icinga\Module\Notifications\Hook\V2\SourceHook; +use ipl\Stdlib\Filter; +use ipl\Web\Control\SearchEditor; +use ipl\Web\Filter\QueryString; +use ipl\Web\FormElement\SearchSuggestions; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Suggestions for the SearchEditor used to modify rule-filters + */ +class RuleFilterSuggestions extends SearchSuggestions +{ + protected SourceHook $hook; + + /** @var ?string The type of the field for which to show suggestions */ + protected ?string $type = null; + + /** + * Create new RuleFilterSuggestions + * + * {@see forRequest} chooses the correct provider from the given hook and must be called before assemble + * + * @param SourceHook $hook + */ + public function __construct(SourceHook $hook) + { + $this->hook = $hook; + + parent::__construct(new EmptyIterator()); + } + + public function forRequest(ServerRequestInterface $request): static + { + if ($request->getMethod() !== 'POST') { + return $this; + } + + $requestData = json_decode($request->getBody()->read(8192), true); + if (empty($requestData)) { + return $this; + } + + $this->setSearchTerm($requestData['term']['label']); + $this->setOriginalSearchValue($requestData['term']['search']); + $this->setExcludeTerms($requestData['exclude'] ?? []); + + $this->type = $requestData['term']['type'] ?? null; + + $column = $requestData['column'] ?? null; + if ($column === SearchEditor::FAKE_COLUMN) { + $column = null; + } + + $searchFilter = QueryString::parse($requestData['searchFilter'] ?? ''); + $searchFilter = $searchFilter instanceof Filter\Chain + ? $searchFilter + : Filter::all($searchFilter); + + if ($this->type === 'column') { + $this->provider = $this->hook->getColumnSuggestions($this->getSearchTerm() ?? ''); + $this->setGroupingCallback(fn ($x) => $x['group']); + } elseif ($column !== null) { + $this->provider = $this->hook->getValueSuggestions( + $column, + $this->getSearchTerm() ?? '', + $searchFilter + ); + } + + return $this; + } +}