From cd5b920c419caa6fd753d6940b54b1eba3352f46 Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Wed, 3 Jun 2026 11:51:10 +0200 Subject: [PATCH 1/2] `SourceForm`: Adjust configuration of generic and integrated sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow the user to fisrt select `generic` or `integrated`, only śhow the source identifier field for `integrated` and adjust the descriptions. --- application/forms/SourceForm.php | 101 +++++++++++++++++-------------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/application/forms/SourceForm.php b/application/forms/SourceForm.php index 015bf9e66..749f6d9e0 100644 --- a/application/forms/SourceForm.php +++ b/application/forms/SourceForm.php @@ -19,8 +19,6 @@ use ipl\Web\Compat\CompatForm; use ipl\Web\Url; use ipl\Web\Widget\ButtonLink; -use ipl\Web\Widget\Icon; -use ipl\Web\Widget\Link; class SourceForm extends CompatForm { @@ -32,6 +30,9 @@ class SourceForm extends CompatForm /** @var string @var The generic source type */ public const TYPE_GENERIC = 'generic'; + /** @var string The type for sources with an integration */ + private const TYPE_INTEGRATED = 'integrated'; + /** @var Connection */ private Connection $db; @@ -53,8 +54,8 @@ protected function assemble(): void Text::create($this->translate( '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. Refer to the source\'s documentation for the correct source type.' + . ' integration in Icinga Web, or use the generic type for sources that communicate directly with' + . ' the Icinga Notifications API.' )) )); @@ -67,14 +68,45 @@ protected function assemble(): void ] ); $this->addElement( - 'text', - 'type', + 'select', + 'source_type', [ - 'required' => true, - 'label' => $this->translate('Source Type'), + 'ignore' => true, + 'required' => true, + 'label' => $this->translate('Source Type'), + 'value' => self::TYPE_GENERIC, + 'class' => 'autosubmit', + 'options' => [ + self::TYPE_GENERIC => $this->translate('Generic', 'notifications.source.type'), + self::TYPE_INTEGRATED => $this->translate('Integrated', 'notifications.source.type') + ] ] ); + if ($this->getPopulatedValue('source_type') === self::TYPE_INTEGRATED) { + $this->addHtml( + new HtmlElement( + 'p', + Attributes::create(['class' => 'description']), + Text::create( + $this->translate( + 'Enter the source identifier as stated in the integration\'s documentation.' + . ' Note that integrated sources usually provide their own configuration interface for' + . ' notifications, which is the recommended way to set them up.' + ) + ) + ) + ); + $this->addElement( + 'text', + 'type', + [ + 'required' => true, + 'label' => $this->translate('Source Identifier'), + ] + ); + } + $this->addElement('fieldset', 'credentials', [ 'label' => $this->translate('Source Credentials') ]); @@ -85,41 +117,9 @@ protected function assemble(): void Text::create($this->translate( 'These credentials will be used by the source to authenticate' . ' against Icinga Notifications when submitting events. You will need to add this to the' - . ' source\'s configuration as well:' - )), - Text::create(' '), - match ($this->getValue('type')) { - 'icinga2' => new Link( - [ - $this->translate('Icinga DB Documentation'), - ' ', - new Icon('arrow-up-right-from-square') - ], - Url::fromPath( - 'https://icinga.com/docs/icinga-db' - . '/latest/doc/03-Configuration/#notifications-configuration' - ), - ['target' => '_blank'] - ), - 'kubernetes' => new Link( - [ - $this->translate('Icinga for Kubernetes Documentation'), - ' ', - new Icon('arrow-up-right-from-square') - ], - Url::fromPath( - 'https://icinga.com/docs/icinga-for-kubernetes' - . '/latest/doc/03-Configuration/#notifications-configuration' - ), - ['target' => '_blank'] - ), - self::TYPE_GENERIC => Text::create($this->translate( - 'Consult the documentation of your source for configuration details.' - )), - default => Text::create($this->translate( - 'Please choose the source type above to see the required configuration.' - )) - } + . ' source\'s configuration as well.' + . ' Consult the documentation of your source for configuration details.' + )) )); $credentials->addElement( @@ -214,7 +214,16 @@ public function loadSource(int $id): static { $this->sourceId = $id; - $this->populate($this->fetchDbValues()); + $values = $this->fetchDbValues(); + + if ($values['type'] === self::TYPE_GENERIC) { + unset($values['type']); + $values['source_type'] = self::TYPE_GENERIC; + } else { + $values['source_type'] = self::TYPE_INTEGRATED; + } + + $this->populate($values); return $this; } @@ -228,7 +237,7 @@ public function addSource(): void $source = [ 'name' => $data['name'], - 'type' => $data['type'], + 'type' => $this->getValue('type', self::TYPE_GENERIC), 'listener_username' => $data['credentials']['listener_username'], // Not using PASSWORD_DEFAULT, as the used algorithm should // be kept in sync with what the daemon understands @@ -255,7 +264,7 @@ public function editSource(): void $source = [ 'name' => $data['name'], - 'type' => $data['type'], + 'type' => $this->getValue('type', self::TYPE_GENERIC), 'listener_username' => $data['credentials']['listener_username'] ]; From 742d4f59e4bb2bd1a858c5efbd796f245f389e93 Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Wed, 3 Jun 2026 11:53:32 +0200 Subject: [PATCH 2/2] Allow filter configuration of rules for generic sources Create a simple `SearchEditor` without suggestions, enrichment and validation when editing the filter of a genric rule. --- .../controllers/EventRuleController.php | 92 +++++++++++++------ public/css/form.less | 4 + 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index fc35fd342..00e8b4df9 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -16,6 +16,7 @@ use Icinga\Module\Notifications\Forms\EventRuleConfigElements\NotificationConfigProvider; use Icinga\Module\Notifications\Forms\EventRuleConfigForm; use Icinga\Module\Notifications\Forms\EventRuleForm; +use Icinga\Module\Notifications\Forms\SourceForm; use Icinga\Module\Notifications\Hook\V2\SourceHook; use Icinga\Module\Notifications\Model\Rule; use Icinga\Module\Notifications\Model\Source; @@ -26,15 +27,19 @@ use ipl\Html\Attributes; use ipl\Html\Contract\Form; use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\Text; use ipl\Stdlib\Filter; use ipl\Stdlib\Filter\Condition; use ipl\Stdlib\Seq; +use ipl\Web\Common\CalloutType; use ipl\Web\Compat\CompatController; use ipl\Web\Control\SearchBar\SearchException; use ipl\Web\Control\SearchEditor; use ipl\Web\Filter\QueryString; use ipl\Web\FormElement\SearchSuggestions; use ipl\Web\Url; +use ipl\Web\Widget\Callout; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; use JsonException; @@ -233,14 +238,15 @@ public function searchEditorAction(): void $editor = (new SearchEditor()) ->setQueryString($parsedFilter['qs'] ?? '') - ->setSuggestionUrl( + ->setAction(Url::fromRequest()->with('object_filter', $filter)->getAbsoluteUrl()); + + if ($hook !== null) { + $editor->setSuggestionUrl( Url::fromPath( 'notifications/event-rule/suggest', ['id' => $ruleId, '_disableLayout' => true, 'showCompact' => true] ) - ) - ->setAction(Url::fromRequest()->with('object_filter', $filter)->getAbsoluteUrl()) - ->on( + )->on( SearchEditor::ON_VALIDATE_COLUMN, function (Condition $condition) use ($hook) { try { @@ -259,32 +265,56 @@ function (Condition $condition) use ($hook) { )); } } - ) - ->on(Form::ON_SUBMIT, function (SearchEditor $form) use ($ruleId, $hook) { - $filter = $form->getFilter(); - - $this->session->set( - 'object_filter', - (new RuleSerializer( - $filter, - $hook->getJsonPaths(...Seq::unique(Seq::map($filter->yieldRules(), fn($r) => $r->getColumn()))) - ))->getJson() - ); - $this->redirectNow(Links::eventRule($ruleId)->setParam('_filterOnly')); + )->getParser()->on(QueryString::ON_CONDITION, function (Condition $condition) use ($hook) { + try { + $hook->enrichCondition($condition); + } catch (Throwable $e) { + Logger::error( + 'Source hook %s failed to enrich filter condition: %s', + get_class($hook), + $e + ); + } }); - - $editor->getParser()->on(QueryString::ON_CONDITION, function (Condition $condition) use ($hook) { - try { - $hook->enrichCondition($condition); - } catch (Throwable $e) { - Logger::error( - 'Source hook %s failed to enrich filter condition: %s', - get_class($hook), - $e + $getJsonPaths = function (Filter\Chain $filter) use ($hook) { + return $hook->getJsonPaths( + ...Seq::unique( + Seq::map($filter->yieldRules(), fn($r) => $r->getColumn()) + ) ); - } - }); - $editor->handleRequest($this->getServerRequest()); + }; + } else { + $getJsonPaths = function (Filter\Chain $filter) { + $jsonPaths = []; + foreach (Seq::unique(Seq::map($filter->yieldRules(), fn($r) => $r->getColumn())) as $path) { + $jsonPaths[$path] = [$path]; + } + + return $jsonPaths; + }; + } + + $editor->on(Form::ON_SUBMIT, function (SearchEditor $form) use ($ruleId, $getJsonPaths) { + $filter = $form->getFilter(); + $this->session->set('object_filter', (new RuleSerializer($filter, $getJsonPaths($filter)))->getJson()); + $this->redirectNow(Links::eventRule($ruleId)->setParam('_filterOnly')); + })->handleRequest($this->getServerRequest()); + + if ($hook === null) { + $this->getDocument()->addHtml( + (new Callout( + CalloutType::Info, + Text::create( + $this->translate( + 'Please make sure columns are valid JSON paths, ' + . 'as no validation is available for this source. ' + . 'Refer to the source\'s documentation for available columns.' + ) + ) + )) + ->addAttributes(Attributes::create(['class' => 'generic-source-hint'])) + ); + } $this->getDocument()->addHtml($editor); @@ -326,7 +356,7 @@ public function suggestAction(): void $this->getDocument()->addHtml($suggestions); } - protected function resolveSourceHook(int $ruleId): SourceHook + protected function resolveSourceHook(int $ruleId): ?SourceHook { $source = null; if ($ruleId !== -1) { @@ -349,6 +379,10 @@ protected function resolveSourceHook(int $ruleId): SourceHook $this->httpNotFound($this->translate('Rule not found')); } + if ($source->type === SourceForm::TYPE_GENERIC) { + return null; + } + $hook = SourceHookLocator::forType($source->type); if ($hook === null) { diff --git a/public/css/form.less b/public/css/form.less index 0f88ea3b8..2cfb4a4b1 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -78,6 +78,10 @@ } } +.generic-source-hint { + margin: 1em 1em 0 1em; +} + /* Style */ .icinga-controls {