Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 136 additions & 61 deletions application/controllers/EventRuleController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +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\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\Stdlib\Seq;
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\FormElement\SearchSuggestions;
use ipl\Web\Url;
use ipl\Web\Widget\Icon;
use ipl\Web\Widget\Link;
use JsonException;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;

Expand Down Expand Up @@ -195,7 +203,130 @@ 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.'
));
}

$version = $parsedFilter['version'] ?? null;
if ($version !== RuleSerializer::VERSION) {
Logger::error(
'Cannot load filter for rule with id %d: filter version \'%s\' is not supported (expected %d)',
$ruleId,
$version,
RuleSerializer::VERSION
);
throw new ConfigurationError($this->translate(
'Unsupported rule filter version. 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) {
try {
$hook->assertValidCondition($condition);
} catch (SearchException $e) {
throw $e;
} catch (Throwable $e) {
Logger::error(
'Source hook %s failed to validate filter condition: %s',
get_class($hook),
$e
);

throw new SearchException($this->translate(
'Failed to validate column. Please contact your system administrator.'
));
}
}
)
->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'));
});

$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
);
}
});
$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'));
$requestData = SearchSuggestions::parseRequest($this->getServerRequest()) ?? [];

$type = $requestData['term']['type'] ?? null;
$label = $requestData['term']['label'] ?? '';
$failureMessage = null;

if ($type === 'column') {
$provider = $hook->getColumnSuggestions($label);
} else {
$column = $requestData['column'] ?? null;
if ($column === null || $column === SearchEditor::FAKE_COLUMN) {
$failureMessage = $this->translate('Missing column name');
$provider = [];
} else {
/** @var Filter\Chain $searchFilter */
$searchFilter = QueryString::parse($requestData['searchFilter'] ?? '');
$provider = $hook->getValueSuggestions($column, $label, $searchFilter);
}
}

$suggestions = (new SearchSuggestions($provider))
->setSearchTerm($label)
->setOriginalSearchValue($requestData['term']['search'] ?? '')
->showFailureMessage($failureMessage);

if ($type === 'column') {
$suggestions->setGroupingCallback(fn ($x) => $x['group']);
}

$this->getDocument()->addHtml($suggestions);
}

protected function resolveSourceHook(int $ruleId): SourceHook
{
$source = null;
if ($ruleId !== -1) {
$source = Rule::on(Database::get())
Expand All @@ -216,19 +347,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(
Expand All @@ -237,51 +356,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
Expand Down
8 changes: 5 additions & 3 deletions application/forms/EventRuleConfigForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,15 @@ public function prepareConfigUpdate(string $newName, int $newSource): ValidHtml
/**
* Get the element to update in case the object filter of the rule is changed
*
* @param string $newFilter
* @param ?string $newFilter
*
* @return ValidHtml
*/
public function prepareObjectFilterUpdate(string $newFilter): ValidHtml
public function prepareObjectFilterUpdate(?string $newFilter): ValidHtml
{
$this->populate(['object_filter' => $newFilter]);
if ($newFilter !== null) {
$this->populate(['object_filter' => $newFilter]);
}

return new HtmlElement(
'div',
Expand Down
28 changes: 2 additions & 26 deletions application/forms/SourceForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand All @@ -51,31 +47,14 @@ 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']),
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. 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.'
))
));

Expand All @@ -88,14 +67,11 @@ protected function assemble(): void
]
);
$this->addElement(
'select',
'text',
'type',
[
'required' => true,
'label' => $this->translate('Source Type'),
'class' => 'autosubmit',
'options' => $types,
'disabledOptions' => ['']
]
);

Expand Down
38 changes: 38 additions & 0 deletions library/Notifications/Common/SourceHookLocator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
// 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');
}
}
Loading
Loading