diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index 69b255925..e46ed7b1e 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -7,6 +7,7 @@ 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; @@ -14,20 +15,26 @@ 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\RuleParser; +use Icinga\Module\Notifications\Util\RuleSerializer; use Icinga\Module\Notifications\Web\Control\SearchBar\ExtraTagSuggestions; 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\Renderer; use ipl\Web\Url; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; +use JsonException; use Psr\Http\Message\ServerRequestInterface; use Throwable; @@ -195,7 +202,92 @@ 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); + $editor = (new SearchEditor()) + ->setAction(Url::fromRequest()->with('object_filter', $filter)->getAbsoluteUrl()); + + if ($hook) { + if ($filter) { + try { + $parsedFilter = (new RuleParser())->parseJson($filter); + $applyLabels = function (Filter\Rule $rule) use ($hook, &$applyLabels): void { + if ($rule instanceof Filter\Chain) { + foreach ($rule as $child) { + $applyLabels($child); + } + } else { + /** @var Condition $rule */ + $hook->enrichCondition($rule); + } + }; + + $applyLabels($parsedFilter); + } 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 + ->setFilter($parsedFilter ?? new Filter\All()) + ->setSuggestionUrl( + Url::fromPath( + 'notifications/event-rule/suggest', + ['id' => $ruleId, '_disableLayout' => true, 'showCompact' => true] + ) + ) + ->setMetadataFields($hook->getMetadataKeys()) + ->on( + SearchEditor::ON_VALIDATE_COLUMN, + function (Condition $condition) use ($hook) { + if (! $hook->isValidCondition($condition)) { + throw new SearchException($this->translate('Is not a valid column')); + } + + $condition->metaData()->set('jsonPath', $hook->getJsonPath($condition)); + } + ) + ->on(Form::ON_SUBMIT, function (SearchEditor $form) use ($ruleId, $hook) { + $this->session->set( + 'object_filter', + (new RuleSerializer($form->getFilter(), $hook->getMetadataKeys()))->getJson() + ); + $this->redirectNow(Links::eventRule($ruleId)->setParam('_filterOnly')); + }); + } else { + $editor + ->setQueryString($filter ?? '') + ->on( + Form::ON_SUBMIT, + function (SearchEditor $editor) use ($ruleId) { + $this->session->set( + 'object_filter', + (new Renderer($editor->getFilter()))->render() + ); + $this->redirectNow(Links::eventRule($ruleId)->setParam('_filterOnly')); + } + ); + } + + $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 = $hook->getSuggestions(); + $this->getDocument()->addHtml($suggestions->forRequest($this->getServerRequest())); + } + + protected function resolveSourceHook(int $ruleId): ?SourceHook + { $source = null; if ($ruleId !== -1) { $source = Rule::on(Database::get()) @@ -216,8 +308,12 @@ public function searchEditorAction(): void $this->httpNotFound($this->translate('Rule not found')); } + if ($source->type === 'generic') { + return null; + } + $hook = null; - foreach (Hook::all('Notifications/v1/Source') as $h) { + foreach (Hook::all('Notifications/v2/Source') as $h) { /** @var SourceHook $h */ try { if ($h->getSourceType() === $source->type) { @@ -237,51 +333,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 diff --git a/application/forms/SourceForm.php b/application/forms/SourceForm.php index 15fa3ab8c..aa2084e5f 100644 --- a/application/forms/SourceForm.php +++ b/application/forms/SourceForm.php @@ -9,7 +9,7 @@ use Icinga\Application\Hook; use Icinga\Application\Logger; use Icinga\Exception\Http\HttpNotFoundException; -use Icinga\Module\Notifications\Hook\V1\SourceHook; +use Icinga\Module\Notifications\Hook\V2\SourceHook; use Icinga\Module\Notifications\Model\Source; use ipl\Html\Attributes; use ipl\Html\HtmlDocument; @@ -57,7 +57,7 @@ protected function assemble(): void self::TYPE_GENERIC => $this->translate('Generic') ]; - foreach (Hook::all('Notifications/v1/Source') as $hook) { + foreach (Hook::all('Notifications/v2/Source') as $hook) { /** @var SourceHook $hook */ try { $type = $hook->getSourceType(); diff --git a/library/Notifications/Hook/V2/SourceHook.php b/library/Notifications/Hook/V2/SourceHook.php new file mode 100644 index 000000000..0b8a9c1c1 --- /dev/null +++ b/library/Notifications/Hook/V2/SourceHook.php @@ -0,0 +1,77 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Notifications\Hook\V2; + +use ipl\Stdlib\Filter\Condition; +use ipl\Web\Control\SearchBar\Suggestions; +use ipl\Web\Widget\Icon; + +interface SourceHook +{ + /** + * Get the type of source this integration is responsible for + * + * @return string + */ + public function getSourceType(): string; + + /** + * 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 the JsonPath metadata for a condition + * + * @param Condition $condition + * + * @return string + */ + public function getJsonPath(Condition $condition): string; + + /** + * Get the Suggestions for the editor + * + * @return Suggestions + */ + public function getSuggestions(): Suggestions; + + /** + * Get the metadata keys for the conditions + * + * Only the given keys will be added to the SearchEditor and stored in the database + * + * @return string[] + */ + public function getMetadataKeys(): array; +} diff --git a/library/Notifications/Model/Source.php b/library/Notifications/Model/Source.php index d69bbee2e..557d33adf 100644 --- a/library/Notifications/Model/Source.php +++ b/library/Notifications/Model/Source.php @@ -8,7 +8,7 @@ use DateTime; use Icinga\Application\Hook; use Icinga\Application\Logger; -use Icinga\Module\Notifications\Hook\V1\SourceHook; +use Icinga\Module\Notifications\Hook\V2\SourceHook; use ipl\Orm\Behavior\BoolCast; use ipl\Orm\Behavior\MillisecondTimestamp; use ipl\Orm\Behaviors; @@ -103,7 +103,7 @@ 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) { + foreach (Hook::all('Notifications/v2/Source') as $hook) { /** @var SourceHook $hook */ try { if ($hook->getSourceType() === $this->type) { diff --git a/library/Notifications/Util/RuleParser.php b/library/Notifications/Util/RuleParser.php new file mode 100644 index 000000000..54fd6d83f --- /dev/null +++ b/library/Notifications/Util/RuleParser.php @@ -0,0 +1,69 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Notifications\Util; + +use ipl\Stdlib\Filter; + +class RuleParser +{ + public function parseJson(string $json): Filter\Rule + { + return $this->parseRule(json_decode($json, true)); + } + + /** + * Parse a serialized rule + * + * @param array $rule A rule serialized as json + * + * @return Filter\Rule + */ + public function parseRule(array $rule): Filter\Rule + { + if (in_array($rule['op'], ['&', '|', '!'])) { + return $this->parseChain($rule); + } else { + return $this->parseCondition($rule); + } + } + + protected function parseChain(array $data): Filter\Chain + { + $rules = []; + foreach ($data['rules'] as $rule) { + $rules[] = $this->parseRule($rule); + } + + + return match ($data['op']) { + '&' => new Filter\All(...$rules), + '|' => new Filter\Any(...$rules), + '!' => new Filter\None(...$rules), + }; + } + + protected function parseCondition(array $data): Filter\Condition + { + $condition = match ($data['op']) { + '!~' => new Filter\Unlike($data['columnName'], $data['value']), + '!=' => new Filter\Unequal($data['columnName'], $data['value']), + '~' => new Filter\Like($data['columnName'], $data['value']), + '=' => new Filter\Equal($data['columnName'], $data['value']), + '>' => new Filter\GreaterThan($data['columnName'], $data['value']), + '<' => new Filter\LessThan($data['columnName'], $data['value']), + '>=' => new Filter\GreaterThanOrEqual($data['columnName'], $data['value']), + '<=' => new Filter\LessThanOrEqual($data['columnName'], $data['value']), + }; + + $condition->metaData()->set('jsonPath', $data['column']); + + foreach ($data['metadata'] ?? [] as $key => $value) { + $condition->metaData()->set($key, $value); + } + + return $condition; + } +} diff --git a/library/Notifications/Util/RuleSerializer.php b/library/Notifications/Util/RuleSerializer.php new file mode 100644 index 000000000..4db2a4981 --- /dev/null +++ b/library/Notifications/Util/RuleSerializer.php @@ -0,0 +1,121 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Module\Notifications\Util; + +use Icinga\Util\Json; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Filter\Chain; + +class RuleSerializer +{ + protected Filter\Rule $filter; + + protected array $metadataKeys = []; + + /** + * Create an object that can be used to serialize a rule to JSON + * + * @param Filter\Rule $filter + */ + public function __construct(Filter\Rule $filter, array $metadataKeys = []) + { + $this->filter = $filter; + $this->metadataKeys = $metadataKeys; + } + + /** + * Serialize the filter as Json + * + * @return string + * + * @throws \Icinga\Exception\Json\JsonEncodeException + */ + public function getJson(): string + { + if ($this->filter instanceof Filter\Chain) { + $result = $this->serializeChain($this->filter); + } else { + /** @var Filter\Condition $this->filter */ + $result = $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: array} + */ + 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`, `column` and `value` from a condition + * + * @param Filter\Condition $condition + * + * @return array{op: string, column: string, value: mixed} + */ + protected function serializeCondition(Filter\Condition $condition): array + { + return [ + '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 => '<=', + }, + 'column' => $condition->metaData()->get('jsonPath'), + 'value' => $condition->getValue(), + 'columnName' => $condition->getColumn(), + 'metadata' => $this->serializeConditionMetadata($condition), + ]; + } + + /** + * Serialize the metadata of a condtion into an array + * + * @param Filter\Condition $condition + * + * @return array + */ + protected function serializeConditionMetadata(Filter\Condition $condition): array + { + $result = []; + foreach ($this->metadataKeys as $key) { + $result[$key] = $condition->metaData()->get($key); + } + + return $result; + } +}