From e906b1d632fd7222ca2ec68fed54b8930bc5e268 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Oct 2025 11:35:23 +0200 Subject: [PATCH 1/3] ObjectSuggestions: Allow to use a fixed set of columns --- .../Control/SearchBar/ObjectSuggestions.php | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php index bf47df1b1..8a8fef25c 100644 --- a/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php +++ b/library/Icingadb/Web/Control/SearchBar/ObjectSuggestions.php @@ -43,6 +43,9 @@ class ObjectSuggestions extends Suggestions /** @var array */ protected $customVarSources; + /** @var ?array */ + protected ?array $fixedColumns = null; + public function __construct() { $this->customVarSources = [ @@ -154,7 +157,7 @@ protected function fetchValueSuggestions($column, $searchTerm, Filter\Chain $sea if (strpos($column, ' ') !== false) { // $column may be a label list($path, $_) = Seq::find( - self::collectFilterColumns($query->getModel(), $query->getResolver()), + $this->fixedColumns ?? self::collectFilterColumns($query->getModel(), $query->getResolver()), $column, false ); @@ -269,7 +272,8 @@ protected function fetchColumnSuggestions($searchTerm) // Ordinary columns comes after exact matches, // or if there ar no exact matches they come first $titleAdded = false; - foreach (self::collectFilterColumns($model, $query->getResolver()) as $columnName => $columnMeta) { + $columns = $this->fixedColumns ?? self::collectFilterColumns($query->getModel(), $query->getResolver()); + foreach ($columns as $columnName => $columnMeta) { if ($this->matchSuggestion($columnName, $columnMeta, $searchTerm)) { if ($titleAdded === false) { $this->addHtml(HtmlElement::create( @@ -518,4 +522,18 @@ public function onlyWithCustomVarSources(array $relations): self return $this; } + + /** + * Provide suggestions based on a fixed set of columns + * + * @param array $columns + * + * @return $this + */ + public function withFixedColumns(array $columns): static + { + $this->fixedColumns = $columns; + + return $this; + } } From 0a3b0b9afb32074f6d37af535fc177d3b0db95db Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Oct 2025 11:36:09 +0200 Subject: [PATCH 2/3] Introduce new route `icingadb/suggest/restriction-column` --- application/controllers/SuggestController.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 application/controllers/SuggestController.php diff --git a/application/controllers/SuggestController.php b/application/controllers/SuggestController.php new file mode 100644 index 000000000..243c3e75b --- /dev/null +++ b/application/controllers/SuggestController.php @@ -0,0 +1,41 @@ +setModel( + match ($this->params->getRequired('type')) { + 'host', Source::TYPE_ALL => Host::class, + 'service' => Service::class, + default => $this->httpBadRequest('Invalid type') + } + ) + ->onlyWithCustomVarSources(['host', 'service']) + ->withFixedColumns([ + 'host.name' => $this->translate('Host Name'), + 'hostgroup.name' => $this->translate('Hostgroup Name'), + 'host.user.name' => $this->translate('Contact Name'), + 'host.usergroup.name' => $this->translate('Contactgroup Name'), + 'service.name' => $this->translate('Service Name'), + 'servicegroup.name' => $this->translate('Servicegroup Name'), + 'service.user.name' => $this->translate('Contact Name'), + 'service.usergroup.name' => $this->translate('Contactgroup Name') + ]); + + $this->getDocument()->addHtml( + $suggestions->forRequest($this->getServerRequest()) + ); + } +} From a0c9f03a78870c5afed4b2042dfdf4f76ba80a71 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Oct 2025 11:37:50 +0200 Subject: [PATCH 3/3] Provide integration for Icinga Notifications Web --- .../ProvidedHook/Notifications/V1/Source.php | 207 ++++++++++++++++++ run.php | 4 + 2 files changed, 211 insertions(+) create mode 100644 library/Icingadb/ProvidedHook/Notifications/V1/Source.php diff --git a/library/Icingadb/ProvidedHook/Notifications/V1/Source.php b/library/Icingadb/ProvidedHook/Notifications/V1/Source.php new file mode 100644 index 000000000..51574c8d2 --- /dev/null +++ b/library/Icingadb/ProvidedHook/Notifications/V1/Source.php @@ -0,0 +1,207 @@ + */ + private array $allowedColumns; + + public function __construct() + { + $this->allowedColumns = [ + 'host.name' => $this->translate('Host Name'), + 'hostgroup.name' => $this->translate('Hostgroup Name'), + 'host.user.name' => $this->translate('Contact Name'), + 'host.usergroup.name' => $this->translate('Contactgroup Name'), + 'service.name' => $this->translate('Service Name'), + 'servicegroup.name' => $this->translate('Servicegroup Name'), + 'service.user.name' => $this->translate('Contact Name'), + 'service.usergroup.name' => $this->translate('Contactgroup Name') + ]; + } + + public function getSourceType(): string + { + return 'icinga2'; + } + + public function getSourceLabel(): string + { + return 'Icinga'; + } + + public function getSourceIcon(): Icon + { + return new IcingaIcon('icinga'); + } + + public function getRuleFilterTargets(int $sourceId): array + { + return [ + 'host' => $this->translate('Hosts only'), + 'service' => $this->translate('Services only'), + self::TYPE_ALL => $this->translate('Hosts and Services') + ]; + } + + public function getRuleFilterEditor(string $filter): SearchEditor + { + if ($filter === 'host' || $filter === 'service' || $filter === self::TYPE_ALL) { + $type = $filter; + $filter = ''; + } else { + try { + $data = 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.' + )); + } + + if ($data['version'] !== 1 || ! isset($data['config']['type']) || ! isset($data['config']['filter'])) { + Logger::error('Invalid rule filter configuration: %s', $filter); + throw new ConfigurationError($this->translate( + 'Invalid rule filter configuration. Please contact your system administrator.' + )); + } + + $type = $data['config']['type']; + $filter = $data['config']['filter']; + } + + $editor = new SearchEditor(); + $editor->setQueryString($filter); + $editor->setSuggestionUrl(Url::fromPath( + 'icingadb/suggest/restriction-column', + ['_disableLayout' => true, 'showCompact' => true, 'type' => $type] + )); + $editor->getParser()->on(QueryString::ON_CONDITION, function (Condition $condition) { + if ($condition->getColumn()) { + if (array_key_exists($condition->getColumn(), $this->allowedColumns)) { + $condition->metaData()->set('columnLabel', $this->allowedColumns[$condition->getColumn()]); + } elseif (preg_match('/^(host|service)\.vars\.(.*)/i', $condition->getColumn(), $m)) { + $prefix = $m[1] === 'host' ? $this->translate('Host') : $this->translate('Service'); + $condition->metaData()->set('columnLabel', $prefix . ' ' . $m[2]); + } + } + }); + $editor->on(SearchEditor::ON_VALIDATE_COLUMN, function (Condition $condition) { + if (! array_key_exists($condition->getColumn(), $this->allowedColumns)) { + if (! preg_match('/^(?:host|service)\.vars\./i', $condition->getColumn())) { + throw new SearchException($this->translate('Is not a valid column')); + } + } + })->on(HtmlDocument::ON_ASSEMBLED, function (SearchEditor $editor) use ($type) { + $editor->prependHtml(new HtmlElement( + 'p', + Attributes::create(['class' => 'description']), + Text::create( + match ($type) { + 'host' => $this->translate( + 'Only hosts matching the following criteria will be affected.' + ), + 'service' => $this->translate( + 'Only services matching the following criteria will be affected.' + ), + self::TYPE_ALL => $this->translate( + 'All hosts and services matching the following criteria will be affected.' + ) + } + ) + )); + + // Not using addElement, as otherwise the submit button is hidden because it's not last-of-type + $hidden = $editor->createElement('hidden', 'type', ['value' => $type]); + $editor->registerElement($hidden); + $editor->prependHtml($hidden); + }); + + return $editor; + } + + public function serializeRuleFilter(Form $editor): string + { + if (! $editor instanceof SearchEditor) { + throw new InvalidArgumentException('Editor must be an instance of ' . SearchEditor::class); + } + + $rule = $editor->getFilter(); + $filter = (new Renderer($rule))->render(); + if ($filter === '') { + return ''; + } + + $type = $editor->getElement('type')->getValue(); + + $queries = []; + if ($type === 'host' || $type === self::TYPE_ALL) { + $queries['host'] = Host::on(Backend::getDb()) + ->filter(Filter::all( + Filter::equal('host.id', ':host_id'), + Filter::equal('host.environment_id', ':environment_id') + )); + } + + if ($type === 'service' || $type === self::TYPE_ALL) { + $queries['service'] = Service::on(Backend::getDb()) + ->filter(Filter::all( + Filter::equal('service.id', ':service_id'), + Filter::equal('service.environment_id', ':environment_id') + )); + } + + return json_encode([ + 'version' => 1, + 'config' => [ + 'type' => $type, + 'filter' => $filter + ], + 'queries' => array_map(function ($query) use ($rule) { + $query->columns([new Expression('1')])->filter($rule)->limit(1); + + [$query, $parameters] = $query->getDb()->getQueryBuilder()->assembleSelect( + $query->assembleSelect()->resetOrderBy() + ); + + return [ + 'query' => $query, + 'parameters' => $parameters + ]; + }, $queries) + ], JSON_THROW_ON_ERROR); + } +} diff --git a/run.php b/run.php index b4c803222..f130da980 100644 --- a/run.php +++ b/run.php @@ -13,6 +13,10 @@ $this->provideHook('Reporting/Report', 'Reporting/ServiceSlaReport'); $this->provideHook('Reporting/Report', 'Reporting/TotalServiceSlaReport'); +if ($this::exists('notifications')) { + $this->provideHook('Notifications/v1/Source'); +} + if ($this::exists('reporting')) { $this->provideHook('Icingadb/HostActions', 'CreateHostSlaReport'); $this->provideHook('Icingadb/ServiceActions', 'CreateServiceSlaReport');