feat: optimize Zabbix queries and enhance specialized device support

- Implement query optimization (reduced output, parameter skipping) to minimize Zabbix API traffic.

- Add indirect dependency handling: deviceType implies tags and state implies items.

- Move schema extensions to samples/extensions/ to clarify their role as samples.

- Enhance DistanceTrackerDevice with String time fields to support optional date portions.

- Ensure allDevices strictly filters by deviceType and populates the field in results.

- Refactor runAllRegressionTests mutation to use internal unique names and improve stability.

- Fix unnecessary Zabbix API calls for item preprocessing during template and host imports.

- Update documentation including cookbook recipes, test specifications, and optimization guides.

- Add extensive unit, integration, and regression tests covering all implemented changes.

- Update docker-compose.yml to mount the samples/ directory as a volume.

- Update IntelliJ .idea run configurations to reflect the new sample extension paths.
This commit is contained in:
Andreas Hilbig 2026-02-02 13:20:06 +01:00
parent 97a0f70fd6
commit b646b8c606
28 changed files with 551 additions and 74 deletions

View file

@ -2,7 +2,7 @@
<configuration default="false" name="index.ts" type="NodeJSConfigurationType" path-to-node="wsl://Ubuntu@/home/ahilbig/.nvm/versions/node/v24.12.0/bin/node" nameIsGenerated="true" path-to-js-file="src/index.ts" node-parameters="--import tsx" working-dir="$PROJECT_DIR$"> <configuration default="false" name="index.ts" type="NodeJSConfigurationType" path-to-node="wsl://Ubuntu@/home/ahilbig/.nvm/versions/node/v24.12.0/bin/node" nameIsGenerated="true" path-to-js-file="src/index.ts" node-parameters="--import tsx" working-dir="$PROJECT_DIR$">
<envs> <envs>
<env name="ADDITIONAL_RESOLVERS" value="SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice" /> <env name="ADDITIONAL_RESOLVERS" value="SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice" />
<env name="ADDITIONAL_SCHEMAS" value="./schema/extensions/display_devices.graphql,./schema/extensions/location_tracker_devices.graphql,./schema/extensions/location_tracker_commons.graphql" /> <env name="ADDITIONAL_SCHEMAS" value="./samples/extensions/display_devices.graphql,./samples/extensions/location_tracker_devices.graphql,./samples/extensions/location_tracker_commons.graphql" />
<env name="DEBUG" value="device-control-center-api:*" /> <env name="DEBUG" value="device-control-center-api:*" />
<env name="ZABBIX_PRIVILEGE_ESCALATION_TOKEN" value="$ZABBIX_AUTH_TOKEN_VCR_DEV$" /> <env name="ZABBIX_PRIVILEGE_ESCALATION_TOKEN" value="$ZABBIX_AUTH_TOKEN_VCR_DEV$" />
<env name="ZABBIX_BASE_URL" value="http://cockpit.vcr.develop.hilbigit.com/" /> <env name="ZABBIX_BASE_URL" value="http://cockpit.vcr.develop.hilbigit.com/" />

50
.idea/workspace.xml generated
View file

@ -5,18 +5,34 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="d7a71994-2699-4ae4-9fd2-ee13b7f33d35" name="Changes" comment="docs: refactor documentation and upgrade to Node.js 24&#10;&#10;This commit upgrades the project to Node.js 24 (LTS) and performs a major refactoring of the documentation to support both advanced users and AI-based automation (MCP).&#10;&#10;Changes:&#10;- Environment &amp; CI/CD:&#10; - Set Node.js version to &gt;=24 in package.json and .nvmrc.&#10; - Updated Dockerfile to use Node 24 base image.&#10; - Updated @types/node to ^24.10.9.&#10;- Documentation:&#10; - Refactored README.md with comprehensive technical reference, configuration details, and Zabbix-to-GraphQL mapping.&#10; - Created docs/howtos/cookbook.md with practical recipes for common tasks and AI test generation.&#10; - Updated docs/howtos/mcp.md to emphasize GraphQL's advantages for AI agents and Model Context Protocol.&#10; - Added readme.improvement.plan.md to track documentation evolution.&#10; - Enhanced all how-to guides with improved cross-references and up-to-date information.&#10;- Guidelines:&#10; - Updated .junie/guidelines.md with Node 24 requirements and enhanced commit message standards (Conventional Commits 1.0.0).&#10;- Infrastructure &amp; Code:&#10; - Updated docker-compose.yml with Apollo MCP server integration.&#10; - Refined configuration and schema handling in src/api/ and src/datasources/.&#10; - Synchronized generated TypeScript types with schema updates."> <list default="true" id="d7a71994-2699-4ae4-9fd2-ee13b7f33d35" name="Changes" comment="docs: refactor documentation and upgrade to Node.js 24&#10;&#10;This commit upgrades the project to Node.js 24 (LTS) and performs a major refactoring of the documentation to support both advanced users and AI-based automation (MCP).&#10;&#10;Changes:&#10;- Environment &amp; CI/CD:&#10; - Set Node.js version to &gt;=24 in package.json and .nvmrc.&#10; - Updated Dockerfile to use Node 24 base image.&#10; - Updated @types/node to ^24.10.9.&#10;- Documentation:&#10; - Refactored README.md with comprehensive technical reference, configuration details, and Zabbix-to-GraphQL mapping.&#10; - Created docs/howtos/cookbook.md with practical recipes for common tasks and AI test generation.&#10; - Updated docs/howtos/mcp.md to emphasize GraphQL's advantages for AI agents and Model Context Protocol.&#10; - Added readme.improvement.plan.md to track documentation evolution.&#10; - Enhanced all how-to guides with improved cross-references and up-to-date information.&#10;- Guidelines:&#10; - Updated .junie/guidelines.md with Node 24 requirements and enhanced commit message standards (Conventional Commits 1.0.0).&#10;- Infrastructure &amp; Code:&#10; - Updated docker-compose.yml with Apollo MCP server integration.&#10; - Refined configuration and schema handling in src/api/ and src/datasources/.&#10; - Synchronized generated TypeScript types with schema updates.">
<change afterPath="$PROJECT_DIR$/docs/queries/sample_distance_tracker_test_query.graphql" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/test/schema_dependent_queries.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/runConfigurations/index_ts.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/runConfigurations/index_ts.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docker-compose.yml" beforeDir="false" afterPath="$PROJECT_DIR$/docker-compose.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/cookbook.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/cookbook.md" afterDir="false" /> <change beforePath="$PROJECT_DIR$/docs/howtos/cookbook.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/cookbook.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/queries/sample_import_weather_sensor_template.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/docs/queries/sample_import_weather_sensor_template.graphql" afterDir="false" /> <change beforePath="$PROJECT_DIR$/docs/howtos/query_optimization.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/query_optimization.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/schema.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/schema.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/queries/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/queries/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/tests.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/tests.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/mcp/operations/runAllRegressionTests.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/mcp/operations/runAllRegressionTests.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/display_devices.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/display_devices.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/ground_value_checker.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/ground_value_checker.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/location_tracker_commons.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/location_tracker_commons.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/location_tracker_devices.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/location_tracker_devices.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/weather_sensor.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/weather_sensor.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/mutations.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/schema/mutations.graphql" afterDir="false" /> <change beforePath="$PROJECT_DIR$/schema/mutations.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/schema/mutations.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/api/resolvers.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/api/resolvers.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/graphql-params-to-zabbix-output.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/graphql-params-to-zabbix-output.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-hosts.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-hosts.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/datasources/zabbix-hosts.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-hosts.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-templates.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-templates.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/execution/host_importer.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/host_importer.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/execution/host_importer.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/host_importer.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/execution/regression_test_executor.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/regression_test_executor.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/execution/regression_test_executor.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/regression_test_executor.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/execution/template_importer.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/template_importer.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/execution/template_importer.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/template_importer.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/model/model_enum_values.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/model/model_enum_values.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/schema/generated/graphql.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/schema/generated/graphql.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/schema/generated/graphql.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/schema/generated/graphql.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/host_importer.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/host_importer.test.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/test/host_query.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/host_query.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/template_importer.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/template_importer.test.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/test/query_optimization.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/query_optimization.test.ts" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -27,13 +43,13 @@
<execution /> <execution />
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="149" /> <option name="cachedIndexableFilesCount" value="163" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY"> <option name="RECENT_BRANCH_BY_REPOSITORY">
<map> <map>
<entry key="$PROJECT_DIR$" value="license" /> <entry key="$PROJECT_DIR$" value="vlsv" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@ -95,7 +111,7 @@
"go.import.settings.migrated": "true", "go.import.settings.migrated": "true",
"javascript.preferred.runtime.type.id": "node", "javascript.preferred.runtime.type.id": "node",
"junie.onboarding.icon.badge.shown": "true", "junie.onboarding.icon.badge.shown": "true",
"last_opened_file_path": "//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/src", "last_opened_file_path": "//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/docs",
"node.js.detected.package.eslint": "true", "node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true", "node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)", "node.js.selected.package.eslint": "(autodetect)",
@ -107,8 +123,8 @@
"npm.copy-schema.executor": "Run", "npm.copy-schema.executor": "Run",
"npm.prod.executor": "Run", "npm.prod.executor": "Run",
"npm.test.executor": "Run", "npm.test.executor": "Run",
"settings.editor.selected.configurable": "ml.llm.mcp", "settings.editor.selected.configurable": "junie.mcp",
"settings.editor.splitter.proportion": "0.28812414", "settings.editor.splitter.proportion": "0.23751687",
"to.speed.mode.migration.done": "true", "to.speed.mode.migration.done": "true",
"ts.external.directory.path": "\\\\wsl.localhost\\Ubuntu\\home\\ahilbig\\git\\vcr\\zabbix-graphql-api\\node_modules\\typescript\\lib", "ts.external.directory.path": "\\\\wsl.localhost\\Ubuntu\\home\\ahilbig\\git\\vcr\\zabbix-graphql-api\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true" "vue.rearranger.settings.migration": "true"
@ -116,16 +132,16 @@
}]]></component> }]]></component>
<component name="RecapSpentCounter"> <component name="RecapSpentCounter">
<option name="endsOfQuotaMs" value="1772398800000" /> <option name="endsOfQuotaMs" value="1772398800000" />
<option name="spentUsd" value="0.01011225" /> <option name="spentUsd" value="0.0915201" />
</component> </component>
<component name="RecapUselessUpdatesCounter"> <component name="RecapUselessUpdatesCounter">
<option name="suspendCountdown" value="8" /> <option name="suspendCountdown" value="0" />
</component> </component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\docs" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\src" /> <recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\src" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\dist" /> <recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\dist" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\docs" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\src\testdata\templates" /> <recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\src\testdata\templates" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\src\test" /> <recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\src\test" />
</key> </key>
@ -153,7 +169,7 @@
<node-interpreter value="project" /> <node-interpreter value="project" />
<envs> <envs>
<env name="ADDITIONAL_RESOLVERS" value="SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice" /> <env name="ADDITIONAL_RESOLVERS" value="SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice" />
<env name="ADDITIONAL_SCHEMAS" value="./schema/extensions/display_devices.graphql,./schema/extensions/location_tracker_devices.graphql,./schema/extensions/location_tracker_commons.graphql" /> <env name="ADDITIONAL_SCHEMAS" value="./samples/extensions/display_devices.graphql,./samples/extensions/location_tracker_devices.graphql,./samples/extensions/location_tracker_commons.graphql" />
<env name="DEBUG" value="device-control-center-api:*" /> <env name="DEBUG" value="device-control-center-api:*" />
<env name="ZABBIX_PRIVILEGE_ESCALATION_TOKEN" value="$ZABBIX_AUTH_TOKEN_VCR_DEV$" /> <env name="ZABBIX_PRIVILEGE_ESCALATION_TOKEN" value="$ZABBIX_AUTH_TOKEN_VCR_DEV$" />
<env name="ZABBIX_BASE_URL" value="http://cockpit.vcr.develop.hilbigit.com/" /> <env name="ZABBIX_BASE_URL" value="http://cockpit.vcr.develop.hilbigit.com/" />
@ -185,11 +201,11 @@
</list> </list>
<recent_temporary> <recent_temporary>
<list> <list>
<item itemvalue="npm.prod" />
<item itemvalue="npm.copy-schema" />
<item itemvalue="npm.test" />
<item itemvalue="npm.test" /> <item itemvalue="npm.test" />
<item itemvalue="npm.prod" /> <item itemvalue="npm.prod" />
<item itemvalue="npm.copy-schema" />
<item itemvalue="npm.prod" />
<item itemvalue="npm.copy-schema" />
</list> </list>
</recent_temporary> </recent_temporary>
</component> </component>
@ -488,7 +504,7 @@
</line-breakpoint> </line-breakpoint>
<line-breakpoint enabled="true" type="javascript"> <line-breakpoint enabled="true" type="javascript">
<url>file://$PROJECT_DIR$/src/datasources/zabbix-request.ts</url> <url>file://$PROJECT_DIR$/src/datasources/zabbix-request.ts</url>
<line>213</line> <line>253</line>
<option name="timeStamp" value="6" /> <option name="timeStamp" value="6" />
</line-breakpoint> </line-breakpoint>
</breakpoints> </breakpoints>

View file

@ -18,7 +18,7 @@ The Zabbix GraphQL API acts as a wrapper and enhancer for the native Zabbix JSON
- *Reference*: `schema/mutations.graphql` (importHosts, importTemplates, importUserRights, etc.), `docs/sample_import_*.graphql` - *Reference*: `schema/mutations.graphql` (importHosts, importTemplates, importUserRights, etc.), `docs/sample_import_*.graphql`
- **Dynamic Schema Extension**: Extend the schema without code changes using environment variables - **Dynamic Schema Extension**: Extend the schema without code changes using environment variables
- *Reference*: `src/api/schema.ts`, `schema/extensions/`, `src/common_utils.ts` (ADDITIONAL_SCHEMAS, ADDITIONAL_RESOLVERS) - *Reference*: `src/api/schema.ts`, `samples/extensions/` (sample extensions), `src/common_utils.ts` (ADDITIONAL_SCHEMAS, ADDITIONAL_RESOLVERS)
- **Permission System**: Role-based access control using Zabbix template groups - **Permission System**: Role-based access control using Zabbix template groups
- *Reference*: `schema/api_commons.graphql` (Permission enum, PermissionRequest), `src/api/resolvers.ts` (hasPermissions, userPermissions), `docs/sample_import_permissions_template_groups_mutation.graphql` - *Reference*: `schema/api_commons.graphql` (Permission enum, PermissionRequest), `src/api/resolvers.ts` (hasPermissions, userPermissions), `docs/sample_import_permissions_template_groups_mutation.graphql`
@ -213,7 +213,7 @@ HOST_GROUP_FILTER_DEFAULT=Roadwork/Devices/*
HOST_TYPE_FILTER_DEFAULT=Roadwork/Devices HOST_TYPE_FILTER_DEFAULT=Roadwork/Devices
# Schema Extensions (No-Code) # Schema Extensions (No-Code)
ADDITIONAL_SCHEMAS=./schema/extensions/display_devices.graphql,./schema/extensions/location_tracker_devices.graphql,./schema/extensions/location_tracker_commons.graphql ADDITIONAL_SCHEMAS=./samples/extensions/display_devices.graphql,./samples/extensions/location_tracker_devices.graphql,./samples/extensions/location_tracker_commons.graphql
ADDITIONAL_RESOLVERS=SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice ADDITIONAL_RESOLVERS=SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice
# Logging # Logging

View file

@ -11,6 +11,8 @@ services:
environment: environment:
- SCHEMA_PATH=/usr/app/dist/schema/ - SCHEMA_PATH=/usr/app/dist/schema/
- ZABBIX_DEVELOPMENT_TOKEN=${ZABBIX_DEVELOPMENT_TOKEN} - ZABBIX_DEVELOPMENT_TOKEN=${ZABBIX_DEVELOPMENT_TOKEN}
volumes:
- ./samples:/usr/app/dist/samples
apollo-mcp-server: apollo-mcp-server:
image: ghcr.io/apollographql/apollo-mcp-server:latest image: ghcr.io/apollographql/apollo-mcp-server:latest

View file

@ -73,12 +73,14 @@ Compare the GraphQL response with the expected output described in the Zabbix do
This recipe shows how to add support for a new specialized device type without modifying the core API code. We will use the `DistanceTrackerDevice` as an example. This recipe shows how to add support for a new specialized device type without modifying the core API code. We will use the `DistanceTrackerDevice` as an example.
> **Important**: Schema extensions are not part of the core API source code. They are loaded dynamically at runtime via environment variables. The extensions provided in the `samples/extensions/` directory of this repository are **samples** to demonstrate how to use this mechanism. You can place your own extension files in any directory accessible by the API server.
### 📋 Prerequisites ### 📋 Prerequisites
- Zabbix Template Group `Templates/Roadwork/Devices` exists. - Zabbix Template Group `Templates/Roadwork/Devices` exists.
- Zabbix GraphQL API is running. - Zabbix GraphQL API is running.
### 🛠️ Step 1: Define the Schema Extension ### 🛠️ Step 1: Define the Schema Extension
Create a new `.graphql` file in `schema/extensions/` (e.g. `distance_tracker.graphql`). Create a new `.graphql` file in `samples/extensions/` (e.g. `distance_tracker.graphql`).
> **Advice**: A new device type must always implement both the `Host` and `Device` interfaces to ensure compatibility with the API's core logic and resolvers. > **Advice**: A new device type must always implement both the `Host` and `Device` interfaces to ensure compatibility with the API's core logic and resolvers.
@ -102,20 +104,20 @@ type DistanceTrackerState implements DeviceState {
} }
type DistanceTrackerValues { type DistanceTrackerValues {
timeFrom: Time timeFrom: String
timeUntil: Time timeUntil: String
count: Int count: Int
# The distances are modelled using a type which is already defined in location_tracker_commons.graphql # The distances are modelled using a type which is already defined in location_tracker_commons.graphql
distances: [SensorDistanceValue!] distances: [SensorDistanceValue!]
} }
``` ```
> **Reference**: This example is based on the already prepared sample: [location_tracker_devices.graphql](../../schema/extensions/location_tracker_devices.graphql). > **Reference**: This example is based on the already prepared sample: [location_tracker_devices.graphql](../../samples/extensions/location_tracker_devices.graphql).
### ⚙️ Step 2: Configure Environment Variables ### ⚙️ Step 2: Configure Environment Variables
Add the new schema and resolver to your `.env` file: Add the new schema and resolver to your `.env` file:
```env ```env
ADDITIONAL_SCHEMAS=./schema/extensions/distance_tracker.graphql,./schema/extensions/location_tracker_commons.graphql ADDITIONAL_SCHEMAS=./samples/extensions/distance_tracker.graphql,./samples/extensions/location_tracker_commons.graphql
ADDITIONAL_RESOLVERS=DistanceTrackerDevice ADDITIONAL_RESOLVERS=DistanceTrackerDevice
``` ```
Restart the API server. Restart the API server.
@ -203,7 +205,7 @@ This recipe demonstrates how to extend the schema with new device types that ret
- The device has geo-coordinates set via user macros (e.g. `{$LAT}` and `{$LON}`). - The device has geo-coordinates set via user macros (e.g. `{$LAT}` and `{$LON}`).
### 🛠️ Step 1: Define the Schema Extension ### 🛠️ Step 1: Define the Schema Extension
Create a new `.graphql` file in `schema/extensions/` (e.g. `weather_sensor.graphql` or `ground_value_checker.graphql`). Create a new `.graphql` file in `samples/extensions/` (e.g. `weather_sensor.graphql` or `ground_value_checker.graphql`).
**Sample: Weather Sensor** **Sample: Weather Sensor**
```graphql ```graphql
@ -262,7 +264,7 @@ type GroundValues {
### ⚙️ Step 2: Register the Resolver ### ⚙️ Step 2: Register the Resolver
Add the new types and schemas to your `.env` file to enable the dynamic resolver: Add the new types and schemas to your `.env` file to enable the dynamic resolver:
```env ```env
ADDITIONAL_SCHEMAS=./schema/extensions/weather_sensor.graphql,./schema/extensions/ground_value_checker.graphql ADDITIONAL_SCHEMAS=./samples/extensions/weather_sensor.graphql,./samples/extensions/ground_value_checker.graphql
ADDITIONAL_RESOLVERS=WeatherSensorDevice,GroundValueChecker ADDITIONAL_RESOLVERS=WeatherSensorDevice,GroundValueChecker
``` ```
Restart the API server to apply the changes. Restart the API server to apply the changes.
@ -344,6 +346,30 @@ Create a host, assign it macros for coordinates, and query its state.
--- ---
## 🍳 Recipe: Testing Specialized Device Types
This recipe shows how to execute a comprehensive query to verify the state and configuration of specialized device types, such as the `DistanceTrackerDevice`. This is useful for validating that your schema extensions and hierarchical mappings are working correctly.
### 📋 Prerequisites
- Zabbix GraphQL API is running.
- The schema has been extended with the `DistanceTrackerDevice` type (see [Recipe: Extending Schema with a New Device Type](#-recipe-extending-schema-with-a-new-device-type)). Sample extensions can be found in the `samples/extensions` directory.
- At least one host with `deviceType` set to `DistanceTrackerDevice` exists in Zabbix.
### 🛠️ Step 1: Get the Sample Query
1. **Open the Sample**: Open [docs/queries/sample_distance_tracker_test_query.graphql](../queries/sample_distance_tracker_test_query.graphql).
2. **Copy the Query**: Copy the GraphQL code block under the `### Query` header.
### 🚀 Step 2: Execution/Action
Execute the query against your GraphQL endpoint. This query retrieves information from `allHostGroups`, `allDevices`, and `allHosts`, using inline fragments to access fields specific to `DistanceTrackerDevice`.
### ✅ Step 3: Verification
Check the response for the following:
- **apiVersion** and **zabbixVersion** are returned.
- **allHostGroups** contains the expected groups.
- **allDevices** and **allHosts** include your `DistanceTrackerDevice` with its specialized `state` (count, timeFrom, timeUntil) and `tags` (deviceWidgetPreview).
---
## 🍳 Recipe: Provisioning a New Host ## 🍳 Recipe: Provisioning a New Host
### 📋 Prerequisites ### 📋 Prerequisites

View file

@ -22,7 +22,7 @@ The `GraphqlParamsToNeededZabbixOutput` class provides static methods to map Gra
### 3. Resolver Integration ### 3. Resolver Integration
Resolvers use the mapper to determine the required output and pass it to the datasource: Resolvers use the mapper to determine the required output and pass it to the datasource:
```typescript ```typescript
const output = GraphqlParamsToNeededZabbixOutput.mapAllHosts(args, info); const output = GraphqlParamsToNeededZabbixOutput.mapAllHosts(info);
return await new ZabbixQueryHostsRequestWithItemsAndInventory(...) return await new ZabbixQueryHostsRequestWithItemsAndInventory(...)
.executeRequestThrowError(dataSources.zabbixAPI, new ParsedArgs(args), output); .executeRequestThrowError(dataSources.zabbixAPI, new ParsedArgs(args), output);
``` ```
@ -30,6 +30,7 @@ return await new ZabbixQueryHostsRequestWithItemsAndInventory(...)
### 4. Indirect Dependencies ### 4. Indirect Dependencies
Some GraphQL fields are not directly returned by Zabbix but are computed from other data. The optimization logic ensures these dependencies are handled: Some GraphQL fields are not directly returned by Zabbix but are computed from other data. The optimization logic ensures these dependencies are handled:
- **`state`**: Requesting the `state` field on a `Device` requires Zabbix `items`. The mapper automatically adds `items` to the requested output if `state` is present. - **`state`**: Requesting the `state` field on a `Device` requires Zabbix `items`. The mapper automatically adds `items` to the requested output if `state` is present.
- **`deviceType`**: Requesting `deviceType` requires Zabbix `tags` (or `inheritedTags`). This is needed because the `deviceType` is resolved from a Zabbix tag and will be empty otherwise. The optimization logic ensures that `selectTags` and `selectInheritedTags` are not skipped when `deviceType` is requested.
## 🛠️ Configuration ## 🛠️ Configuration
Optimization rules are defined in the constructor of specialized `ZabbixRequest` classes. Optimization rules are defined in the constructor of specialized `ZabbixRequest` classes.
@ -37,7 +38,7 @@ Optimization rules are defined in the constructor of specialized `ZabbixRequest`
### 📋 Supported Optimizations ### 📋 Supported Optimizations
- **Hosts & Devices**: - **Hosts & Devices**:
- `selectParentTemplates` skipped if `parentTemplates` not requested. - `selectParentTemplates` skipped if `parentTemplates` not requested.
- `selectTags` and `selectInheritedTags` skipped if `tags` not requested. - `selectTags` and `selectInheritedTags` skipped if `tags` (or `deviceType`) not requested.
- `selectHostGroups` skipped if `hostgroups` not requested. - `selectHostGroups` skipped if `hostgroups` not requested.
- `selectItems` skipped if `items` (or `state`) not requested. - `selectItems` skipped if `items` (or `state`) not requested.
- `selectInventory` skipped if `inventory` not requested. - `selectInventory` skipped if `inventory` not requested.

View file

@ -10,7 +10,7 @@ The GraphQL schema is located in the `../../schema/` directory and consists of:
- `zabbix.graphql` - Zabbix-specific types (see detailed documentation in file comments) - `zabbix.graphql` - Zabbix-specific types (see detailed documentation in file comments)
- `device_value_commons.graphql` - Common value types (see detailed documentation in file comments) - `device_value_commons.graphql` - Common value types (see detailed documentation in file comments)
- `api_commons.graphql` - Common API types and permission system (see detailed documentation in file comments) - `api_commons.graphql` - Common API types and permission system (see detailed documentation in file comments)
- `extensions/` - Custom device type extensions - `samples/extensions/` - Sample device type extensions (not part of the core source)
For comprehensive understanding of each operation, read the detailed comments in the respective schema files. For comprehensive understanding of each operation, read the detailed comments in the respective schema files.
@ -38,7 +38,8 @@ The `Location` type represents geographical information from Zabbix host invento
Extend the schema without code changes using environment variables: Extend the schema without code changes using environment variables:
```bash ```bash
ADDITIONAL_SCHEMAS=./schema/extensions/display_devices.graphql,./schema/extensions/location_tracker_devices.graphql # Extensions can be located anywhere; samples are provided in samples/extensions/
ADDITIONAL_SCHEMAS=./samples/extensions/display_devices.graphql,./samples/extensions/location_tracker_devices.graphql
ADDITIONAL_RESOLVERS=SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice ADDITIONAL_RESOLVERS=SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice
``` ```

View file

@ -8,6 +8,7 @@ This directory contains practical examples of GraphQL operations for the Zabbix
- [Query All Hosts](./sample_all_hosts_query.graphql): Retrieve basic host information and inventory. - [Query All Hosts](./sample_all_hosts_query.graphql): Retrieve basic host information and inventory.
- [Import Hosts](./sample_import_hosts_mutation.graphql): Create or update multiple hosts with tags and group assignments. - [Import Hosts](./sample_import_hosts_mutation.graphql): Create or update multiple hosts with tags and group assignments.
- [Query All Devices](./sample_all_devices_query.graphql): Query specialized devices using the `allDevices` query. - [Query All Devices](./sample_all_devices_query.graphql): Query specialized devices using the `allDevices` query.
- [Distance Tracker Test Query](./sample_distance_tracker_test_query.graphql): Comprehensive query for testing specialized `DistanceTrackerDevice` types.
### 📄 Templates ### 📄 Templates
- [Query Templates](./sample_templates_query.graphql): List available templates and their items. - [Query Templates](./sample_templates_query.graphql): List available templates and their items.

View file

@ -0,0 +1,118 @@
### Query
This query demonstrates how to retrieve data from multiple sources, including specialized device types like `DistanceTrackerDevice`.
> **Precondition**: This query will only work if the GraphQL schema has been extended with the `DistanceTrackerDevice` type (see the sample in `samples/extensions/location_tracker_devices.graphql`).
```graphql
query DistanceTrackerDeviceTest {
apiVersion
zabbixVersion
allHostGroups(search_name: "Roadwork/Devices/*") {
groupid
name
}
allDevices {
deviceType
host
name
... on DistanceTrackerDevice {
state {
current {
count
timeFrom
timeUntil
}
}
}
}
allHosts {
hostid
host
name
deviceType
... on Device {
tags {
deviceWidgetPreview {
TOP_LEFT {
key
emptyValue
unit
value_font_size
g_value_transform
unit_font_size
g_unit_transform
}
TOP_RIGHT {
key
emptyValue
unit
value_font_size
g_value_transform
unit_font_size
g_unit_transform
}
BOTTOM_LEFT {
key
emptyValue
unit
value_font_size
g_value_transform
unit_font_size
g_unit_transform
}
BOTTOM_RIGHT {
key
emptyValue
unit
value_font_size
g_value_transform
unit_font_size
g_unit_transform
}
}
}
}
... on DistanceTrackerDevice {
state {
current {
count
timeFrom
timeUntil
}
}
}
... on ZabbixHost {
items {
itemid
name
key_
hostid
lastclock
lastvalue
value_type
attributeName
status
type
}
}
... on GenericDevice {
deviceType
state {
generic: current
}
}
... on SinglePanelDevice {
deviceType
state {
current {
values {
contentIndex
contentKey
contentText
}
}
}
}
}
}
```

View file

@ -71,6 +71,9 @@ This document outlines the test cases and coverage for the Zabbix GraphQL API.
- **TC-DOCS-01**: Validate all Zabbix documentation sample queries. - **TC-DOCS-01**: Validate all Zabbix documentation sample queries.
- **TC-MCP-01**: Validate all MCP operation files against the schema. - **TC-MCP-01**: Validate all MCP operation files against the schema.
### Schema-dependent Tests
- **TC-SCHEMA-01**: Verify comprehensive query for `DistanceTrackerDevice` works correctly when schema is extended.
### End-to-End (E2E) Tests ### End-to-End (E2E) Tests
- **TC-E2E-01**: Run a complete smoketest using MCP (creates template, group, and host, verifies, and cleans up). - **TC-E2E-01**: Run a complete smoketest using MCP (creates template, group, and host, verifies, and cleans up).
- **TC-E2E-02**: Run all regression tests to verify critical system behavior and prevent known issues. - **TC-E2E-02**: Run all regression tests to verify critical system behavior and prevent known issues.
@ -87,6 +90,7 @@ The `runAllRegressionTests` mutation (TC-E2E-02) executes the following checks:
- **Dependent Items**: Verifies that templates with master and dependent items can be imported successfully, correctly resolving the dependency within the same import operation. - **Dependent Items**: Verifies that templates with master and dependent items can be imported successfully, correctly resolving the dependency within the same import operation.
- **State sub-properties**: Verifies that requesting device state sub-properties correctly triggers the retrieval of required Zabbix items, even if `items` is not explicitly requested (verifying the indirect dependency logic). - **State sub-properties**: Verifies that requesting device state sub-properties correctly triggers the retrieval of required Zabbix items, even if `items` is not explicitly requested (verifying the indirect dependency logic).
- **Negative Optimization (allDevices)**: Verifies that items are NOT requested from Zabbix if neither `items` nor `state` (or state sub-properties) are requested within the `allDevices` query. - **Negative Optimization (allDevices)**: Verifies that items are NOT requested from Zabbix if neither `items` nor `state` (or state sub-properties) are requested within the `allDevices` query.
- **allDevices deviceType filter**: Verifies that the `allDevices` query only returns hosts that have a `deviceType` tag, and that the `deviceType` field is populated for all results.
## ✅ Test Coverage Checklist ## ✅ Test Coverage Checklist
@ -138,6 +142,7 @@ The `runAllRegressionTests` mutation (TC-E2E-02) executes the following checks:
| TC-CONF-07 | Parse Zabbix Args | Unit | Jest | [src/test/zabbix_api_args_parser.test.ts](../src/test/zabbix_api_args_parser.test.ts) | | TC-CONF-07 | Parse Zabbix Args | Unit | Jest | [src/test/zabbix_api_args_parser.test.ts](../src/test/zabbix_api_args_parser.test.ts) |
| TC-DOCS-01 | Zabbix Docs Samples Integration | Integration | Jest | [src/test/zabbix_docs_samples.test.ts](../src/test/zabbix_docs_samples.test.ts) | | TC-DOCS-01 | Zabbix Docs Samples Integration | Integration | Jest | [src/test/zabbix_docs_samples.test.ts](../src/test/zabbix_docs_samples.test.ts) |
| TC-MCP-01 | MCP Operations Validation | Integration | Jest | [src/test/mcp_operations_validation.test.ts](../src/test/mcp_operations_validation.test.ts) | | TC-MCP-01 | MCP Operations Validation | Integration | Jest | [src/test/mcp_operations_validation.test.ts](../src/test/mcp_operations_validation.test.ts) |
| TC-SCHEMA-01 | DistanceTrackerDevice Query | Integration | Jest | [src/test/schema_dependent_queries.test.ts](../src/test/schema_dependent_queries.test.ts) |
| TC-E2E-01 | Run complete smoketest | E2E | GraphQL / MCP | [mcp/operations/runSmoketest.graphql](../mcp/operations/runSmoketest.graphql) | | TC-E2E-01 | Run complete smoketest | E2E | GraphQL / MCP | [mcp/operations/runSmoketest.graphql](../mcp/operations/runSmoketest.graphql) |
| TC-E2E-02 | Run all regression tests | E2E | GraphQL / MCP | [mcp/operations/runAllRegressionTests.graphql](../mcp/operations/runAllRegressionTests.graphql) | | TC-E2E-02 | Run all regression tests | E2E | GraphQL / MCP | [mcp/operations/runAllRegressionTests.graphql](../mcp/operations/runAllRegressionTests.graphql) |

View file

@ -1,7 +1,6 @@
# Runs all regression tests. # Runs all regression tests.
# Variables: hostName, groupName mutation RunAllRegressionTests {
mutation RunAllRegressionTests($hostName: String!, $groupName: String!) { runAllRegressionTests {
runAllRegressionTests(hostName: $hostName, groupName: $groupName) {
success success
message message
steps { steps {

View file

@ -42,11 +42,11 @@ type DistanceTrackerValues {
""" """
Start of time interval for the delivered device counting value. Start of time interval for the delivered device counting value.
""" """
timeFrom: Time timeFrom: String
""" """
End of time interval for the delivered device counting value. End of time interval for the delivered device counting value.
""" """
timeUntil: Time timeUntil: String
""" """
Number of unique device keys detected between timeFrom and timeUntil. Number of unique device keys detected between timeFrom and timeUntil.

View file

@ -142,12 +142,7 @@ type Mutation {
""" """
Runs all regression tests. Runs all regression tests.
""" """
runAllRegressionTests( runAllRegressionTests: SmoketestResponse!
"""Technical name for the test host."""
hostName: String!,
"""Technical name for the test host group."""
groupName: String!
): SmoketestResponse!
} }
""" """

View file

@ -107,7 +107,7 @@ export function createResolvers(): Resolvers {
if (Config.HOST_TYPE_FILTER_DEFAULT) { if (Config.HOST_TYPE_FILTER_DEFAULT) {
args.tag_hostType ??= [Config.HOST_TYPE_FILTER_DEFAULT]; args.tag_hostType ??= [Config.HOST_TYPE_FILTER_DEFAULT];
} }
const output = GraphqlParamsToNeededZabbixOutput.mapAllHosts(args, info); const output = GraphqlParamsToNeededZabbixOutput.mapAllHosts(info);
return await new ZabbixQueryHostsRequestWithItemsAndInventory(zabbixAuthToken, cookie) return await new ZabbixQueryHostsRequestWithItemsAndInventory(zabbixAuthToken, cookie)
.executeRequestThrowError( .executeRequestThrowError(
dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(args), output dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(args), output
@ -120,7 +120,7 @@ export function createResolvers(): Resolvers {
if (Config.HOST_TYPE_FILTER_DEFAULT) { if (Config.HOST_TYPE_FILTER_DEFAULT) {
args.tag_hostType ??= [Config.HOST_TYPE_FILTER_DEFAULT]; args.tag_hostType ??= [Config.HOST_TYPE_FILTER_DEFAULT];
} }
const output = GraphqlParamsToNeededZabbixOutput.mapAllDevices(args, info); const output = GraphqlParamsToNeededZabbixOutput.mapAllDevices(info);
return await new ZabbixQueryDevices(zabbixAuthToken, cookie) return await new ZabbixQueryDevices(zabbixAuthToken, cookie)
.executeRequestThrowError( .executeRequestThrowError(
dataSources?.zabbixAPI || zabbixAPI, new ZabbixQueryDevicesArgs(args), output dataSources?.zabbixAPI || zabbixAPI, new ZabbixQueryDevicesArgs(args), output
@ -133,7 +133,7 @@ export function createResolvers(): Resolvers {
if (!args.search_name && Config.HOST_GROUP_FILTER_DEFAULT) { if (!args.search_name && Config.HOST_GROUP_FILTER_DEFAULT) {
args.search_name = Config.HOST_GROUP_FILTER_DEFAULT args.search_name = Config.HOST_GROUP_FILTER_DEFAULT
} }
const output = GraphqlParamsToNeededZabbixOutput.mapAllHostGroups(args, info); const output = GraphqlParamsToNeededZabbixOutput.mapAllHostGroups(info);
return await new ZabbixQueryHostgroupsRequest(zabbixAuthToken, cookie).executeRequestThrowError( return await new ZabbixQueryHostgroupsRequest(zabbixAuthToken, cookie).executeRequestThrowError(
dataSources?.zabbixAPI || zabbixAPI, new ZabbixQueryHostgroupsParams(args), output dataSources?.zabbixAPI || zabbixAPI, new ZabbixQueryHostgroupsParams(args), output
) )
@ -173,7 +173,7 @@ export function createResolvers(): Resolvers {
name: args.name_pattern name: args.name_pattern
} }
} }
const output = GraphqlParamsToNeededZabbixOutput.mapTemplates(args, info); const output = GraphqlParamsToNeededZabbixOutput.mapTemplates(info);
return await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie) return await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie)
.executeRequestThrowError(dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(params), output); .executeRequestThrowError(dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(params), output);
}, },
@ -287,11 +287,11 @@ export function createResolvers(): Resolvers {
}: any) => { }: any) => {
return SmoketestExecutor.runSmoketest(args.hostName, args.templateName, args.groupName, zabbixAuthToken, cookie) return SmoketestExecutor.runSmoketest(args.hostName, args.templateName, args.groupName, zabbixAuthToken, cookie)
}, },
runAllRegressionTests: async (_parent: any, args: any, { runAllRegressionTests: async (_parent: any, _args: any, {
zabbixAuthToken, zabbixAuthToken,
cookie cookie
}: any) => { }: any) => {
return RegressionTestExecutor.runAllRegressionTests(args.hostName, args.groupName, zabbixAuthToken, cookie) return RegressionTestExecutor.runAllRegressionTests(zabbixAuthToken, cookie)
} }
}, },

View file

@ -1,26 +1,20 @@
import {GraphQLResolveInfo} from "graphql"; import {GraphQLResolveInfo} from "graphql";
import {getRequestedFields} from "../api/graphql_utils.js"; import {getRequestedFields} from "../api/graphql_utils.js";
import {
QueryAllDevicesArgs,
QueryAllHostGroupsArgs,
QueryAllHostsArgs,
QueryTemplatesArgs
} from "../schema/generated/graphql.js";
export class GraphqlParamsToNeededZabbixOutput { export class GraphqlParamsToNeededZabbixOutput {
static mapAllHosts(args: QueryAllHostsArgs, info: GraphQLResolveInfo): string[] { static mapAllHosts(info: GraphQLResolveInfo): string[] {
return getRequestedFields(info); return getRequestedFields(info);
} }
static mapAllDevices(args: QueryAllDevicesArgs, info: GraphQLResolveInfo): string[] { static mapAllDevices(info: GraphQLResolveInfo): string[] {
return getRequestedFields(info); return getRequestedFields(info);
} }
static mapAllHostGroups(args: QueryAllHostGroupsArgs, info: GraphQLResolveInfo): string[] { static mapAllHostGroups(info: GraphQLResolveInfo): string[] {
return getRequestedFields(info); return getRequestedFields(info);
} }
static mapTemplates(args: QueryTemplatesArgs, info: GraphQLResolveInfo): string[] { static mapTemplates(info: GraphQLResolveInfo): string[] {
return getRequestedFields(info); return getRequestedFields(info);
} }
} }

View file

@ -21,6 +21,8 @@ export class ZabbixQueryHostsGenericRequest<T extends ZabbixResult, A extends Pa
this.skippableZabbixParams.set("selectTags", "tags"); this.skippableZabbixParams.set("selectTags", "tags");
this.skippableZabbixParams.set("selectInheritedTags", "tags"); this.skippableZabbixParams.set("selectInheritedTags", "tags");
this.skippableZabbixParams.set("selectHostGroups", "hostgroups"); this.skippableZabbixParams.set("selectHostGroups", "hostgroups");
this.impliedFields.set("deviceType", ["tags"]);
this.impliedFields.set("hostType", ["tags"]);
} }
createZabbixParams(args?: A, output?: string[]): ZabbixParams { createZabbixParams(args?: A, output?: string[]): ZabbixParams {

View file

@ -115,7 +115,7 @@ export class TemplateHelper {
// Use name_pattern which now searches both visibility name and technical name (host) // Use name_pattern which now searches both visibility name and technical name (host)
let templates = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixApi, new ParsedArgs({ let templates = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixApi, new ParsedArgs({
name_pattern: templateName name_pattern: templateName
})) }), ["templateid", "host"])
if (isZabbixErrorResult(templates) || !templates?.length) { if (isZabbixErrorResult(templates) || !templates?.length) {
logger.error(`Unable to find templateName=${templateName}`) logger.error(`Unable to find templateName=${templateName}`)

View file

@ -174,7 +174,7 @@ export class HostImporter {
{ {
tag_deviceType: deviceType tag_deviceType: deviceType
} }
)); ), ["templateid"]);
if (templates?.length) { if (templates?.length) {
result = Number(templates[0].templateid) result = Number(templates[0].templateid)

View file

@ -12,13 +12,16 @@ import {
ZabbixQueryHostsGenericRequestWithItems ZabbixQueryHostsGenericRequestWithItems
} from "../datasources/zabbix-hosts.js"; } from "../datasources/zabbix-hosts.js";
import {ZabbixQueryTemplatesRequest} from "../datasources/zabbix-templates.js"; import {ZabbixQueryTemplatesRequest} from "../datasources/zabbix-templates.js";
import {ParsedArgs} from "../datasources/zabbix-request.js"; import {isZabbixErrorResult, ParsedArgs, ZabbixRequest} from "../datasources/zabbix-request.js";
export class RegressionTestExecutor { export class RegressionTestExecutor {
public static async runAllRegressionTests(hostName: string, groupName: string, zabbixAuthToken?: string, cookie?: string): Promise<SmoketestResponse> { public static async runAllRegressionTests(zabbixAuthToken?: string, cookie?: string): Promise<SmoketestResponse> {
const steps: SmoketestStep[] = []; const steps: SmoketestStep[] = [];
let success = true; let success = true;
const hostName = "REG_HOST_" + Math.random().toString(36).substring(7);
const groupName = "REG_GROUP_" + Math.random().toString(36).substring(7);
try { try {
// Regression 1: Locations query argument order // Regression 1: Locations query argument order
// This verifies the fix where getLocations was called with (authToken, args) instead of (args, authToken) // This verifies the fix where getLocations was called with (authToken, args) instead of (args, authToken)
@ -309,8 +312,15 @@ export class RegressionTestExecutor {
optSuccess = optSuccess && hasSelectItems3 && hasOutput3; optSuccess = optSuccess && hasSelectItems3 && hasOutput3;
// 4. Test indirect dependencies: deviceType implies tags
const testParams4 = optRequest.createZabbixParams(new ParsedArgs({}), ["hostid", "deviceType"]);
const hasSelectTags4 = "selectTags" in testParams4;
const hasOutput4 = Array.isArray(testParams4.output) && testParams4.output.includes("hostid");
optSuccess = optSuccess && hasSelectTags4 && hasOutput4;
if (!optSuccess) { if (!optSuccess) {
logger.error(`REG-OPT: Optimization verification failed. hasSelectItems1: ${hasSelectItems1}, hasOutput1: ${hasOutput1}, hasSelectItems2: ${hasSelectItems2}, hasSelectTags2: ${hasSelectTags2}, hasSelectItems3: ${hasSelectItems3}, hasOutput3: ${hasOutput3}`); logger.error(`REG-OPT: Optimization verification failed. hasSelectItems1: ${hasSelectItems1}, hasOutput1: ${hasOutput1}, hasSelectItems2: ${hasSelectItems2}, hasSelectTags2: ${hasSelectTags2}, hasSelectItems3: ${hasSelectItems3}, hasOutput3: ${hasOutput3}, hasSelectTags4: ${hasSelectTags4}, hasOutput4: ${hasOutput4}`);
} }
} catch (error) { } catch (error) {
logger.error(`REG-OPT: Error during optimization test: ${error}`); logger.error(`REG-OPT: Error during optimization test: ${error}`);
@ -469,6 +479,57 @@ export class RegressionTestExecutor {
}); });
if (!optNegSuccess) success = false; if (!optNegSuccess) success = false;
// Regression 12: allDevices deviceType filter
// Verifies that allDevices only returns hosts with a deviceType tag
const devHostNameWithTag = "REG_DEV_WITH_TAG_" + Math.random().toString(36).substring(7);
const devHostNameWithoutTag = "REG_DEV_WITHOUT_TAG_" + Math.random().toString(36).substring(7);
// Get groupid for hostGroupName
const groupQuery: any = await new ZabbixRequest("hostgroup.get", zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ParsedArgs({ filter_name: hostGroupName }));
const regGroupId = Array.isArray(groupQuery) && groupQuery[0]?.groupid;
if (regGroupId) {
await HostImporter.importHosts([{
deviceKey: devHostNameWithTag,
deviceType: "RegressionDevice",
groupNames: [hostGroupName]
}], zabbixAuthToken, cookie);
await new ZabbixRequest("host.create", zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({
host: devHostNameWithoutTag,
name: devHostNameWithoutTag,
groups: [{ groupid: regGroupId }]
}));
const allDevicesResult: any = await new ZabbixQueryDevices(zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ZabbixQueryDevicesArgs({
filter_host: [devHostNameWithTag, devHostNameWithoutTag]
}), ["name", "host", "hostid", "deviceType"]);
if (isZabbixErrorResult(allDevicesResult)) {
steps.push({
name: "REG-DEV-FILTER: allDevices deviceType filter",
success: false,
message: `Zabbix error: ${allDevicesResult.error.message}`
});
} else {
const hasHostWithTag = allDevicesResult.some((d: any) => d.host === devHostNameWithTag);
const hasHostWithoutTag = allDevicesResult.some((d: any) => d.host === devHostNameWithoutTag);
const devTypeNotNull = allDevicesResult.length > 0 && allDevicesResult.every((d: any) => d.deviceType !== null && d.deviceType !== undefined && d.deviceType !== "");
const devFilterSuccess = hasHostWithTag && !hasHostWithoutTag && devTypeNotNull;
steps.push({
name: "REG-DEV-FILTER: allDevices deviceType filter",
success: devFilterSuccess,
message: devFilterSuccess
? `allDevices correctly filtered out hosts without deviceType tag`
: `Failed: withTag=${hasHostWithTag}, withoutTag=${hasHostWithoutTag}, typeNotNull=${devTypeNotNull}, result=${JSON.stringify(allDevicesResult)}`
});
if (!devFilterSuccess) success = false;
}
}
// Step 1: Create Host Group (Legacy test kept for compatibility) // Step 1: Create Host Group (Legacy test kept for compatibility)
const groupResult = await HostImporter.importHostGroups([{ const groupResult = await HostImporter.importHostGroups([{
groupName: groupName groupName: groupName
@ -486,6 +547,8 @@ export class RegressionTestExecutor {
await HostDeleter.deleteHosts(null, hostName, zabbixAuthToken, cookie); await HostDeleter.deleteHosts(null, hostName, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, macroHostName, zabbixAuthToken, cookie); await HostDeleter.deleteHosts(null, macroHostName, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, metaHostName, zabbixAuthToken, cookie); await HostDeleter.deleteHosts(null, metaHostName, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, devHostNameWithTag, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, devHostNameWithoutTag, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, regTemplateName, zabbixAuthToken, cookie); await TemplateDeleter.deleteTemplates(null, regTemplateName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, httpTempName, zabbixAuthToken, cookie); await TemplateDeleter.deleteTemplates(null, httpTempName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, macroTemplateName, zabbixAuthToken, cookie); await TemplateDeleter.deleteTemplates(null, macroTemplateName, zabbixAuthToken, cookie);

View file

@ -108,7 +108,7 @@ export class TemplateImporter {
let templateNames = template.templates.map(t => t.name) let templateNames = template.templates.map(t => t.name)
let queryResult = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({ let queryResult = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({
filter_host: templateNames filter_host: templateNames
})) }), ["templateid"])
if (isZabbixErrorResult(queryResult)) { if (isZabbixErrorResult(queryResult)) {
let errorMessage = queryResult.error.message; let errorMessage = queryResult.error.message;

View file

@ -642,12 +642,6 @@ export interface MutationImportUserRightsArgs {
} }
export interface MutationRunAllRegressionTestsArgs {
groupName: Scalars['String']['input'];
hostName: Scalars['String']['input'];
}
export interface MutationRunSmoketestArgs { export interface MutationRunSmoketestArgs {
groupName: Scalars['String']['input']; groupName: Scalars['String']['input'];
hostName: Scalars['String']['input']; hostName: Scalars['String']['input'];
@ -1597,7 +1591,7 @@ export type MutationResolvers<ContextType = any, ParentType extends ResolversPar
importTemplateGroups?: Resolver<Maybe<Array<ResolversTypes['CreateTemplateGroupResponse']>>, ParentType, ContextType, RequireFields<MutationImportTemplateGroupsArgs, 'templateGroups'>>; importTemplateGroups?: Resolver<Maybe<Array<ResolversTypes['CreateTemplateGroupResponse']>>, ParentType, ContextType, RequireFields<MutationImportTemplateGroupsArgs, 'templateGroups'>>;
importTemplates?: Resolver<Maybe<Array<ResolversTypes['ImportTemplateResponse']>>, ParentType, ContextType, RequireFields<MutationImportTemplatesArgs, 'templates'>>; importTemplates?: Resolver<Maybe<Array<ResolversTypes['ImportTemplateResponse']>>, ParentType, ContextType, RequireFields<MutationImportTemplatesArgs, 'templates'>>;
importUserRights?: Resolver<Maybe<ResolversTypes['ImportUserRightsResult']>, ParentType, ContextType, RequireFields<MutationImportUserRightsArgs, 'dryRun' | 'input'>>; importUserRights?: Resolver<Maybe<ResolversTypes['ImportUserRightsResult']>, ParentType, ContextType, RequireFields<MutationImportUserRightsArgs, 'dryRun' | 'input'>>;
runAllRegressionTests?: Resolver<ResolversTypes['SmoketestResponse'], ParentType, ContextType, RequireFields<MutationRunAllRegressionTestsArgs, 'groupName' | 'hostName'>>; runAllRegressionTests?: Resolver<ResolversTypes['SmoketestResponse'], ParentType, ContextType>;
runSmoketest?: Resolver<ResolversTypes['SmoketestResponse'], ParentType, ContextType, RequireFields<MutationRunSmoketestArgs, 'groupName' | 'hostName' | 'templateName'>>; runSmoketest?: Resolver<ResolversTypes['SmoketestResponse'], ParentType, ContextType, RequireFields<MutationRunSmoketestArgs, 'groupName' | 'hostName' | 'templateName'>>;
}; };

View file

@ -57,8 +57,8 @@ describe("Host and HostGroup Resolvers", () => {
})); }));
}); });
test("allDevices query", async () => { test("allDevices query - with hostid", async () => {
const mockDevices = [{ hostid: "2", host: "Device 1" }]; const mockDevices = [{ hostid: "2", host: "Device 1", deviceType: "GenericDevice" }];
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockDevices); (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockDevices);
const args: QueryAllDevicesArgs = { hostids: 2 }; const args: QueryAllDevicesArgs = { hostids: 2 };
@ -74,7 +74,63 @@ describe("Host and HostGroup Resolvers", () => {
body: expect.objectContaining({ body: expect.objectContaining({
method: "host.get", method: "host.get",
params: expect.objectContaining({ params: expect.objectContaining({
hostids: 2 hostids: 2,
tags: expect.arrayContaining([{
tag: "deviceType",
operator: 4
}])
})
})
}));
});
test("allDevices query - with deviceType filter", async () => {
const mockDevices = [{ hostid: "2", host: "Device 1", deviceType: "SomeType" }];
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockDevices);
const args: QueryAllDevicesArgs = { tag_deviceType: ["SomeType"] };
const context = {
zabbixAuthToken: "test-token",
dataSources: { zabbixAPI: zabbixAPI }
};
const result = await resolvers.Query.allDevices(null, args, context);
expect(result).toEqual(mockDevices);
expect(zabbixAPI.post).toHaveBeenCalledWith("host.get.with_items", expect.objectContaining({
body: expect.objectContaining({
params: expect.objectContaining({
tags: expect.arrayContaining([{
tag: "deviceType",
operator: 1,
value: "SomeType"
}])
})
})
}));
});
test("allDevices query - ensures deviceType exists if no filter provided", async () => {
const mockDevices = [{ hostid: "3", host: "Device with tag", deviceType: "SomeType" }];
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockDevices);
const args: QueryAllDevicesArgs = {};
const context = {
zabbixAuthToken: "test-token",
dataSources: { zabbixAPI: zabbixAPI }
};
const result = await resolvers.Query.allDevices(null, args, context);
expect(result).toEqual(mockDevices);
expect(zabbixAPI.post).toHaveBeenCalledWith("host.get.with_items", expect.objectContaining({
body: expect.objectContaining({
method: "host.get",
params: expect.objectContaining({
tags: expect.arrayContaining([{
tag: "deviceType",
operator: 4
}])
}) })
}) })
})); }));

View file

@ -163,6 +163,37 @@ describe("Query Optimization", () => {
expect(callParams.output).toContain("items"); expect(callParams.output).toContain("items");
}); });
test("allHosts optimization - keep selectTags when deviceType requested", async () => {
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce([]);
const args: QueryAllHostsArgs = {};
const context = {
zabbixAuthToken: "test-token",
dataSources: { zabbixAPI: zabbixAPI }
};
const info = {
fieldNodes: [{
selectionSet: {
selections: [
{ kind: 'Field', name: { value: 'hostid' } },
{ kind: 'Field', name: { value: 'deviceType' } }
]
}
}]
};
await resolvers.Query.allHosts(null, args, context, info);
expect(zabbixAPI.post).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
body: expect.objectContaining({
params: expect.objectContaining({
output: ["hostid"],
selectTags: expect.any(Array)
})
})
}));
});
test("allDevices optimization - skip items when not requested", async () => { test("allDevices optimization - skip items when not requested", async () => {
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce([]); (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([]);

View file

@ -0,0 +1,173 @@
import {ApolloServer} from '@apollo/server';
import {schema_loader} from '../api/schema.js';
import {readFileSync} from 'fs';
import {join} from 'path';
import {zabbixAPI} from '../datasources/zabbix-api.js';
import {Config} from "../common_utils.js";
describe("Schema-dependent Queries Integration Tests", () => {
let server: ApolloServer;
let postSpy: jest.SpyInstance;
let originalSchemas: any;
let originalResolvers: any;
let originalApiVersion: any;
beforeAll(async () => {
originalSchemas = Config.ADDITIONAL_SCHEMAS;
originalResolvers = Config.ADDITIONAL_RESOLVERS;
originalApiVersion = Config.API_VERSION;
// We need to bypass the static readonly nature of Config for this test.
Object.defineProperty(Config, 'ADDITIONAL_SCHEMAS', {
value: "./samples/extensions/location_tracker_devices.graphql,./samples/extensions/location_tracker_commons.graphql,./samples/extensions/display_devices.graphql",
configurable: true
});
Object.defineProperty(Config, 'ADDITIONAL_RESOLVERS', {
value: "DistanceTrackerDevice,SinglePanelDevice",
configurable: true
});
Object.defineProperty(Config, 'API_VERSION', {
value: "1.2.3",
configurable: true
});
const schema = await schema_loader();
server = new ApolloServer({
schema,
});
postSpy = jest.spyOn(zabbixAPI, 'post');
});
afterAll(() => {
postSpy.mockRestore();
Object.defineProperty(Config, 'ADDITIONAL_SCHEMAS', { value: originalSchemas });
Object.defineProperty(Config, 'ADDITIONAL_RESOLVERS', { value: originalResolvers });
Object.defineProperty(Config, 'API_VERSION', { value: originalApiVersion });
});
test("TC-SCHEMA-01: DistanceTrackerDevice Comprehensive Query", async () => {
const filePath = join(process.cwd(), 'docs', 'queries', 'sample_distance_tracker_test_query.graphql');
const content = readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n');
const queryMatch = content.match(/```graphql\n([\s\S]*?)\n```/);
if (!queryMatch) {
throw new Error(`No graphql block found in sample query file`);
}
const query = queryMatch[1];
// Setup mock responses for Zabbix API
postSpy.mockImplementation((method: string) => {
if (method === 'apiinfo.version') return Promise.resolve("7.4.0");
if (method.startsWith('hostgroup.get')) {
return Promise.resolve([
{ groupid: "1", name: "Roadwork/Devices/Tracker" }
]);
}
if (method.startsWith('host.get')) {
return Promise.resolve([
{
hostid: "10001",
host: "TRACKER_01",
name: "Distance Tracker 01",
deviceType: "DistanceTrackerDevice", // Manually mapped because we mock post()
tags: [
{ tag: "deviceType", value: "DistanceTrackerDevice" }
],
items: [
{ itemid: "1", name: "Count", key_: "state.current.count", lastvalue: "5", lastclock: 1704103200, value_type: "3" },
{ itemid: "2", name: "Time From", key_: "state.current.timeFrom", lastvalue: "2024-01-01T10:00:00Z", lastclock: 1704103200, value_type: "4" },
{ itemid: "3", name: "Time Until", key_: "state.current.timeUntil", lastvalue: "2024-01-01T11:00:00Z", lastclock: 1704103200, value_type: "4" }
],
inheritedTags: []
},
{
hostid: "10003",
host: "TRACKER_02",
name: "Distance Tracker 02",
deviceType: "DistanceTrackerDevice",
tags: [
{ tag: "deviceType", value: "DistanceTrackerDevice" }
],
items: [
{ itemid: "10", name: "Count", key_: "state.current.count", lastvalue: "10", lastclock: 1704103200, value_type: "3" },
{ itemid: "11", name: "Time From", key_: "state.current.timeFrom", lastvalue: "09:58:09", lastclock: 1704103200, value_type: "4" }
],
inheritedTags: []
},
{
hostid: "10004",
host: "TRACKER_03",
name: "Distance Tracker 03",
deviceType: "DistanceTrackerDevice",
tags: [
{ tag: "deviceType", value: "DistanceTrackerDevice" }
],
items: [
{ itemid: "20", name: "Count", key_: "state.current.count", lastvalue: "0", lastclock: 1704103200, value_type: "3" },
{ itemid: "21", name: "Time From", key_: "state.current.timeFrom", lastvalue: "", lastclock: 1704103200, value_type: "4" }
],
inheritedTags: []
},
{
hostid: "10002",
host: "DISPLAY_01",
name: "LED Display 01",
deviceType: "SinglePanelDevice", // Manually mapped because we mock post()
tags: [
{ tag: "deviceType", value: "SinglePanelDevice" }
],
items: [
{ itemid: "4", name: "Content", key_: "state.current.values.1.contentText", lastvalue: "Roadwork Ahead", lastclock: 1704103200, value_type: "4" }
],
inheritedTags: []
}
]);
}
return Promise.resolve([]);
});
const response = await server.executeOperation({
query: query,
}, {
contextValue: { zabbixAuthToken: 'test-token', dataSources: { zabbixAPI: zabbixAPI } }
});
if (response.body.kind === 'single') {
const result = response.body.singleResult;
if (result.errors) {
console.error(`Errors in query:`, JSON.stringify(result.errors, null, 2));
}
expect(result.errors).toBeUndefined();
const data = result.data as any;
expect(data.apiVersion).toBe("1.2.3");
expect(data.zabbixVersion).toBe("7.4.0");
expect(data.allHostGroups).toHaveLength(1);
expect(data.allDevices).toBeDefined();
// Verify DistanceTrackerDevice resolution
const tracker = data.allDevices.find((d: any) => d.host === "TRACKER_01");
expect(tracker.deviceType).toBe("DistanceTrackerDevice");
expect(tracker.state.current.count).toBe(5);
expect(tracker.state.current.timeFrom).toBe("2024-01-01T10:00:00Z");
const tracker02 = data.allDevices.find((d: any) => d.host === "TRACKER_02");
expect(tracker02.state.current.count).toBe(10);
expect(tracker02.state.current.timeFrom).toBe("09:58:09");
const tracker03 = data.allDevices.find((d: any) => d.host === "TRACKER_03");
expect(tracker03.state.current.timeFrom).toBe("");
// Verify allHosts with fragments
const trackerInHosts = data.allHosts.find((h: any) => h.host === "TRACKER_01");
expect(trackerInHosts.state.current.count).toBe(5);
const displayInHosts = data.allHosts.find((h: any) => h.host === "DISPLAY_01");
expect(displayInHosts.deviceType).toBe("SinglePanelDevice");
} else {
throw new Error(`Unexpected response kind: ${response.body.kind}`);
}
});
});