From d36c247d969a7505944082b5d122fb1dfba3fb99 Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Thu, 13 Nov 2025 11:49:34 +0100 Subject: [PATCH 1/9] Apply column controls to csv-/json-exports --- library/Icingadb/Data/CsvResultSetUtils.php | 6 +++++- library/Icingadb/Data/JsonResultSetUtils.php | 5 ++++- library/Icingadb/Web/Controller.php | 8 ++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/library/Icingadb/Data/CsvResultSetUtils.php b/library/Icingadb/Data/CsvResultSetUtils.php index 862260cc8..7979045ba 100644 --- a/library/Icingadb/Data/CsvResultSetUtils.php +++ b/library/Icingadb/Data/CsvResultSetUtils.php @@ -11,6 +11,7 @@ use Icinga\Module\Icingadb\Model\Service; use ipl\Orm\Model; use ipl\Orm\Query; +use ipl\Orm\ResultSet; trait CsvResultSetUtils { @@ -69,7 +70,10 @@ protected function extractKeysAndValues(Model $model, string $path = ''): array public static function stream(Query $query): void { $model = $query->getModel(); - if ($model instanceof Host || $model instanceof Service || $model instanceof DependencyNode) { + if ( + ($model instanceof Host || $model instanceof Service || $model instanceof DependencyNode) + && empty($query->getColumns()) + ) { $query->setResultSetClass(VolatileCsvResults::class); } else { $query->setResultSetClass(__CLASS__); diff --git a/library/Icingadb/Data/JsonResultSetUtils.php b/library/Icingadb/Data/JsonResultSetUtils.php index dc78fe094..e8e1a7439 100644 --- a/library/Icingadb/Data/JsonResultSetUtils.php +++ b/library/Icingadb/Data/JsonResultSetUtils.php @@ -63,7 +63,10 @@ protected function createObject(Model $model): array public static function stream(Query $query): void { $model = $query->getModel(); - if ($model instanceof Host || $model instanceof Service || $model instanceof DependencyNode) { + if ( + ($model instanceof Host || $model instanceof Service || $model instanceof DependencyNode) + && empty($query->getColumns()) + ) { $query->setResultSetClass(VolatileJsonResults::class); } else { $query->setResultSetClass(__CLASS__); diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php index f18bfa5b8..f0e264981 100644 --- a/library/Icingadb/Web/Controller.php +++ b/library/Icingadb/Web/Controller.php @@ -58,6 +58,9 @@ class Controller extends CompatController /** @var bool */ private $formatProcessed = false; + /** @var array|null Columns to be included in csv/json exports, when null all columns are included */ + protected ?array $columns = null; + /** * Get the filter created from query string parameters * @@ -103,6 +106,7 @@ public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwit } $query->withColumns($columns); + $this->columns = $columns; if (! $viewMode) { $viewModeSwitcher->setViewMode('tabular'); @@ -366,6 +370,10 @@ public function export(Query ...$queries) $query->limit(null) ->offset(null); } + + if ($this->columns !== null) { + $query->columns($this->columns); + } } if ($this->format === 'json' || $this->format === 'csv') { From 0d77df29115dd67c61a5fbe3afb843c9f72cc3cc Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Thu, 13 Nov 2025 11:52:03 +0100 Subject: [PATCH 2/9] remove unused import --- library/Icingadb/Data/CsvResultSetUtils.php | 1 - 1 file changed, 1 deletion(-) diff --git a/library/Icingadb/Data/CsvResultSetUtils.php b/library/Icingadb/Data/CsvResultSetUtils.php index 7979045ba..c291c4bd5 100644 --- a/library/Icingadb/Data/CsvResultSetUtils.php +++ b/library/Icingadb/Data/CsvResultSetUtils.php @@ -11,7 +11,6 @@ use Icinga\Module\Icingadb\Model\Service; use ipl\Orm\Model; use ipl\Orm\Query; -use ipl\Orm\ResultSet; trait CsvResultSetUtils { From cdc4920ad207dc9ac44bd8e53e44596a689a7f5e Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Mon, 17 Nov 2025 15:03:25 +0100 Subject: [PATCH 3/9] Add column-filter during redis updates and remove empty json objects during export --- library/Icingadb/Data/CsvResultSetUtils.php | 5 +--- library/Icingadb/Data/JsonResultSetUtils.php | 10 +++---- .../Icingadb/Redis/VolatileStateResults.php | 27 ++++++++++++++++--- library/Icingadb/Web/Controller.php | 22 ++++++++------- 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/library/Icingadb/Data/CsvResultSetUtils.php b/library/Icingadb/Data/CsvResultSetUtils.php index c291c4bd5..862260cc8 100644 --- a/library/Icingadb/Data/CsvResultSetUtils.php +++ b/library/Icingadb/Data/CsvResultSetUtils.php @@ -69,10 +69,7 @@ protected function extractKeysAndValues(Model $model, string $path = ''): array public static function stream(Query $query): void { $model = $query->getModel(); - if ( - ($model instanceof Host || $model instanceof Service || $model instanceof DependencyNode) - && empty($query->getColumns()) - ) { + if ($model instanceof Host || $model instanceof Service || $model instanceof DependencyNode) { $query->setResultSetClass(VolatileCsvResults::class); } else { $query->setResultSetClass(__CLASS__); diff --git a/library/Icingadb/Data/JsonResultSetUtils.php b/library/Icingadb/Data/JsonResultSetUtils.php index e8e1a7439..63c193cf5 100644 --- a/library/Icingadb/Data/JsonResultSetUtils.php +++ b/library/Icingadb/Data/JsonResultSetUtils.php @@ -51,7 +51,10 @@ protected function createObject(Model $model): array $keysAndValues = []; foreach ($model as $key => $value) { if ($value instanceof Model) { - $keysAndValues[$key] = $this->createObject($value); + $object = $this->createObject($value); + if ($object !== []) { + $keysAndValues[$key] = $object; + } } else { $keysAndValues[$key] = $this->formatValue($key, $value); } @@ -63,10 +66,7 @@ protected function createObject(Model $model): array public static function stream(Query $query): void { $model = $query->getModel(); - if ( - ($model instanceof Host || $model instanceof Service || $model instanceof DependencyNode) - && empty($query->getColumns()) - ) { + if ($model instanceof Host || $model instanceof Service || $model instanceof DependencyNode) { $query->setResultSetClass(VolatileJsonResults::class); } else { $query->setResultSetClass(__CLASS__); diff --git a/library/Icingadb/Redis/VolatileStateResults.php b/library/Icingadb/Redis/VolatileStateResults.php index f3459e2b9..1c377b318 100644 --- a/library/Icingadb/Redis/VolatileStateResults.php +++ b/library/Icingadb/Redis/VolatileStateResults.php @@ -36,11 +36,17 @@ class VolatileStateResults extends ResultSet /** @var string Object type service */ protected const TYPE_SERVICE = 'service'; + /** @var array|null Columns to be selected if they were explicitly set, if null all columns are selected */ + protected ?array $columns = null; + public static function fromQuery(Query $query) { $self = parent::fromQuery($query); $self->resolver = $query->getResolver(); $self->redisUnavailable = Backend::getRedis()->isUnavailable(); + if (! empty($query->getColumns())) { + $self->columns = $query->getColumns(); + } return $self; } @@ -109,8 +115,21 @@ protected function applyRedisUpdates($rows) $type = null; $showSourceGranted = $this->getAuth()->hasPermission('icingadb/object/show-source'); - $getKeysAndBehaviors = function (State $state): array { - return [$state->getColumns(), $this->resolver->getBehaviors($state)]; + $getKeysAndBehaviors = function (State $state, $type): array { + $columns = array_filter($state->getColumns(), function ($column) { + return ! str_ends_with($column, '_id'); + }); + + if ($this->columns !== null) { + $normalizedColumns = array_map(function ($column) use ($type) { + return preg_replace("/^($type\.state\.|state\.)/", '', $column); + }, iterator_to_array($this->columns)); + + $stateColumns = array_intersect($normalizedColumns, $columns); + return [$stateColumns, $this->resolver->getBehaviors($state)]; + } + + return [$columns, $this->resolver->getBehaviors($state)]; }; $states = []; @@ -141,7 +160,7 @@ protected function applyRedisUpdates($rows) $states[$type][bin2hex($row->id)] = $row->state; if (! isset($states[$type]['keys'])) { - [$keys, $behaviors] = $getKeysAndBehaviors($row->state); + [$keys, $behaviors] = $getKeysAndBehaviors($row->state, $type); if (! $showSourceGranted) { $keys = array_diff($keys, ['check_commandline']); @@ -155,7 +174,7 @@ protected function applyRedisUpdates($rows) $states[self::TYPE_HOST][bin2hex($row->host->id)] = $row->host->state; if (! isset($states[self::TYPE_HOST]['keys'])) { - [$keys, $behaviors] = $getKeysAndBehaviors($row->host->state); + [$keys, $behaviors] = $getKeysAndBehaviors($row->host->state, $type); $states[self::TYPE_HOST]['keys'] = $keys; $states[self::TYPE_HOST]['behaviors'] = $behaviors; diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php index f0e264981..6e58979ec 100644 --- a/library/Icingadb/Web/Controller.php +++ b/library/Icingadb/Web/Controller.php @@ -22,6 +22,8 @@ use Icinga\Module\Icingadb\Common\SearchControls; use Icinga\Module\Icingadb\Data\CsvResultSet; use Icinga\Module\Icingadb\Data\JsonResultSet; +use Icinga\Module\Icingadb\Model\Host; +use Icinga\Module\Icingadb\Model\Service; use Icinga\Module\Icingadb\Web\Control\GridViewModeSwitcher; use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher; use Icinga\Module\Icingadb\Widget\ItemTable\StateItemTable; @@ -58,9 +60,6 @@ class Controller extends CompatController /** @var bool */ private $formatProcessed = false; - /** @var array|null Columns to be included in csv/json exports, when null all columns are included */ - protected ?array $columns = null; - /** * Get the filter created from query string parameters * @@ -105,8 +104,17 @@ public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwit } } - $query->withColumns($columns); - $this->columns = $columns; + if ($this->format === 'csv' || $this->format === 'json') { + if ($query->getModel() instanceof Host && ! in_array('host.id', $columns)) { + $columns[] = 'host.id'; + } elseif ($query->getModel() instanceof Service && ! in_array('service.id', $columns)) { + $columns[] = 'service.id'; + } + + $query->columns($columns); + } else { + $query->withColumns($columns); + } if (! $viewMode) { $viewModeSwitcher->setViewMode('tabular'); @@ -370,10 +378,6 @@ public function export(Query ...$queries) $query->limit(null) ->offset(null); } - - if ($this->columns !== null) { - $query->columns($this->columns); - } } if ($this->format === 'json' || $this->format === 'csv') { From d8d12fbb2376e965c025ebd22ffa94d1b5009bcb Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Mon, 17 Nov 2025 16:01:29 +0100 Subject: [PATCH 4/9] Remove unecessary iterator_to_array call --- library/Icingadb/Redis/VolatileStateResults.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Icingadb/Redis/VolatileStateResults.php b/library/Icingadb/Redis/VolatileStateResults.php index 1c377b318..989aa5985 100644 --- a/library/Icingadb/Redis/VolatileStateResults.php +++ b/library/Icingadb/Redis/VolatileStateResults.php @@ -123,7 +123,7 @@ protected function applyRedisUpdates($rows) if ($this->columns !== null) { $normalizedColumns = array_map(function ($column) use ($type) { return preg_replace("/^($type\.state\.|state\.)/", '', $column); - }, iterator_to_array($this->columns)); + }, $this->columns); $stateColumns = array_intersect($normalizedColumns, $columns); return [$stateColumns, $this->resolver->getBehaviors($state)]; From efb3c9176b98798f95e3461d8d70d8ff6186c1f6 Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Tue, 18 Nov 2025 08:46:14 +0100 Subject: [PATCH 5/9] Add comments and minor improvements --- library/Icingadb/Data/JsonResultSetUtils.php | 4 +++- library/Icingadb/Redis/VolatileStateResults.php | 17 ++++++++--------- library/Icingadb/Web/Controller.php | 2 ++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/library/Icingadb/Data/JsonResultSetUtils.php b/library/Icingadb/Data/JsonResultSetUtils.php index 63c193cf5..a7e1a0dc7 100644 --- a/library/Icingadb/Data/JsonResultSetUtils.php +++ b/library/Icingadb/Data/JsonResultSetUtils.php @@ -52,7 +52,9 @@ protected function createObject(Model $model): array foreach ($model as $key => $value) { if ($value instanceof Model) { $object = $this->createObject($value); - if ($object !== []) { + // If there is no value in the model or it's descendents, + // it was not a part of the query, so no JSON object will be created for this model. + if (! empty($object)) { $keysAndValues[$key] = $object; } } else { diff --git a/library/Icingadb/Redis/VolatileStateResults.php b/library/Icingadb/Redis/VolatileStateResults.php index 989aa5985..38cd8cde7 100644 --- a/library/Icingadb/Redis/VolatileStateResults.php +++ b/library/Icingadb/Redis/VolatileStateResults.php @@ -36,17 +36,15 @@ class VolatileStateResults extends ResultSet /** @var string Object type service */ protected const TYPE_SERVICE = 'service'; - /** @var array|null Columns to be selected if they were explicitly set, if null all columns are selected */ - protected ?array $columns = null; + /** @var array Columns to be selected if they were explicitly set, if empty all columns are selected */ + protected array $columns; public static function fromQuery(Query $query) { $self = parent::fromQuery($query); $self->resolver = $query->getResolver(); $self->redisUnavailable = Backend::getRedis()->isUnavailable(); - if (! empty($query->getColumns())) { - $self->columns = $query->getColumns(); - } + $self->columns = $query->getColumns(); return $self; } @@ -120,10 +118,11 @@ protected function applyRedisUpdates($rows) return ! str_ends_with($column, '_id'); }); - if ($this->columns !== null) { - $normalizedColumns = array_map(function ($column) use ($type) { - return preg_replace("/^($type\.state\.|state\.)/", '', $column); - }, $this->columns); + if (! empty($this->columns)) { + $normalizedColumns = array_map( + fn($column) => preg_replace("/^($type\.state\.|state\.)/", '', $column), + $this->columns + ); $stateColumns = array_intersect($normalizedColumns, $columns); return [$stateColumns, $this->resolver->getBehaviors($state)]; diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php index 6e58979ec..33ff1873f 100644 --- a/library/Icingadb/Web/Controller.php +++ b/library/Icingadb/Web/Controller.php @@ -104,6 +104,8 @@ public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwit } } + // When exporting as CSV or JSON, and the user requested specific columns, only those should be included + // The model's id must always be selected, to ensure redis updates can be applied. if ($this->format === 'csv' || $this->format === 'json') { if ($query->getModel() instanceof Host && ! in_array('host.id', $columns)) { $columns[] = 'host.id'; From 31a339d327d1f9c3c1a563ccbf59219cfa1a80b7 Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Tue, 18 Nov 2025 09:45:44 +0100 Subject: [PATCH 6/9] Update docs --- doc/05-Upgrading.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/05-Upgrading.md b/doc/05-Upgrading.md index 5b15f237e..f1e82c59e 100644 --- a/doc/05-Upgrading.md +++ b/doc/05-Upgrading.md @@ -16,6 +16,11 @@ If you are upgrading across multiple versions, make sure to follow the steps for if another role denies access to different variables. The same applies to `icingadb/protect/variables`, in which case variables protected in one role will now be protected even if another role protects different variables. This has been done to simplify the configuration and to get it more in line with how refusals work in Icinga Web. +* When using the `?columns` parameter to filter for specific columns and exporting to CSV/JSON, the exported file will + only contain the listed columns of the Hosts/Services, if the parameter is not set, all columns will be included. +* If a relation is entirely empty and would result in an empty JSON-object, the JSON-export will not create an object + for it at all. (instead of `{"someKey":"someValue","emptyObject":{}}` we will now export `{"someKey":"someValue"}`) + **Removed Features** From 68895d35c33ca1370c75dd446fc6646edfcfbe59 Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Tue, 18 Nov 2025 11:24:00 +0100 Subject: [PATCH 7/9] fix: ensure csv header is only printed once --- library/Icingadb/Data/CsvResultSetUtils.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/library/Icingadb/Data/CsvResultSetUtils.php b/library/Icingadb/Data/CsvResultSetUtils.php index 862260cc8..ac96e713b 100644 --- a/library/Icingadb/Data/CsvResultSetUtils.php +++ b/library/Icingadb/Data/CsvResultSetUtils.php @@ -85,12 +85,14 @@ public static function stream(Query $query): void $offset = 0; } + $headerPrinted = false; do { $query->offset($offset); $result = $query->execute()->disableCache(); - foreach ($result as $i => $keysAndValues) { - if ($i === 0) { + foreach ($result as $keysAndValues) { + if (! $headerPrinted) { echo implode(',', array_keys($keysAndValues)); + $headerPrinted = true; } echo "\r\n"; From 80ea7141f8c0632270e2ae30b0aa7c9f80f1e140 Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Tue, 18 Nov 2025 16:08:36 +0100 Subject: [PATCH 8/9] Add ids in VolatileStateResults instead of Controller and remove them from the results --- .../Icingadb/Redis/VolatileStateResults.php | 28 +++++++++++++++++-- library/Icingadb/Web/Controller.php | 7 ----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/library/Icingadb/Redis/VolatileStateResults.php b/library/Icingadb/Redis/VolatileStateResults.php index 38cd8cde7..5c1994e49 100644 --- a/library/Icingadb/Redis/VolatileStateResults.php +++ b/library/Icingadb/Redis/VolatileStateResults.php @@ -36,8 +36,11 @@ class VolatileStateResults extends ResultSet /** @var string Object type service */ protected const TYPE_SERVICE = 'service'; - /** @var array Columns to be selected if they were explicitly set, if empty all columns are selected */ - protected array $columns; + /** @var array|null Columns to be selected if they were explicitly set, if empty all columns are selected */ + protected ?array $columns; + + /** @var bool Whether the model's ID should be contained in the results */ + protected bool $includeModelID = true; public static function fromQuery(Query $query) { @@ -46,6 +49,20 @@ public static function fromQuery(Query $query) $self->redisUnavailable = Backend::getRedis()->isUnavailable(); $self->columns = $query->getColumns(); + if (! empty($self->columns)) { + // The id is necessary to apply the redis-updates + if ($query->getModel() instanceof Host && empty(array_intersect(['host.id', 'id'], $self->columns))) { + $query->withColumns('host.id'); + $self->includeModelID = false; + } elseif ( + $query->getModel() instanceof Service && + empty(array_intersect(['service.id', 'id'], $self->columns)) + ) { + $query->withColumns('service.id'); + $self->includeModelID = false; + } + } + return $self; } @@ -66,7 +83,12 @@ public function current() $this->rewind(); } - return parent::current(); + $result = parent::current(); + if (! $this->includeModelID) { + unset($result['id']); + } + + return $result; } public function next(): void diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php index 33ff1873f..08d8ce3f6 100644 --- a/library/Icingadb/Web/Controller.php +++ b/library/Icingadb/Web/Controller.php @@ -105,14 +105,7 @@ public function createColumnControl(Query $query, ViewModeSwitcher $viewModeSwit } // When exporting as CSV or JSON, and the user requested specific columns, only those should be included - // The model's id must always be selected, to ensure redis updates can be applied. if ($this->format === 'csv' || $this->format === 'json') { - if ($query->getModel() instanceof Host && ! in_array('host.id', $columns)) { - $columns[] = 'host.id'; - } elseif ($query->getModel() instanceof Service && ! in_array('service.id', $columns)) { - $columns[] = 'service.id'; - } - $query->columns($columns); } else { $query->withColumns($columns); From 874f31ea814f375205882022a729bb6a8ec3a4c2 Mon Sep 17 00:00:00 2001 From: Bastian Lederer Date: Wed, 19 Nov 2025 11:38:53 +0100 Subject: [PATCH 9/9] remove unsued imports --- library/Icingadb/Web/Controller.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/library/Icingadb/Web/Controller.php b/library/Icingadb/Web/Controller.php index 08d8ce3f6..ba59e0fba 100644 --- a/library/Icingadb/Web/Controller.php +++ b/library/Icingadb/Web/Controller.php @@ -22,8 +22,6 @@ use Icinga\Module\Icingadb\Common\SearchControls; use Icinga\Module\Icingadb\Data\CsvResultSet; use Icinga\Module\Icingadb\Data\JsonResultSet; -use Icinga\Module\Icingadb\Model\Host; -use Icinga\Module\Icingadb\Model\Service; use Icinga\Module\Icingadb\Web\Control\GridViewModeSwitcher; use Icinga\Module\Icingadb\Web\Control\ViewModeSwitcher; use Icinga\Module\Icingadb\Widget\ItemTable\StateItemTable;