From a3ed4886a3b07e293429c2e0a8acad06d084b811 Mon Sep 17 00:00:00 2001 From: Andreas Hilbig Date: Sat, 24 Jan 2026 15:42:13 +0100 Subject: [PATCH] feat: add template and template group management via GraphQL - Implemented GraphQL endpoints for importing, querying, and deleting Zabbix templates and template groups. - Added support for full template data import, including items, preprocessing steps, tags, and linked templates. - Implemented dependent item support by deferred creation logic in the template importer. - Added ability to query templates and template groups with name pattern filtering (supporting Zabbix wildcards). - Implemented batch deletion for templates and template groups by ID or name pattern. - Improved error reporting by including detailed Zabbix API error data in GraphQL responses. - Added comprehensive unit and integration tests covering all new functionality. - Provided GraphQL sample queries and mutations in the 'docs' directory for all new endpoints. --- .idea/runConfigurations/index_ts.xml | 2 +- .idea/workspace.xml | 47 +- docs/sample_all_template_groups_query.graphql | 18 + ...le_delete_template_groups_mutation.graphql | 30 + docs/sample_delete_templates_mutation.graphql | 30 + ...le_import_template_groups_mutation.graphql | 59 ++ docs/sample_import_templates_mutation.graphql | 97 +++ docs/sample_templates_query.graphql | 18 + package.json | 2 +- schema/mutations.graphql | 140 ++++ schema/queries.graphql | 10 + src/api/resolvers.ts | 70 ++ src/datasources/zabbix-request.ts | 14 +- src/datasources/zabbix-templates.ts | 41 +- src/execution/template_deleter.ts | 107 +++ src/execution/template_importer.ts | 220 ++++++ src/schema/generated/graphql.ts | 216 ++++++ src/test/template_deleter.test.ts | 162 +++++ src/test/template_importer.test.ts | 176 +++++ src/test/template_integration.test.ts | 229 ++++++ src/test/template_query.test.ts | 112 +++ .../templates/zbx_default_templates_vcr.yaml | 670 ++++++++++++++++++ 22 files changed, 2450 insertions(+), 20 deletions(-) create mode 100644 docs/sample_all_template_groups_query.graphql create mode 100644 docs/sample_delete_template_groups_mutation.graphql create mode 100644 docs/sample_delete_templates_mutation.graphql create mode 100644 docs/sample_import_template_groups_mutation.graphql create mode 100644 docs/sample_import_templates_mutation.graphql create mode 100644 docs/sample_templates_query.graphql create mode 100644 src/execution/template_deleter.ts create mode 100644 src/execution/template_importer.ts create mode 100644 src/test/template_deleter.test.ts create mode 100644 src/test/template_importer.test.ts create mode 100644 src/test/template_integration.test.ts create mode 100644 src/test/template_query.test.ts create mode 100644 src/testdata/templates/zbx_default_templates_vcr.yaml diff --git a/.idea/runConfigurations/index_ts.xml b/.idea/runConfigurations/index_ts.xml index 052e544..1c32029 100644 --- a/.idea/runConfigurations/index_ts.xml +++ b/.idea/runConfigurations/index_ts.xml @@ -1,5 +1,5 @@ - + diff --git a/.idea/workspace.xml b/.idea/workspace.xml index c60a6b6..9d62e65 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,13 +4,16 @@ - + + + - + + - + - @@ -52,7 +55,7 @@ "NIXITCH_NIX_PROFILES": "", "NIXITCH_NIX_REMOTE": "", "NIXITCH_NIX_USER_PROFILE_DIR": "", - "Node.js.index.ts.executor": "Debug", + "Node.js.index.ts.executor": "Run", "RunOnceActivity.MCP Project settings loaded": "true", "RunOnceActivity.ShowReadmeOnStart": "true", "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", @@ -63,7 +66,7 @@ "go.import.settings.migrated": "true", "javascript.preferred.runtime.type.id": "node", "junie.onboarding.icon.badge.shown": "true", - "last_opened_file_path": "//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/src/test", + "last_opened_file_path": "//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/src/testdata/templates", "node.js.detected.package.eslint": "true", "node.js.detected.package.tslint": "true", "node.js.selected.package.eslint": "(autodetect)", @@ -74,7 +77,7 @@ "npm.compile.executor": "Run", "npm.copy-schema.executor": "Run", "npm.prod.executor": "Run", - "settings.editor.selected.configurable": "preferences.sourceCode.TypeScript", + "settings.editor.selected.configurable": "settings.javascript.runtime", "to.speed.mode.migration.done": "true", "ts.external.directory.path": "\\\\wsl.localhost\\Ubuntu\\home\\ahilbig\\git\\vcr\\zabbix-graphql-api\\node_modules\\typescript\\lib", "vue.rearranger.settings.migration": "true" @@ -89,6 +92,7 @@ + @@ -160,7 +164,9 @@ - + + + - @@ -308,16 +322,23 @@ - - file://$PROJECT_DIR$/src/datasources/zabbix-hosts.ts - 149 + file://$PROJECT_DIR$/src/datasources/zabbix-request.ts + 133 - + + file://$PROJECT_DIR$/src/datasources/zabbix-request.ts + 213 + + diff --git a/docs/sample_all_template_groups_query.graphql b/docs/sample_all_template_groups_query.graphql new file mode 100644 index 0000000..5a608bc --- /dev/null +++ b/docs/sample_all_template_groups_query.graphql @@ -0,0 +1,18 @@ +### Query +Use this query to list all template groups. + +```graphql +query AllTemplateGroups($name_pattern: String) { + allTemplateGroups(name_pattern: $name_pattern) { + groupid + name + } +} +``` + +### Variables +```json +{ + "name_pattern": "Templates/Roadwork/*" +} +``` diff --git a/docs/sample_delete_template_groups_mutation.graphql b/docs/sample_delete_template_groups_mutation.graphql new file mode 100644 index 0000000..a5da82d --- /dev/null +++ b/docs/sample_delete_template_groups_mutation.graphql @@ -0,0 +1,30 @@ +### Mutation +Use this mutation to delete template groups by their numeric IDs or by a name pattern. + +```graphql +mutation DeleteTemplateGroups($groupids: [Int!], $name_pattern: String) { + deleteTemplateGroups(groupids: $groupids, name_pattern: $name_pattern) { + id + message + error { + message + code + data + } + } +} +``` + +### Variables (by ID) +```json +{ + "groupids": [201] +} +``` + +### Variables (by name pattern) +```json +{ + "name_pattern": "Templates/Roadwork/%" +} +``` diff --git a/docs/sample_delete_templates_mutation.graphql b/docs/sample_delete_templates_mutation.graphql new file mode 100644 index 0000000..37b3881 --- /dev/null +++ b/docs/sample_delete_templates_mutation.graphql @@ -0,0 +1,30 @@ +### Mutation +Use this mutation to delete templates by their numeric IDs or by a name pattern. + +```graphql +mutation DeleteTemplates($templateids: [Int!], $name_pattern: String) { + deleteTemplates(templateids: $templateids, name_pattern: $name_pattern) { + id + message + error { + message + code + data + } + } +} +``` + +### Variables (by ID) +```json +{ + "templateids": [501] +} +``` + +### Variables (by name pattern) +```json +{ + "name_pattern": "BT_DEVICE_TRACKER%" +} +``` diff --git a/docs/sample_import_template_groups_mutation.graphql b/docs/sample_import_template_groups_mutation.graphql new file mode 100644 index 0000000..e9256fd --- /dev/null +++ b/docs/sample_import_template_groups_mutation.graphql @@ -0,0 +1,59 @@ +### Mutation +Use this mutation to import template groups. + +```graphql +mutation ImportTemplateGroups($templateGroups: [CreateTemplateGroup!]!) { + importTemplateGroups(templateGroups: $templateGroups) { + groupName + groupid + message + error { + message + code + data + } + } +} +``` + +### Variables +This sample data is based on the `template_groups` from `src/testdata/templates/zbx_default_templates_vcr.yaml`. + +```json +{ + "templateGroups": [ + { + "uuid": "43aab460fe444f18886b19948413b7e3", + "groupName": "Permissions/ConstructionSite" + }, + { + "uuid": "376524057e094c07aaa0cf7f524849dc", + "groupName": "Templates/Roadwork/Controller" + }, + { + "uuid": "7d83c76454564390bb0e34600780eaec", + "groupName": "Templates/Roadwork/Device-Capabilities" + }, + { + "uuid": "48d5d2a18a08448c96a931b63bb2c97d", + "groupName": "Templates/Roadwork/Device-Capabilities/FLASH_ATTACHABLE" + }, + { + "uuid": "785986b84892468ea2e92d912747b1d3", + "groupName": "Templates/Roadwork/Device-Capabilities/GEOLOCALIZABLE" + }, + { + "uuid": "a4b79479e97a4b48972dcb476d45e55a", + "groupName": "Templates/Roadwork/Device-Capabilities/HAS_OPERATIONAL_DATA" + }, + { + "uuid": "3604af8102644bee9dcaf0f9c1ee93a1", + "groupName": "Templates/Roadwork/Devices" + }, + { + "uuid": "5ad0bd9e42a4487e869e9e41b38fe553", + "groupName": "Templates/Roadwork/DisplayLibrary" + } + ] +} +``` diff --git a/docs/sample_import_templates_mutation.graphql b/docs/sample_import_templates_mutation.graphql new file mode 100644 index 0000000..4e41fec --- /dev/null +++ b/docs/sample_import_templates_mutation.graphql @@ -0,0 +1,97 @@ +### Mutation +Use this mutation to import templates along with their items, tags, and linked templates. + +```graphql +mutation ImportTemplates($templates: [CreateTemplate!]!) { + importTemplates(templates: $templates) { + host + templateid + message + error { + message + code + data + } + } +} +``` + +### Variables +This sample data is based on the `BT_DEVICE_TRACKER` template from `src/testdata/templates/zbx_default_templates_vcr.yaml`. + +```json +{ + "templates": [ + { + "uuid": "27474f627cb344b782a81c16d7e0c7d1", + "host": "BT_DEVICE_TRACKER", + "name": "BT_DEVICE_TRACKER", + "groupNames": ["Templates/Roadwork/Devices"], + "templates": [ + { "name": "ROADWORK_DEVICE" } + ], + "tags": [ + { "tag": "class", "value": "roadwork" }, + { "tag": "deviceType", "value": "bt_device_tracker_generic" } + ], + "items": [ + { + "uuid": "d4d3ec9f3ca940a39a721b6cfd2f3471", + "name": "location", + "type": 18, + "key": "location", + "value_type": 4, + "history": "2d", + "preprocessing": [ + { + "type": 21, + "params": [ + "var obj=JSON.parse(value);\n\nif (obj[\"isFiltered\"]) {\n throw \"Result is filtered\";\n return \"filtered\";\n}\n\nreturn value;" + ] + }, + { + "type": 15, + "params": ["filtered"], + "error_handler": 1 + } + ], + "master_item": { + "key": "mqtt.trap[deviceValue/location]" + } + }, + { + "uuid": "380c4a7d752848cba3b5a59a0f9b13c0", + "name": "MQTT_LOCATION", + "type": 2, + "key": "mqtt.trap[deviceValue/location]", + "value_type": 4, + "history": "0" + } + ] + } + ] +} +``` + +### Mapping Reference +When converting from Zabbix YAML/XML exports, use the following numeric mappings for items and preprocessing: + +#### Item Type (`type`) +- `2`: ZABBIX_TRAP (TRAP) +- `18`: DEPENDANT_ITEM (DEPENDENT) +- `21`: SIMULATOR_JAVASCRIPT (JAVASCRIPT) + +#### Value Type (`value_type`) +- `0`: Float +- `3`: Int (Numeric unsigned) +- `4`: Text + +#### Preprocessing Type (`type`) +- `12`: JSONPATH +- `15`: NOT_MATCHES_REGEX +- `21`: JAVASCRIPT + +#### Error Handler (`error_handler`) +- `1`: DISCARD_VALUE +- `2`: SET_VALUE +- `3`: SET_ERROR diff --git a/docs/sample_templates_query.graphql b/docs/sample_templates_query.graphql new file mode 100644 index 0000000..6583ce6 --- /dev/null +++ b/docs/sample_templates_query.graphql @@ -0,0 +1,18 @@ +### Query +Use this query to verify the results of the template import. + +```graphql +query GetTemplates($name_pattern: String) { + templates(name_pattern: $name_pattern) { + templateid + name + } +} +``` + +### Variables +```json +{ + "name_pattern": "BT_DEVICE_TRACKER" +} +``` diff --git a/package.json b/package.json index 8cab6b9..a59a819 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "compile": "tsc", - "start": "nodemon --watch \"src/**\" --watch \"schema.graphql\" --ext \"ts,json\" --exec \"tsc & node --require ts-node/register --inspect --import tsx/esm ./src/index.ts\"", + "start": "nodemon --watch \"src/**\" --watch \"schema.graphql\" --ext \"ts,json\" --exec \"node --import tsx ./src/index.ts\"", "prod": "npm run copy-schema && node ./dist/index.js", "test": "jest --detectOpenHandles --forceExit --bail", "codegen": "graphql-codegen --config codegen.ts --watch \"schema.graphql\"", diff --git a/schema/mutations.graphql b/schema/mutations.graphql index f2c6e2b..376709c 100644 --- a/schema/mutations.graphql +++ b/schema/mutations.graphql @@ -29,12 +29,152 @@ type Mutation { importHosts(hosts: [CreateHost!]!):[ImportHostResponse!] importUserRights(input: UserRightsInput!, dryRun: Boolean! = true): ImportUserRightsResult + + """ + (Mass) Import template groups + and assign them by groupid or name. + + Return value: If no error occurs a groupid be returned for each created group, + otherwise the return object will contain an error message + + Authentication: By zbx_session - cookie or zabbix-auth-token - header + """ + importTemplateGroups(templateGroups: [CreateTemplateGroup!]!):[CreateTemplateGroupResponse!] + + """ + (Mass) Import templates. + + Return value: If no error occurs a templateid will be returned for each created template, + otherwise the return object will contain an error message. + + Authentication: By zbx_session - cookie or zabbix-auth-token - header + """ + importTemplates(templates: [CreateTemplate!]!):[ImportTemplateResponse!] + + """ + Delete templates. + + Authentication: By zbx_session - cookie or zabbix-auth-token - header + """ + deleteTemplates(templateids: [Int!], name_pattern: String): [DeleteResponse!] + + """ + Delete template groups. + + Authentication: By zbx_session - cookie or zabbix-auth-token - header + """ + deleteTemplateGroups(groupids: [Int!], name_pattern: String): [DeleteResponse!] } #################################################################### # Input types used for importXXX - and storeXXX - Mutations #################################################################### +type DeleteResponse { + id: Int! + message: String + error: ApiError +} + +input CreateTemplateGroup { + """ + Name of the template group + """ + groupName: String! + """ + Internally used unique id + (will be assigned by Zabbix if empty) + """ + uuid: String +} + +input CreateTemplate { + """ + Name of the template + """ + host: String! + """ + Visible name of the template + """ + name: String + """ + groupNames is used to assign the created object + to a template group. + """ + groupNames: [String!]! + """ + Optionally the internal groupids can be passed - in this case the + groupName is ignored + """ + groupids: [Int] + """ + Internally used unique id + (will be assigned by Zabbix if empty) + """ + uuid: String + """ + Template items + """ + items: [CreateTemplateItem!] + """ + Linked templates + """ + templates: [CreateLinkedTemplate!] + """ + Template tags + """ + tags: [CreateTag!] +} + +input CreateTemplateItem { + uuid: String + name: String! + type: Int + key: String! + value_type: Int + history: String + units: String + delay: String + description: String + preprocessing: [CreateItemPreprocessing!] + tags: [CreateTag!] + master_item: CreateMasterItem +} + +input CreateMasterItem { + key: String! +} + +input CreateItemPreprocessing { + type: Int! + params: [String!]! + error_handler: Int + error_handler_params: String +} + +input CreateLinkedTemplate { + name: String! +} + +input CreateTag { + tag: String! + value: String +} + +type ImportTemplateResponse { + host: String! + templateid: String + message: String + error: ApiError +} + +type CreateTemplateGroupResponse { + groupName: String! + groupid: Int + message: String + error: ApiError +} + input CreateHostGroup { """ Name of the host group diff --git a/schema/queries.graphql b/schema/queries.graphql index acd128c..781c6e2 100644 --- a/schema/queries.graphql +++ b/schema/queries.graphql @@ -113,5 +113,15 @@ type Query { exclude_groups_pattern: Regex allowing to exclude all matching hostgroups from group permissions """ exportUserRights(name_pattern: String = "" exclude_hostgroups_pattern: String = ""): UserRights + + """ + Get templates. + """ + templates(hostids: [Int], name_pattern: String): [Template] + + """ + Get template groups. + """ + allTemplateGroups(name_pattern: String): [HostGroup] } diff --git a/src/api/resolvers.ts b/src/api/resolvers.ts index 62225dc..da934a8 100644 --- a/src/api/resolvers.ts +++ b/src/api/resolvers.ts @@ -6,6 +6,10 @@ import { MutationCreateHostArgs, MutationImportHostGroupsArgs, MutationImportHostsArgs, + MutationImportTemplateGroupsArgs, + MutationImportTemplatesArgs, + MutationDeleteTemplatesArgs, + MutationDeleteTemplateGroupsArgs, MutationImportUserRightsArgs, Permission, QueryAllDevicesArgs, QueryAllHostGroupsArgs, @@ -13,12 +17,15 @@ import { QueryExportHostValueHistoryArgs, QueryExportUserRightsArgs, QueryHasPermissionsArgs, + QueryTemplatesArgs, QueryUserPermissionsArgs, Resolvers, StorageItemType, } from "../schema/generated/graphql.js"; import {HostImporter} from "../execution/host_importer.js"; +import {TemplateImporter} from "../execution/template_importer.js"; +import {TemplateDeleter} from "../execution/template_deleter.js"; import {HostValueExporter} from "../execution/host_exporter.js"; import {logger} from "../logging/logger.js"; import {ParsedArgs, ZabbixRequest} from "../datasources/zabbix-request.js"; @@ -39,6 +46,14 @@ import { ZabbixImportUserRolesRequest, ZabbixQueryUserRolesRequest } from "../datasources/zabbix-userroles.js"; +import { + ZabbixCreateItemRequest, + ZabbixCreateTemplateGroupRequest, + ZabbixCreateTemplateRequest, + ZabbixQueryItemRequest, + ZabbixQueryTemplateGroupRequest, + ZabbixQueryTemplatesRequest +} from "../datasources/zabbix-templates.js"; import {ZABBIX_EDGE_DEVICE_BASE_GROUP, zabbixAPI} from "../datasources/zabbix-api.js"; import {GraphQLInterfaceType, GraphQLList} from "graphql/type/index.js"; import {isDevice} from "./resolver_helpers.js"; @@ -129,6 +144,37 @@ export function createResolvers(): Resolvers { userGroups: groups, userRoles: roles } + }, + + templates: async (_parent: any, args: QueryTemplatesArgs, { + zabbixAuthToken, + cookie + }: any) => { + let params: any = {} + if (args.hostids) { + params.templateids = args.hostids + } + if (args.name_pattern) { + params.search = { + name: args.name_pattern + } + } + return await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie) + .executeRequestThrowError(zabbixAPI, new ParsedArgs(params)); + }, + + allTemplateGroups: async (_parent: any, args: any, { + zabbixAuthToken, + cookie + }: any) => { + let params: any = {} + if (args.name_pattern) { + params.search = { + name: args.name_pattern + } + } + return await new ZabbixQueryTemplateGroupRequest(zabbixAuthToken, cookie) + .executeRequestThrowError(zabbixAPI, new ParsedArgs(params)); } }, Mutation: { @@ -172,6 +218,30 @@ export function createResolvers(): Resolvers { userRoles: userRolesImport, userGroups: userGroupsImport } + }, + importTemplateGroups: async (_parent: any, args: MutationImportTemplateGroupsArgs, { + zabbixAuthToken, + cookie + }: any) => { + return TemplateImporter.importTemplateGroups(args.templateGroups, zabbixAuthToken, cookie) + }, + importTemplates: async (_parent: any, args: MutationImportTemplatesArgs, { + zabbixAuthToken, + cookie + }: any) => { + return TemplateImporter.importTemplates(args.templates, zabbixAuthToken, cookie) + }, + deleteTemplates: async (_parent: any, args: MutationDeleteTemplatesArgs, { + zabbixAuthToken, + cookie + }: any) => { + return TemplateDeleter.deleteTemplates(args.templateids, args.name_pattern, zabbixAuthToken, cookie) + }, + deleteTemplateGroups: async (_parent: any, args: MutationDeleteTemplateGroupsArgs, { + zabbixAuthToken, + cookie + }: any) => { + return TemplateDeleter.deleteTemplateGroups(args.groupids, args.name_pattern, zabbixAuthToken, cookie) } }, diff --git a/src/datasources/zabbix-request.ts b/src/datasources/zabbix-request.ts index ff77fc2..5fa8f93 100644 --- a/src/datasources/zabbix-request.ts +++ b/src/datasources/zabbix-request.ts @@ -39,7 +39,7 @@ export class ParsedArgs { constructor(params?: any) { if (Array.isArray(params)) { this.zabbix_params = params.map(arg => this.parseArgObject(arg)) - } else if (params instanceof Object) { + } else { this.zabbix_params = this.parseArgObject(params) } } @@ -52,9 +52,12 @@ export class ParsedArgs { return paramName in this.zabbix_params ? this.zabbix_params[paramName] : undefined } - parseArgObject(args?: Object) { + parseArgObject(args?: any) { + if (args && (typeof args !== 'object' || args.constructor !== Object)) { + return args; + } let result: ZabbixParams - if (args) { + if (args && typeof args === 'object' && args.constructor === Object) { if ("name_pattern" in args && typeof args["name_pattern"] == "string") { if (args["name_pattern"]) { this.name_pattern = args["name_pattern"] @@ -159,7 +162,10 @@ export class ZabbixRequest { - return {...this.requestBodyTemplate.params, ...paramsObj} + if (paramsObj !== null && typeof paramsObj === 'object' && paramsObj.constructor === Object) { + return {...this.requestBodyTemplate.params, ...paramsObj} + } + return paramsObj; }) } else { params = {...this.requestBodyTemplate.params, ...zabbixParams ?? this.createZabbixParams(args)} diff --git a/src/datasources/zabbix-templates.ts b/src/datasources/zabbix-templates.ts index d8e7ae4..9352010 100644 --- a/src/datasources/zabbix-templates.ts +++ b/src/datasources/zabbix-templates.ts @@ -1,5 +1,7 @@ -import { ZabbixRequest } from "./zabbix-request.js"; +import {isZabbixErrorResult, ParsedArgs, ZabbixRequest} from "./zabbix-request.js"; +import {ZabbixAPI} from "./zabbix-api.js"; +import {logger} from "../logging/logger.js"; @@ -30,3 +32,40 @@ export class ZabbixQueryTemplateGroupRequest extends ZabbixRequest { + constructor(authToken?: string | null, cookie?: string | null) { + super("templategroup.create", authToken, cookie); + } +} + +export class ZabbixCreateTemplateRequest extends ZabbixRequest<{ templateids: string[] }> { + constructor(authToken?: string | null, cookie?: string | null) { + super("template.create", authToken, cookie); + } +} + +export class ZabbixQueryItemRequest extends ZabbixRequest { + constructor(authToken?: string | null, cookie?: string | null) { + super("item.get", authToken, cookie); + } +} + +export class ZabbixCreateItemRequest extends ZabbixRequest<{ itemids: string[] }> { + constructor(authToken?: string | null, cookie?: string | null) { + super("item.create", authToken, cookie); + } +} + +export class ZabbixDeleteTemplatesRequest extends ZabbixRequest<{ templateids: string[] }> { + constructor(authToken?: string | null, cookie?: string | null) { + super("template.delete", authToken, cookie); + } +} + +export class ZabbixDeleteTemplateGroupsRequest extends ZabbixRequest<{ groupids: string[] }> { + constructor(authToken?: string | null, cookie?: string | null) { + super("templategroup.delete", authToken, cookie); + } +} + + diff --git a/src/execution/template_deleter.ts b/src/execution/template_deleter.ts new file mode 100644 index 0000000..2eac02e --- /dev/null +++ b/src/execution/template_deleter.ts @@ -0,0 +1,107 @@ + +import {DeleteResponse} from "../schema/generated/graphql.js"; +import { + ZabbixDeleteTemplateGroupsRequest, + ZabbixDeleteTemplatesRequest, + ZabbixQueryTemplateGroupRequest, + ZabbixQueryTemplatesRequest +} from "../datasources/zabbix-templates.js"; +import {isZabbixErrorResult, ParsedArgs} from "../datasources/zabbix-request.js"; +import {zabbixAPI} from "../datasources/zabbix-api.js"; + +export class TemplateDeleter { + + public static async deleteTemplates(templateids: number[] | null | undefined, name_pattern?: string | null, zabbixAuthToken?: string, cookie?: string): Promise { + const result: DeleteResponse[] = []; + let idsToDelete = templateids ? [...templateids] : []; + + if (name_pattern) { + const queryResult = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie) + .executeRequestReturnError(zabbixAPI, new ParsedArgs({ name_pattern: name_pattern })); + + if (!isZabbixErrorResult(queryResult) && Array.isArray(queryResult)) { + const foundIds = queryResult.map(t => Number(t.templateid)); + // Merge and deduplicate + idsToDelete = Array.from(new Set([...idsToDelete, ...foundIds])); + } + } + + if (idsToDelete.length === 0) { + return []; + } + + // Zabbix template.delete accepts an array of template IDs + const deleteResult = await new ZabbixDeleteTemplatesRequest(zabbixAuthToken, cookie) + .executeRequestReturnError(zabbixAPI, new ParsedArgs(idsToDelete)); + + if (isZabbixErrorResult(deleteResult)) { + let errorMessage = deleteResult.error.message; + if (deleteResult.error.data) { + errorMessage += " " + (typeof deleteResult.error.data === 'string' ? deleteResult.error.data : JSON.stringify(deleteResult.error.data)); + } + // If the whole batch fails, we report the error for each ID + for (const id of idsToDelete) { + result.push({ + id: id, + message: errorMessage, + error: deleteResult.error + }); + } + } else if (deleteResult?.templateids) { + for (const id of idsToDelete) { + result.push({ + id: id, + message: `Template ${id} deleted successfully` + }); + } + } + + return result; + } + + public static async deleteTemplateGroups(groupids: number[] | null | undefined, name_pattern?: string | null, zabbixAuthToken?: string, cookie?: string): Promise { + const result: DeleteResponse[] = []; + let idsToDelete = groupids ? [...groupids] : []; + + if (name_pattern) { + const queryResult = await new ZabbixQueryTemplateGroupRequest(zabbixAuthToken, cookie) + .executeRequestReturnError(zabbixAPI, new ParsedArgs({ name_pattern: name_pattern })); + + if (!isZabbixErrorResult(queryResult) && Array.isArray(queryResult)) { + const foundIds = queryResult.map(g => Number(g.groupid)); + // Merge and deduplicate + idsToDelete = Array.from(new Set([...idsToDelete, ...foundIds])); + } + } + + if (idsToDelete.length === 0) { + return []; + } + + const deleteResult = await new ZabbixDeleteTemplateGroupsRequest(zabbixAuthToken, cookie) + .executeRequestReturnError(zabbixAPI, new ParsedArgs(idsToDelete)); + + if (isZabbixErrorResult(deleteResult)) { + let errorMessage = deleteResult.error.message; + if (deleteResult.error.data) { + errorMessage += " " + (typeof deleteResult.error.data === 'string' ? deleteResult.error.data : JSON.stringify(deleteResult.error.data)); + } + for (const id of idsToDelete) { + result.push({ + id: id, + message: errorMessage, + error: deleteResult.error + }); + } + } else if (deleteResult?.groupids) { + for (const id of idsToDelete) { + result.push({ + id: id, + message: `Template group ${id} deleted successfully` + }); + } + } + + return result; + } +} diff --git a/src/execution/template_importer.ts b/src/execution/template_importer.ts new file mode 100644 index 0000000..5910ac1 --- /dev/null +++ b/src/execution/template_importer.ts @@ -0,0 +1,220 @@ + +import { + CreateTemplate, + CreateTemplateGroup, + CreateTemplateGroupResponse, + ImportTemplateResponse, + InputMaybe +} from "../schema/generated/graphql.js"; +import {logger} from "../logging/logger.js"; +import { + ZabbixCreateItemRequest, + ZabbixCreateTemplateGroupRequest, + ZabbixCreateTemplateRequest, + ZabbixQueryItemRequest, + ZabbixQueryTemplateGroupRequest, + ZabbixQueryTemplatesRequest +} from "../datasources/zabbix-templates.js"; +import {isZabbixErrorResult, ParsedArgs, ZabbixErrorResult} from "../datasources/zabbix-request.js"; +import {zabbixAPI} from "../datasources/zabbix-api.js"; + +export class TemplateImporter { + + public static async importTemplateGroups(templateGroups: InputMaybe> | undefined, zabbixAuthToken?: string, cookie?: string) { + if (!templateGroups) { + return null + } + let result: CreateTemplateGroupResponse[] = [] + for (let group of templateGroups) { + let createGroupResult: { groupids: string[] } | ZabbixErrorResult | undefined = undefined; + + // Try to find if it exists by name first + let groups = await new ZabbixQueryTemplateGroupRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({ + filter_name: group.groupName + })) + + let groupid = 0 + let message: string | undefined = undefined + + if (!isZabbixErrorResult(groups) && groups?.length) { + groupid = Number(groups[0].groupid) + message = `Template group ${group.groupName} already exists with groupid=${groupid} - skipping` + logger.debug(message) + } else { + createGroupResult = await new ZabbixCreateTemplateGroupRequest(zabbixAuthToken, cookie) + .executeRequestReturnError(zabbixAPI, new ParsedArgs({ + name: group.groupName, + uuid: group.uuid + })) + + if (isZabbixErrorResult(createGroupResult)) { + let errorMessage = createGroupResult.error.message; + if (createGroupResult.error.data) { + errorMessage += " " + (typeof createGroupResult.error.data === 'string' ? createGroupResult.error.data : JSON.stringify(createGroupResult.error.data)); + } + result.push({ + groupName: group.groupName, + message: `Unable to create template group ${group.groupName}: ${errorMessage}`, + error: createGroupResult.error + }) + continue + } else if (createGroupResult?.groupids?.length) { + groupid = Number(createGroupResult.groupids[0]) + } + } + + if (groupid) { + result.push({ + groupName: group.groupName, + groupid: groupid, + message: message + }) + } else { + result.push({ + groupName: group.groupName, + message: `Unable to create template group ${group.groupName}: Unknown error`, + error: { message: "Unknown error - no groupid returned" } + }) + } + } + return result + } + + public static async importTemplates(templates: InputMaybe> | undefined, zabbixAuthToken?: string, cookie?: string) { + if (!templates) { + return null + } + let result: ImportTemplateResponse[] = [] + for (let template of templates) { + // 1. Resolve Group IDs + let groupids = template.groupids + if (!groupids || groupids.length === 0) { + let groups = await new ZabbixQueryTemplateGroupRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({ + filter_name: template.groupNames + })) + + if (isZabbixErrorResult(groups) || !groups?.length) { + result.push({ + host: template.host, + message: `Unable to find template groups=${template.groupNames}` + }) + continue + } + groupids = groups.map(g => Number(g.groupid)) + } + + // 2. Resolve Linked Templates IDs + let linkedTemplates: { templateid: string }[] = [] + if (template.templates && template.templates.length > 0) { + let templateNames = template.templates.map(t => t.name) + let queryResult = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({ + filter_host: templateNames + })) + + if (isZabbixErrorResult(queryResult)) { + let errorMessage = queryResult.error.message; + if (queryResult.error.data) { + errorMessage += " " + (typeof queryResult.error.data === 'string' ? queryResult.error.data : JSON.stringify(queryResult.error.data)); + } + result.push({ + host: template.host, + message: `Error querying linked templates: ${errorMessage}`, + error: queryResult.error + }) + continue + } + linkedTemplates = queryResult.map(t => ({ templateid: t.templateid })) + } + + // 3. Create Template + let templateCreateParams: any = { + host: template.host, + name: template.name || template.host, + groups: groupids.map(id => ({ groupid: id })), + uuid: template.uuid, + templates: linkedTemplates, + tags: template.tags?.map(t => ({ tag: t.tag, value: t.value || "" })) + } + + let templateImportResult = await new ZabbixCreateTemplateRequest(zabbixAuthToken, cookie) + .executeRequestReturnError(zabbixAPI, new ParsedArgs(templateCreateParams)) + + if (isZabbixErrorResult(templateImportResult) || !templateImportResult?.templateids?.length) { + let errorMessage = isZabbixErrorResult(templateImportResult) ? templateImportResult.error.message : "Unknown error"; + if (isZabbixErrorResult(templateImportResult) && templateImportResult.error.data) { + errorMessage += " " + (typeof templateImportResult.error.data === 'string' ? templateImportResult.error.data : JSON.stringify(templateImportResult.error.data)); + } + result.push({ + host: template.host, + message: `Unable to import template=${template.host}: ${errorMessage}`, + error: isZabbixErrorResult(templateImportResult) ? templateImportResult.error : undefined + }) + continue + } + + let templateid = templateImportResult.templateids[0] + + // 4. Create Items if any + if (template.items && template.items.length > 0) { + const createdItemKeyToId = new Map(); + let itemsToCreate = [...template.items]; + let retry = true; + + while (retry && itemsToCreate.length > 0) { + retry = false; + const remainingItems: typeof itemsToCreate = []; + + for (let item of itemsToCreate) { + if (item.master_item && !createdItemKeyToId.has(item.master_item.key)) { + remainingItems.push(item); + continue; + } + + let { key, master_item, ...itemData } = item; + let itemCreateParams: any = { + ...itemData, + key_: key, + hostid: templateid, + preprocessing: item.preprocessing?.map(p => ({ + type: p.type, + params: p.params.join("\n"), + error_handler: p.error_handler, + error_handler_params: p.error_handler_params + })), + tags: item.tags?.map(t => ({ tag: t.tag, value: t.value || "" })) + } + + if (master_item) { + itemCreateParams.master_itemid = createdItemKeyToId.get(master_item.key); + } + + let itemResult = await new ZabbixCreateItemRequest(zabbixAuthToken, cookie) + .executeRequestReturnError(zabbixAPI, new ParsedArgs(itemCreateParams)) + + if (isZabbixErrorResult(itemResult)) { + let errorMessage = itemResult.error.message; + if (itemResult.error.data) { + errorMessage += " " + (typeof itemResult.error.data === 'string' ? itemResult.error.data : JSON.stringify(itemResult.error.data)); + } + logger.error(`Unable to create item ${item.name} for template ${template.host}: ${errorMessage}`) + } else if (itemResult?.itemids?.length) { + createdItemKeyToId.set(key, itemResult.itemids[0]); + retry = true; + } + } + itemsToCreate = remainingItems; + } + + if (itemsToCreate.length > 0) { + logger.error(`Unable to create ${itemsToCreate.length} items for template ${template.host} due to missing master items: ${itemsToCreate.map(i => i.name).join(", ")}`); + } + } + + result.push({ + host: template.host, + templateid: templateid + }) + } + return result + } +} diff --git a/src/schema/generated/graphql.ts b/src/schema/generated/graphql.ts index 737a5ab..0d155af 100644 --- a/src/schema/generated/graphql.ts +++ b/src/schema/generated/graphql.ts @@ -81,6 +81,94 @@ export interface CreateHostResponse { itemids?: Maybe>>; } +export interface CreateItemPreprocessing { + error_handler?: InputMaybe; + error_handler_params?: InputMaybe; + params: Array; + type: Scalars['Int']['input']; +} + +export interface CreateLinkedTemplate { + name: Scalars['String']['input']; +} + +export interface CreateMasterItem { + key: Scalars['String']['input']; +} + +export interface CreateTag { + tag: Scalars['String']['input']; + value?: InputMaybe; +} + +export interface CreateTemplate { + /** + * groupNames is used to assign the created object + * to a template group. + */ + groupNames: Array; + /** + * Optionally the internal groupids can be passed - in this case the + * groupName is ignored + */ + groupids?: InputMaybe>>; + /** Name of the template */ + host: Scalars['String']['input']; + /** Template items */ + items?: InputMaybe>; + /** Visible name of the template */ + name?: InputMaybe; + /** Template tags */ + tags?: InputMaybe>; + /** Linked templates */ + templates?: InputMaybe>; + /** + * Internally used unique id + * (will be assigned by Zabbix if empty) + */ + uuid?: InputMaybe; +} + +export interface CreateTemplateGroup { + /** Name of the template group */ + groupName: Scalars['String']['input']; + /** + * Internally used unique id + * (will be assigned by Zabbix if empty) + */ + uuid?: InputMaybe; +} + +export interface CreateTemplateGroupResponse { + __typename?: 'CreateTemplateGroupResponse'; + error?: Maybe; + groupName: Scalars['String']['output']; + groupid?: Maybe; + message?: Maybe; +} + +export interface CreateTemplateItem { + delay?: InputMaybe; + description?: InputMaybe; + history?: InputMaybe; + key: Scalars['String']['input']; + master_item?: InputMaybe; + name: Scalars['String']['input']; + preprocessing?: InputMaybe>; + tags?: InputMaybe>; + type?: InputMaybe; + units?: InputMaybe; + uuid?: InputMaybe; + value_type?: InputMaybe; +} + +export interface DeleteResponse { + __typename?: 'DeleteResponse'; + error?: Maybe; + id: Scalars['Int']['output']; + message?: Maybe; +} + /** * (IoT / Edge - ) Devices are hosts having a state containing the "output" / the business data which is exposed * besides monitoring information. @@ -232,6 +320,14 @@ export interface ImportHostResponse { message?: Maybe; } +export interface ImportTemplateResponse { + __typename?: 'ImportTemplateResponse'; + error?: Maybe; + host: Scalars['String']['output']; + message?: Maybe; + templateid?: Maybe; +} + export interface ImportUserRightResult { __typename?: 'ImportUserRightResult'; errors?: Maybe>; @@ -268,6 +364,18 @@ export interface Mutation { __typename?: 'Mutation'; /** Authentication: By zbx_session - cookie or zabbix-auth-token - header */ createHost?: Maybe; + /** + * Delete template groups. + * + * Authentication: By zbx_session - cookie or zabbix-auth-token - header + */ + deleteTemplateGroups?: Maybe>; + /** + * Delete templates. + * + * Authentication: By zbx_session - cookie or zabbix-auth-token - header + */ + deleteTemplates?: Maybe>; /** * (Mass) Import zabbix groups * and assign them to the corresponding hosts by groupid or groupName. @@ -287,6 +395,25 @@ export interface Mutation { * Authentication: By zbx_session - cookie or zabbix-auth-token - header */ importHosts?: Maybe>; + /** + * (Mass) Import template groups + * and assign them by groupid or name. + * + * Return value: If no error occurs a groupid be returned for each created group, + * otherwise the return object will contain an error message + * + * Authentication: By zbx_session - cookie or zabbix-auth-token - header + */ + importTemplateGroups?: Maybe>; + /** + * (Mass) Import templates. + * + * Return value: If no error occurs a templateid will be returned for each created template, + * otherwise the return object will contain an error message. + * + * Authentication: By zbx_session - cookie or zabbix-auth-token - header + */ + importTemplates?: Maybe>; importUserRights?: Maybe; } @@ -299,6 +426,18 @@ export interface MutationCreateHostArgs { } +export interface MutationDeleteTemplateGroupsArgs { + groupids?: InputMaybe>; + name_pattern?: InputMaybe; +} + + +export interface MutationDeleteTemplatesArgs { + name_pattern?: InputMaybe; + templateids?: InputMaybe>; +} + + export interface MutationImportHostGroupsArgs { hostGroups: Array; } @@ -309,6 +448,16 @@ export interface MutationImportHostsArgs { } +export interface MutationImportTemplateGroupsArgs { + templateGroups: Array; +} + + +export interface MutationImportTemplatesArgs { + templates: Array; +} + + export interface MutationImportUserRightsArgs { dryRun?: Scalars['Boolean']['input']; input: UserRightsInput; @@ -359,6 +508,8 @@ export interface Query { * Authentication: By zbx_session - cookie or zabbix-auth-token - header */ allHosts?: Maybe>>; + /** Get template groups. */ + allTemplateGroups?: Maybe>>; /** Get api (build) version */ apiVersion: Scalars['String']['output']; /** @@ -401,6 +552,8 @@ export interface Query { * operation. Returns true on success */ logout?: Maybe; + /** Get templates. */ + templates?: Maybe>>; /** * Return all user permissions. If objectNames is provided return only the permissions related to the objects within * the objectNames - list @@ -439,6 +592,11 @@ export interface QueryAllHostsArgs { } +export interface QueryAllTemplateGroupsArgs { + name_pattern?: InputMaybe; +} + + export interface QueryExportHostValueHistoryArgs { host_filter?: InputMaybe>; itemKey_filter?: InputMaybe>; @@ -474,6 +632,12 @@ export interface QueryLoginArgs { } +export interface QueryTemplatesArgs { + hostids?: InputMaybe>>; + name_pattern?: InputMaybe; +} + + export interface QueryUserPermissionsArgs { objectNames?: InputMaybe>; } @@ -749,7 +913,16 @@ export type ResolversTypes = { CreateHostGroup: CreateHostGroup; CreateHostGroupResponse: ResolverTypeWrapper; CreateHostResponse: ResolverTypeWrapper; + CreateItemPreprocessing: CreateItemPreprocessing; + CreateLinkedTemplate: CreateLinkedTemplate; + CreateMasterItem: CreateMasterItem; + CreateTag: CreateTag; + CreateTemplate: CreateTemplate; + CreateTemplateGroup: CreateTemplateGroup; + CreateTemplateGroupResponse: ResolverTypeWrapper; + CreateTemplateItem: CreateTemplateItem; DateTime: ResolverTypeWrapper; + DeleteResponse: ResolverTypeWrapper; Device: ResolverTypeWrapper['Device']>; DeviceCommunicationType: DeviceCommunicationType; DeviceConfig: ResolverTypeWrapper; @@ -769,6 +942,7 @@ export type ResolversTypes = { HostGroup: ResolverTypeWrapper; ID: ResolverTypeWrapper; ImportHostResponse: ResolverTypeWrapper; + ImportTemplateResponse: ResolverTypeWrapper; ImportUserRightResult: ResolverTypeWrapper; ImportUserRightsResult: ResolverTypeWrapper; Int: ResolverTypeWrapper; @@ -814,7 +988,16 @@ export type ResolversParentTypes = { CreateHostGroup: CreateHostGroup; CreateHostGroupResponse: CreateHostGroupResponse; CreateHostResponse: CreateHostResponse; + CreateItemPreprocessing: CreateItemPreprocessing; + CreateLinkedTemplate: CreateLinkedTemplate; + CreateMasterItem: CreateMasterItem; + CreateTag: CreateTag; + CreateTemplate: CreateTemplate; + CreateTemplateGroup: CreateTemplateGroup; + CreateTemplateGroupResponse: CreateTemplateGroupResponse; + CreateTemplateItem: CreateTemplateItem; DateTime: Scalars['DateTime']['output']; + DeleteResponse: DeleteResponse; Device: ResolversInterfaceTypes['Device']; DeviceConfig: DeviceConfig; DeviceState: ResolversInterfaceTypes['DeviceState']; @@ -832,6 +1015,7 @@ export type ResolversParentTypes = { HostGroup: HostGroup; ID: Scalars['ID']['output']; ImportHostResponse: ImportHostResponse; + ImportTemplateResponse: ImportTemplateResponse; ImportUserRightResult: ImportUserRightResult; ImportUserRightsResult: ImportUserRightsResult; Int: Scalars['Int']['output']; @@ -890,10 +1074,25 @@ export type CreateHostResponseResolvers; }; +export type CreateTemplateGroupResponseResolvers = { + error?: Resolver, ParentType, ContextType>; + groupName?: Resolver; + groupid?: Resolver, ParentType, ContextType>; + message?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export interface DateTimeScalarConfig extends GraphQLScalarTypeConfig { name: 'DateTime'; } +export type DeleteResponseResolvers = { + error?: Resolver, ParentType, ContextType>; + id?: Resolver; + message?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type DeviceResolvers = { __resolveType: TypeResolveFn<'GenericDevice', ParentType, ContextType>; deviceType?: Resolver, ParentType, ContextType>; @@ -1011,6 +1210,14 @@ export type ImportHostResponseResolvers; }; +export type ImportTemplateResponseResolvers = { + error?: Resolver, ParentType, ContextType>; + host?: Resolver; + message?: Resolver, ParentType, ContextType>; + templateid?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ImportUserRightResultResolvers = { errors?: Resolver>, ParentType, ContextType>; id?: Resolver, ParentType, ContextType>; @@ -1043,8 +1250,12 @@ export type LocationResolvers = { createHost?: Resolver, ParentType, ContextType, RequireFields>; + deleteTemplateGroups?: Resolver>, ParentType, ContextType, Partial>; + deleteTemplates?: Resolver>, ParentType, ContextType, Partial>; importHostGroups?: Resolver>, ParentType, ContextType, RequireFields>; importHosts?: Resolver>, ParentType, ContextType, RequireFields>; + importTemplateGroups?: Resolver>, ParentType, ContextType, RequireFields>; + importTemplates?: Resolver>, ParentType, ContextType, RequireFields>; importUserRights?: Resolver, ParentType, ContextType, RequireFields>; }; @@ -1064,6 +1275,7 @@ export type QueryResolvers>>, ParentType, ContextType, RequireFields>; allHostGroups?: Resolver>>, ParentType, ContextType, RequireFields>; allHosts?: Resolver>>, ParentType, ContextType, RequireFields>; + allTemplateGroups?: Resolver>>, ParentType, ContextType, Partial>; apiVersion?: Resolver; exportHostValueHistory?: Resolver, ParentType, ContextType, RequireFields>; exportUserRights?: Resolver, ParentType, ContextType, RequireFields>; @@ -1071,6 +1283,7 @@ export type QueryResolvers>>, ParentType, ContextType, RequireFields>; login?: Resolver, ParentType, ContextType, RequireFields>; logout?: Resolver, ParentType, ContextType>; + templates?: Resolver>>, ParentType, ContextType, Partial>; userPermissions?: Resolver>, ParentType, ContextType, Partial>; zabbixVersion?: Resolver, ParentType, ContextType>; }; @@ -1193,7 +1406,9 @@ export type Resolvers = { ApiError?: ApiErrorResolvers; CreateHostGroupResponse?: CreateHostGroupResponseResolvers; CreateHostResponse?: CreateHostResponseResolvers; + CreateTemplateGroupResponse?: CreateTemplateGroupResponseResolvers; DateTime?: GraphQLScalarType; + DeleteResponse?: DeleteResponseResolvers; Device?: DeviceResolvers; DeviceCommunicationType?: DeviceCommunicationTypeResolvers; DeviceConfig?: DeviceConfigResolvers; @@ -1211,6 +1426,7 @@ export type Resolvers = { Host?: HostResolvers; HostGroup?: HostGroupResolvers; ImportHostResponse?: ImportHostResponseResolvers; + ImportTemplateResponse?: ImportTemplateResponseResolvers; ImportUserRightResult?: ImportUserRightResultResolvers; ImportUserRightsResult?: ImportUserRightsResultResolvers; Inventory?: InventoryResolvers; diff --git a/src/test/template_deleter.test.ts b/src/test/template_deleter.test.ts new file mode 100644 index 0000000..3e0f1c5 --- /dev/null +++ b/src/test/template_deleter.test.ts @@ -0,0 +1,162 @@ + +import {TemplateDeleter} from "../execution/template_deleter.js"; +import {zabbixAPI} from "../datasources/zabbix-api.js"; + +// Mocking ZabbixAPI +jest.mock("../datasources/zabbix-api.js", () => ({ + zabbixAPI: { + executeRequest: jest.fn(), + post: jest.fn() + } +})); + +describe("TemplateDeleter", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("deleteTemplates - success", async () => { + const templateids = [1, 2]; + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ templateids: ["1", "2"] }); + + const result = await TemplateDeleter.deleteTemplates(templateids, null, "token"); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe(1); + expect(result[0].message).toContain("deleted successfully"); + expect(result[1].id).toBe(2); + + expect(zabbixAPI.post).toHaveBeenCalledWith("template.delete", expect.objectContaining({ + body: expect.objectContaining({ + params: [1, 2] + }) + })); + }); + + test("deleteTemplates - error", async () => { + const templateids = [1]; + const zabbixError = { + error: { + code: -32602, + message: "Invalid params.", + data: "Template does not exist" + } + }; + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(zabbixError); + + const result = await TemplateDeleter.deleteTemplates(templateids, null, "token"); + + expect(result).toHaveLength(1); + expect(result[0].error).toBeDefined(); + expect(result[0].message).toContain("Invalid params."); + expect(result[0].message).toContain("Template does not exist"); + }); + + test("deleteTemplates - by name_pattern", async () => { + // Mock template.get + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([ + { templateid: "10", host: "PatternTemplate 1" }, + { templateid: "11", host: "PatternTemplate 2" } + ]); + // Mock template.delete + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ templateids: ["10", "11"] }); + + const result = await TemplateDeleter.deleteTemplates(null, "PatternTemplate%", "token"); + + expect(result).toHaveLength(2); + expect(result.map(r => r.id)).toContain(10); + expect(result.map(r => r.id)).toContain(11); + + expect(zabbixAPI.post).toHaveBeenCalledWith("template.get", expect.objectContaining({ + body: expect.objectContaining({ + params: expect.objectContaining({ + search: { name: "PatternTemplate%" } + }) + }) + })); + expect(zabbixAPI.post).toHaveBeenCalledWith("template.delete", expect.objectContaining({ + body: expect.objectContaining({ + params: expect.arrayContaining([10, 11]) + }) + })); + }); + + test("deleteTemplates - merged IDs and name_pattern", async () => { + // Mock template.get + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([ + { templateid: "10", host: "PatternTemplate 1" }, + { templateid: "12", host: "PatternTemplate 3" } + ]); + // Mock template.delete + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ templateids: ["10", "11", "12"] }); + + const result = await TemplateDeleter.deleteTemplates([11], "PatternTemplate%", "token"); + + expect(result).toHaveLength(3); + expect(result.map(r => r.id)).toContain(10); + expect(result.map(r => r.id)).toContain(11); + expect(result.map(r => r.id)).toContain(12); + + expect(zabbixAPI.post).toHaveBeenCalledWith("template.delete", expect.objectContaining({ + body: expect.objectContaining({ + params: expect.arrayContaining([10, 11, 12]) + }) + })); + }); + + test("deleteTemplateGroups - success", async () => { + const groupids = [101]; + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ groupids: ["101"] }); + + const result = await TemplateDeleter.deleteTemplateGroups(groupids, null, "token"); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(101); + expect(result[0].message).toContain("deleted successfully"); + }); + + test("deleteTemplateGroups - error", async () => { + const groupids = [101]; + const zabbixError = { + error: { + code: -32602, + message: "Invalid params.", + data: "Group is in use" + } + }; + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(zabbixError); + + const result = await TemplateDeleter.deleteTemplateGroups(groupids, null, "token"); + + expect(result).toHaveLength(1); + expect(result[0].error).toBeDefined(); + expect(result[0].message).toContain("Group is in use"); + }); + + test("deleteTemplateGroups - by name_pattern", async () => { + // Mock templategroup.get + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([ + { groupid: "201", name: "PatternGroup 1" } + ]); + // Mock templategroup.delete + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ groupids: ["201"] }); + + const result = await TemplateDeleter.deleteTemplateGroups(null, "PatternGroup%", "token"); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(201); + + expect(zabbixAPI.post).toHaveBeenCalledWith("templategroup.get", expect.objectContaining({ + body: expect.objectContaining({ + params: expect.objectContaining({ + search: { name: "PatternGroup%" } + }) + }) + })); + expect(zabbixAPI.post).toHaveBeenCalledWith("templategroup.delete", expect.objectContaining({ + body: expect.objectContaining({ + params: [201] + }) + })); + }); +}); diff --git a/src/test/template_importer.test.ts b/src/test/template_importer.test.ts new file mode 100644 index 0000000..9457bbd --- /dev/null +++ b/src/test/template_importer.test.ts @@ -0,0 +1,176 @@ + +import {TemplateImporter} from "../execution/template_importer.js"; +import {zabbixAPI} from "../datasources/zabbix-api.js"; +import { + ZabbixCreateItemRequest, + ZabbixCreateTemplateGroupRequest, + ZabbixCreateTemplateRequest, + ZabbixQueryItemRequest, + ZabbixQueryTemplateGroupRequest, + ZabbixQueryTemplatesRequest +} from "../datasources/zabbix-templates.js"; +import {ZabbixErrorResult} from "../datasources/zabbix-request.js"; + +// Mocking ZabbixAPI.executeRequest +jest.mock("../datasources/zabbix-api.js", () => ({ + zabbixAPI: { + executeRequest: jest.fn(), + post: jest.fn() + } +})); + +describe("TemplateImporter", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("importTemplateGroups - create new group", async () => { + const templateGroups = [{ groupName: "New Group", uuid: "uuid1" }]; + + // Mocking group.get to return empty (group doesn't exist) + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([]); + // Mocking group.create + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ groupids: ["101"] }); + + const result = await TemplateImporter.importTemplateGroups(templateGroups, "token"); + + expect(result).toHaveLength(1); + expect(result![0].groupid).toBe(101); + expect(result![0].groupName).toBe("New Group"); + }); + + test("importTemplateGroups - group already exists", async () => { + const templateGroups = [{ groupName: "Existing Group" }]; + + // Mocking group.get to return existing group + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ groupid: "102", name: "Existing Group" }]); + + const result = await TemplateImporter.importTemplateGroups(templateGroups, "token"); + + expect(result).toHaveLength(1); + expect(result![0].groupid).toBe(102); + expect(result![0].message).toContain("already exists"); + }); + + test("importTemplates - basic template", async () => { + const templates = [{ + host: "Test Template", + groupNames: ["Group1"] + }]; + + // Mocking group.get for Group1 + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ groupid: "201", name: "Group1" }]); + // Mocking template.create + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ templateids: ["301"] }); + + const result = await TemplateImporter.importTemplates(templates, "token"); + + expect(result).toHaveLength(1); + expect(result![0].templateid).toBe("301"); + expect(result![0].host).toBe("Test Template"); + }); + + test("importTemplates - with items, linked templates and dependent items", async () => { + const templates = [{ + host: "Complex Template", + groupNames: ["Group1"], + templates: [{ name: "Linked Template" }], + items: [ + { + name: "Dependent Item", + key: "dependent.key", + type: 18, + value_type: 3, + master_item: { key: "master.key" } + }, + { + name: "Master Item", + key: "master.key", + type: 0, + value_type: 3, + } + ] + }]; + + // Mocking group.get + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ groupid: "201", name: "Group1" }]); + // Mocking template.get for linked template + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ templateid: "401", host: "Linked Template" }]); + // Mocking template.create + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ templateids: ["501"] }); + + // Mocking item.create for Master Item (first pass will pick Master Item because Dependent Item is missing its master) + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ itemids: ["601"] }); + // Mocking item.create for Dependent Item (second pass) + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ itemids: ["602"] }); + + const result = await TemplateImporter.importTemplates(templates, "token"); + + expect(result).toHaveLength(1); + expect(result![0].templateid).toBe("501"); + + // Check template.create params + const templateCreateCall = (zabbixAPI.post as jest.Mock).mock.calls.find(call => call[1].body.method === "template.create"); + expect(templateCreateCall[1].body.params.templates).toContainEqual({ templateid: "401" }); + + // Check item.create calls + const itemCreateCalls = (zabbixAPI.post as jest.Mock).mock.calls.filter(call => call[1].body.method === "item.create"); + expect(itemCreateCalls).toHaveLength(2); + + const masterCall = itemCreateCalls.find(c => c[1].body.params.name === "Master Item"); + const dependentCall = itemCreateCalls.find(c => c[1].body.params.name === "Dependent Item"); + + expect(masterCall[1].body.params.key_).toBe("master.key"); + expect(dependentCall[1].body.params.key_).toBe("dependent.key"); + expect(dependentCall[1].body.params.master_itemid).toBe("601"); + }); + + test("importTemplates - template query", async () => { + // This tests the template.get functionality used during import + const templates = [{ + host: "Template A", + groupNames: ["Group1"], + templates: [{ name: "Template B" }] + }]; + + // Mock Group + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ groupid: "1", name: "Group1" }]); + // Mock Template B lookup + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ templateid: "2", host: "Template B" }]); + // Mock Template A creation + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ templateids: ["3"] }); + + await TemplateImporter.importTemplates(templates, "token"); + + // Verify that template.get was called for Template B + const templateQueryCall = (zabbixAPI.post as jest.Mock).mock.calls.find(call => call[1].body.method === "template.get"); + expect(templateQueryCall).toBeDefined(); + expect(templateQueryCall[1].body.params.filter.host).toContain("Template B"); + }); + + test("importTemplates - error message includes data field", async () => { + const templates = [{ + host: "Error Template", + groupNames: ["Group1"] + }]; + + // Mocking group.get + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ groupid: "201", name: "Group1" }]); + + // Mocking template.create with an error including data + const zabbixError = { + error: { + code: -32602, + message: "Invalid params.", + data: "Invalid parameter \"/1\": the parameter \"key_\" is missing." + } + }; + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(zabbixError); + + const result = await TemplateImporter.importTemplates(templates, "token"); + + expect(result).toHaveLength(1); + expect(result![0].message).toContain("Invalid params."); + expect(result![0].message).toContain("the parameter \"key_\" is missing."); + }); +}); diff --git a/src/test/template_integration.test.ts b/src/test/template_integration.test.ts new file mode 100644 index 0000000..a6c30fc --- /dev/null +++ b/src/test/template_integration.test.ts @@ -0,0 +1,229 @@ +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'; + +// Mocking ZabbixAPI.post +jest.mock("../datasources/zabbix-api.js", () => ({ + zabbixAPI: { + post: jest.fn(), + executeRequest: jest.fn(), + baseURL: 'http://localhost/zabbix' + } +})); + +describe("Template Integration Tests", () => { + let server: ApolloServer; + + beforeAll(async () => { + const schema = await schema_loader(); + server = new ApolloServer({ + schema, + }); + }); + + test("Import templates using sample query and variables", async () => { + const sampleFile = readFileSync(join(process.cwd(), 'docs', 'sample_import_templates_mutation.graphql'), 'utf-8').replace(/\r\n/g, '\n'); + + // Extract mutation and variables from the doc file + const mutationMatch = sampleFile.match(/```graphql\n([\s\S]*?)\n```/); + const variablesMatch = sampleFile.match(/```json\n([\s\S]*?)\n```/); + + const mutation = mutationMatch![1]; + const variables = JSON.parse(variablesMatch![1]); + + // Mock Zabbix API calls + // 1. Group lookup + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ groupid: "201", name: "Templates/Roadwork/Devices" }]); + // 2. Linked template lookup + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ templateid: "401", host: "ROADWORK_DEVICE" }]); + // 3. Template creation + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ templateids: ["501"] }); + // 4. Item creation (location) + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ itemids: ["601"] }); + // 5. Item creation (MQTT_LOCATION) + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ itemids: ["602"] }); + + const response = await server.executeOperation({ + query: mutation, + variables: variables, + }, { + contextValue: { + zabbixAuthToken: 'test-token' + } + }); + + expect(response.body.kind).toBe('single'); + // @ts-ignore + const result = response.body.singleResult; + expect(result.errors).toBeUndefined(); + expect(result.data.importTemplates).toHaveLength(1); + expect(result.data.importTemplates[0].host).toBe("BT_DEVICE_TRACKER"); + expect(result.data.importTemplates[0].templateid).toBe("501"); + }); + + test("Import and Export templates comparison", async () => { + // 1. Import + const importSample = readFileSync(join(process.cwd(), 'docs', 'sample_import_templates_mutation.graphql'), 'utf-8').replace(/\r\n/g, '\n'); + const importMutation = importSample.match(/```graphql\n([\s\S]*?)\n```/)![1]; + const importVariables = JSON.parse(importSample.match(/```json\n([\s\S]*?)\n```/)![1]); + + // Mock for import + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ groupid: "201", name: "Templates/Roadwork/Devices" }]); + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ templateid: "401", host: "ROADWORK_DEVICE" }]); + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ templateids: ["501"] }); + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ itemids: ["601"] }); + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ itemids: ["602"] }); + + await server.executeOperation({ + query: importMutation, + variables: importVariables, + }, { + contextValue: { zabbixAuthToken: 'test-token' } + }); + + // 2. Export (Query) + const querySample = readFileSync(join(process.cwd(), 'docs', 'sample_templates_query.graphql'), 'utf-8').replace(/\r\n/g, '\n'); + const query = querySample.match(/```graphql\n([\s\S]*?)\n```/)![1]; + const queryVariables = JSON.parse(querySample.match(/```json\n([\s\S]*?)\n```/)![1]); + + // Mock for query + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ templateid: "501", host: "BT_DEVICE_TRACKER", name: "BT_DEVICE_TRACKER" }]); + + const queryResponse = await server.executeOperation({ + query: query, + variables: queryVariables, + }, { + contextValue: { zabbixAuthToken: 'test-token' } + }); + + expect(queryResponse.body.kind).toBe('single'); + // @ts-ignore + const queryResult = queryResponse.body.singleResult; + expect(queryResult.errors).toBeUndefined(); + expect(queryResult.data.templates).toHaveLength(1); + expect(queryResult.data.templates[0].name).toBe(importVariables.templates[0].name); + expect(queryResult.data.templates[0].templateid).toBe("501"); + + // 3. Delete + const deleteMutation = ` + mutation DeleteTemplates($templateids: [Int!], $name_pattern: String) { + deleteTemplates(templateids: $templateids, name_pattern: $name_pattern) { + id + message + } + } + `; + // Mock for query (to find ID for name_pattern deletion) + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ templateid: "501", host: "BT_DEVICE_TRACKER" }]); + // Mock for delete + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ templateids: ["501"] }); + + const deleteResponse = await server.executeOperation({ + query: deleteMutation, + variables: { name_pattern: "BT_DEVICE_TRACKER" }, + }, { + contextValue: { zabbixAuthToken: 'test-token' } + }); + + expect(deleteResponse.body.kind).toBe('single'); + // @ts-ignore + const deleteResult = deleteResponse.body.singleResult; + expect(deleteResult.errors).toBeUndefined(); + expect(deleteResult.data.deleteTemplates).toHaveLength(1); + expect(deleteResult.data.deleteTemplates[0].message).toContain("deleted successfully"); + }); + + test("Import and Export template groups comparison", async () => { + // 1. Import + const importSample = readFileSync(join(process.cwd(), 'docs', 'sample_import_template_groups_mutation.graphql'), 'utf-8').replace(/\r\n/g, '\n'); + const importMutation = importSample.match(/```graphql\n([\s\S]*?)\n```/)![1]; + const importVariables = JSON.parse(importSample.match(/```json\n([\s\S]*?)\n```/)![1]); + + // Mock for import (8 groups in sample) + for (const group of importVariables.templateGroups) { + // Mock lookup (not found) + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce([]); + // Mock creation + const mockGroupId = Math.floor(Math.random() * 1000).toString(); + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ groupids: [mockGroupId] }); + } + + const importResponse = await server.executeOperation({ + query: importMutation, + variables: importVariables, + }, { + contextValue: { zabbixAuthToken: 'test-token' } + }); + + expect(importResponse.body.kind).toBe('single'); + // @ts-ignore + const importResult = importResponse.body.singleResult; + expect(importResult.errors).toBeUndefined(); + expect(importResult.data.importTemplateGroups).toHaveLength(importVariables.templateGroups.length); + + // 2. Export (Query) + const querySample = readFileSync(join(process.cwd(), 'docs', 'sample_all_template_groups_query.graphql'), 'utf-8').replace(/\r\n/g, '\n'); + const query = querySample.match(/```graphql\n([\s\S]*?)\n```/)![1]; + const queryVariables = JSON.parse(querySample.match(/```json\n([\s\S]*?)\n```/)![1]); + + // Mock for query + const mockGroupsResponse = importVariables.templateGroups.map((g: any, index: number) => ({ + groupid: (index + 1000).toString(), + name: g.groupName + })); + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockGroupsResponse); + + const queryResponse = await server.executeOperation({ + query: query, + variables: queryVariables, + }, { + contextValue: { zabbixAuthToken: 'test-token' } + }); + + expect(queryResponse.body.kind).toBe('single'); + // @ts-ignore + const queryResult = queryResponse.body.singleResult; + expect(queryResult.errors).toBeUndefined(); + expect(queryResult.data.allTemplateGroups).toHaveLength(importVariables.templateGroups.length); + + // Verify names match + const importedNames = importVariables.templateGroups.map((g: any) => g.groupName).sort(); + const exportedNames = queryResult.data.allTemplateGroups.map((g: any) => g.name).sort(); + expect(exportedNames).toEqual(importedNames); + + // 3. Delete Template Groups + const groupidsToDelete = queryResult.data.allTemplateGroups.map((g: any) => parseInt(g.groupid)); + // Mock for query (for name_pattern) + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(queryResult.data.allTemplateGroups.map((g: any) => ({ groupid: g.groupid, name: g.name }))); + // Mock for delete + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ groupids: groupidsToDelete.map((id: number) => id.toString()) }); + + const deleteMutation = ` + mutation DeleteTemplateGroups($groupids: [Int!], $name_pattern: String) { + deleteTemplateGroups(groupids: $groupids, name_pattern: $name_pattern) { + id + message + error { + message + } + } + } + `; + + const deleteResponse = await server.executeOperation({ + query: deleteMutation, + variables: { name_pattern: "Templates/Roadwork/%" }, + }, { + contextValue: { zabbixAuthToken: 'test-token' } + }); + + expect(deleteResponse.body.kind).toBe('single'); + // @ts-ignore + const deleteResult = deleteResponse.body.singleResult; + expect(deleteResult.errors).toBeUndefined(); + expect(deleteResult.data.deleteTemplateGroups).toHaveLength(groupidsToDelete.length); + expect(deleteResult.data.deleteTemplateGroups[0].message).toContain("deleted successfully"); + }); +}); diff --git a/src/test/template_query.test.ts b/src/test/template_query.test.ts new file mode 100644 index 0000000..c83761a --- /dev/null +++ b/src/test/template_query.test.ts @@ -0,0 +1,112 @@ + +import {createResolvers} from "../api/resolvers.js"; +import {zabbixAPI} from "../datasources/zabbix-api.js"; +import {QueryTemplatesArgs} from "../schema/generated/graphql.js"; + +// Mocking ZabbixAPI +jest.mock("../datasources/zabbix-api.js", () => ({ + zabbixAPI: { + executeRequest: jest.fn(), + post: jest.fn(), + baseURL: "http://mock-zabbix" + }, + ZABBIX_EDGE_DEVICE_BASE_GROUP: "Baustellen-Devices" +})); + +describe("Template Resolver", () => { + let resolvers: any; + + beforeEach(() => { + jest.clearAllMocks(); + resolvers = createResolvers(); + }); + + test("templates query - returns all templates", async () => { + const mockTemplates = [ + { templateid: "1", name: "Template 1", uuid: "uuid1" }, + { templateid: "2", name: "Template 2", uuid: "uuid2" } + ]; + + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockTemplates); + + const args: QueryTemplatesArgs = {}; + const context = { zabbixAuthToken: "test-token" }; + + const result = await resolvers.Query.templates(null, args, context); + + expect(result).toEqual(mockTemplates); + expect(zabbixAPI.post).toHaveBeenCalledWith("template.get", expect.objectContaining({ + body: expect.objectContaining({ + method: "template.get", + params: {} + }) + })); + }); + + test("templates query - filters by hostids", async () => { + const mockTemplates = [{ templateid: "1", name: "Template 1" }]; + + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockTemplates); + + const args: QueryTemplatesArgs = { hostids: [1] }; + const context = { zabbixAuthToken: "test-token" }; + + const result = await resolvers.Query.templates(null, args, context); + + expect(result).toEqual(mockTemplates); + expect(zabbixAPI.post).toHaveBeenCalledWith("template.get", expect.objectContaining({ + body: expect.objectContaining({ + method: "template.get", + params: expect.objectContaining({ + templateids: [1] + }) + }) + })); + }); + + test("templates query - filters by name_pattern", async () => { + const mockTemplates = [{ templateid: "1", name: "Template 1" }]; + + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockTemplates); + + const args: QueryTemplatesArgs = { name_pattern: "Template" }; + const context = { zabbixAuthToken: "test-token" }; + + const result = await resolvers.Query.templates(null, args, context); + + expect(result).toEqual(mockTemplates); + expect(zabbixAPI.post).toHaveBeenCalledWith("template.get", expect.objectContaining({ + body: expect.objectContaining({ + method: "template.get", + params: expect.objectContaining({ + search: { + name: "Template" + } + }) + }) + })); + }); + + test("templates query - filters by name_pattern with % wildcard", async () => { + const mockTemplates = [{ templateid: "1", name: "Template 1" }]; + + (zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockTemplates); + + const args: QueryTemplatesArgs = { name_pattern: "Temp%1" }; + const context = { zabbixAuthToken: "test-token" }; + + const result = await resolvers.Query.templates(null, args, context); + + expect(result).toEqual(mockTemplates); + expect(zabbixAPI.post).toHaveBeenCalledWith("template.get", expect.objectContaining({ + body: expect.objectContaining({ + method: "template.get", + params: expect.objectContaining({ + search: { + name: "Temp%1" + } + }) + }) + })); + }); +}); diff --git a/src/testdata/templates/zbx_default_templates_vcr.yaml b/src/testdata/templates/zbx_default_templates_vcr.yaml new file mode 100644 index 0000000..c25fa51 --- /dev/null +++ b/src/testdata/templates/zbx_default_templates_vcr.yaml @@ -0,0 +1,670 @@ +zabbix_export: + version: '7.4' + template_groups: + - uuid: 43aab460fe444f18886b19948413b7e3 + name: Permissions/ConstructionSite + - uuid: 376524057e094c07aaa0cf7f524849dc + name: Templates/Roadwork/Controller + - uuid: 7d83c76454564390bb0e34600780eaec + name: Templates/Roadwork/Device-Capabilities + - uuid: 48d5d2a18a08448c96a931b63bb2c97d + name: Templates/Roadwork/Device-Capabilities/FLASH_ATTACHABLE + - uuid: 785986b84892468ea2e92d912747b1d3 + name: Templates/Roadwork/Device-Capabilities/GEOLOCALIZABLE + - uuid: a4b79479e97a4b48972dcb476d45e55a + name: Templates/Roadwork/Device-Capabilities/HAS_OPERATIONAL_DATA + - uuid: 3604af8102644bee9dcaf0f9c1ee93a1 + name: Templates/Roadwork/Devices + - uuid: 5ad0bd9e42a4487e869e9e41b38fe553 + name: Templates/Roadwork/DisplayLibrary + templates: + - uuid: 27474f627cb344b782a81c16d7e0c7d1 + template: BT_DEVICE_TRACKER + name: BT_DEVICE_TRACKER + vendor: + name: 'Hilbig IT GmbH' + version: 2.1.1 + templates: + - name: ROADWORK_DEVICE + groups: + - name: Templates/Roadwork/Devices + items: + - uuid: d4d3ec9f3ca940a39a721b6cfd2f3471 + name: location + type: DEPENDENT + key: location + history: 2d + value_type: TEXT + preprocessing: + - type: JAVASCRIPT + parameters: + - | + var obj=JSON.parse(value); + + if (obj["isFiltered"]) { + throw "Result is filtered"; + return "filtered"; + } + + return value; + - type: NOT_MATCHES_REGEX + parameters: + - filtered + error_handler: DISCARD_VALUE + master_item: + key: 'mqtt.trap[deviceValue/location]' + - uuid: 380c4a7d752848cba3b5a59a0f9b13c0 + name: MQTT_LOCATION + type: TRAP + key: 'mqtt.trap[deviceValue/location]' + history: '0' + value_type: TEXT + - uuid: 29faf53c033840c0b1405f8240e30312 + name: coords + type: DEPENDENT + key: state.current.values.coords + history: 2d + value_type: TEXT + preprocessing: + - type: JAVASCRIPT + parameters: + - | + var obj=JSON.parse(value); + var location = obj["location"]; + var coords = location["coords"]; + return JSON.stringify({ + "btDeviceKey": obj["btDeviceKey"], + "timestamp": location["timestamp"], + "deviceName": obj["deviceName"], + "latitude": coords[1], + "longitude": coords[0], + "coords": coords + }); + master_item: + key: location + tags: + - tag: hasValue + value: 'true' + - uuid: 1ae9486c18394e56b114c9cb4546deaf + name: geojson + type: DEPENDENT + key: state.current.values.geojson + history: 2d + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.location.setup + master_item: + key: location + tags: + - tag: hasValue + value: 'true' + tags: + - tag: class + value: roadwork + - tag: deviceType + value: bt_device_tracker_generic + - uuid: e6905dc6122944f3829ad28a9739e269 + template: BT_TRACKER + name: BT_TRACKER + vendor: + name: 'Hilbig IT GmbH' + version: 2.1.1 + templates: + - name: ROADWORK_DEVICE + groups: + - name: Templates/Roadwork/Devices + items: + - uuid: b1e3062d67f94f7c8d064eff36a58b13 + name: MQTT_STATE + type: DEPENDENT + key: currentstate + value_type: TEXT + preprocessing: + - type: JAVASCRIPT + parameters: + - | + var v = JSON.parse(value); + return JSON.stringify({ + "count": v.count, + "timeFrom": v.timeFrom, + "timeUntil": v.timeUntil + }); + master_item: + key: 'mqtt.trap[deviceValue/count]' + - uuid: 905c5f1b6e524bd2b227769a59f4df1b + name: MQTT_COUNT + type: TRAP + key: 'mqtt.trap[deviceValue/count]' + history: '0' + value_type: TEXT + - uuid: 6fa441872c3140f4adecf39956245603 + name: MQTT_DISTANCE + type: TRAP + key: 'mqtt.trap[deviceValue/distance]' + value_type: TEXT + - uuid: 69d2afa4a0324d818150e9473c3264f3 + name: MQTT_NAME + type: TRAP + key: 'mqtt.trap[deviceValue/name]' + value_type: TEXT + - uuid: 45ff9430d27f47a492c98fce03fc7962 + name: MQTT_SERVICE_DATA + type: TRAP + key: 'mqtt.trap[deviceValue/ServiceData]' + value_type: TEXT + - uuid: 3bf0d3017ea54e1da2a764c3f96bf97e + name: count + type: DEPENDENT + key: state.current.values.count + trends: '0' + preprocessing: + - type: JSONPATH + parameters: + - $.count + master_item: + key: 'mqtt.trap[deviceValue/count]' + - uuid: f0d1fc72e2154613b349be86c6bdcfd6 + name: timeFrom + type: DEPENDENT + key: state.current.values.timeFrom + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.timeFrom + - type: REGEX + parameters: + - 'T(\d\d:\d\d:\d\d):' + - \1 + master_item: + key: 'mqtt.trap[deviceValue/count]' + - uuid: e55bf604808f4eb4a964ebeefdd9eb9e + name: timeUntil + type: DEPENDENT + key: state.current.values.timeUntil + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.timeUntil + - type: REGEX + parameters: + - 'T(\d\d:\d\d:\d\d):' + - \1 + master_item: + key: 'mqtt.trap[deviceValue/count]' + tags: + - tag: class + value: roadwork + - tag: deviceType + value: bt_tracker_generic + - tag: deviceWidgetPreview.BOTTOM_LEFT.key + value: timeFrom + - tag: deviceWidgetPreview.BOTTOM_LEFT.unit + value: Startzeit + - tag: deviceWidgetPreview.BOTTOM_LEFT.unit_font_size + value: '8' + - tag: deviceWidgetPreview.BOTTOM_LEFT.value_font_size + value: '16' + - tag: deviceWidgetPreview.BOTTOM_RIGHT.key + value: timeUntil + - tag: deviceWidgetPreview.BOTTOM_RIGHT.unit + value: Endezeit + - tag: deviceWidgetPreview.BOTTOM_RIGHT.unit_font_size + value: '8' + - tag: deviceWidgetPreview.BOTTOM_RIGHT.value_font_size + value: '16' + - tag: deviceWidgetPreview.TOP_LEFT.key + value: count + - tag: deviceWidgetPreview.TOP_LEFT.unit + value: Geräte + - tag: deviceWidgetPreview.TOP_LEFT.unit_font_size + value: '8' + - tag: deviceWidgetPreview.TOP_LEFT.value_font_size + value: '24' + - uuid: 6490907a74964d0797c7acd1938bc553 + template: GEOLOCATION + name: GEOLOCATION + vendor: + name: 'Hilbig IT GmbH' + version: 2.1.1 + groups: + - name: Templates/Roadwork/Device-Capabilities + - name: Templates/Roadwork/Device-Capabilities/GEOLOCALIZABLE + items: + - uuid: 4ad4d9a769744615816d190c34cb49c7 + name: GPS_LOCATION_MQTT + type: TRAP + key: 'mqtt.trap[operationalValue/location]' + history: '0' + value_type: TEXT + description: 'old value: mqtt.get["rabbitmq","operationalValue/{$DEVICETYPE}/{HOST.HOST}/location","voltra_dev:voltradev","rabbit4voltra"]' + - uuid: 0e0012933b2345d4b119fdc50c526c73 + name: GPS_LOCATION + type: DEPENDENT + key: state.operational.location.json + history: 90d + value_type: TEXT + master_item: + key: 'mqtt.trap[operationalValue/location]' + tags: + - tag: attributeName + value: location + - tag: hasValue + value: 'true' + - tag: topicType + value: operationalValue + - uuid: e9dcf0279afc4ed4a23e274df4c98356 + name: LATITUDE + type: DEPENDENT + key: state.operational.location.latitude + history: 90d + value_type: FLOAT + inventory_link: LOCATION_LAT + preprocessing: + - type: JSONPATH + parameters: + - $.latitude + master_item: + key: state.operational.location.json + - uuid: 49d1677d3a4a4cfab23b2e8e50533833 + name: LONGITUDE + type: DEPENDENT + key: state.operational.location.longitude + history: 90d + value_type: FLOAT + inventory_link: LOCATION_LON + preprocessing: + - type: JSONPATH + parameters: + - $.longitude + master_item: + key: state.operational.location.json + - uuid: d7203eaab36749798014b6d3b1a43e24 + name: LOCATION_NAME + type: DEPENDENT + key: state.operational.location.name + history: 90d + value_type: TEXT + inventory_link: LOCATION + preprocessing: + - type: JAVASCRIPT + parameters: + - | + var json = JSON.parse(value); + var lat = "lat=" + json.latitude; + var lon = "lon=" + json.longitude; + + return lon + ", " + lat; + var geocoderURL = "https://photon.komoot.io/reverse?" + lon + "&" + lat; + + var req = new HttpRequest(); + + req.addHeader('Content-Type: application/json'); + + resp = req.get(geocoderURL); + + if (req.getStatus() != 200) { + throw 'Response code: '+req.getStatus(); + } + + var resultJson = JSON.parse(resp); + + var features = resultJson.features; + if (features.length && features[0].properties) { + var props = features[0].properties; + return props.postcode + " " + props.city; + } + return value; + master_item: + key: state.operational.location.json + tags: + - tag: class + value: roadwork + macros: + - macro: '{$DEVICEKEY}' + value: '{HOST.HOST}' + - uuid: fe8bac9ac30f411cb5a322817760e71d + template: OPERATIONAL_DATA + name: OPERATIONAL_DATA + vendor: + name: 'Hilbig IT GmbH' + version: 2.1.1 + groups: + - name: Templates/Roadwork/Device-Capabilities + - name: Templates/Roadwork/Device-Capabilities/HAS_OPERATIONAL_DATA + items: + - uuid: 602290e9f42f4135b548e1cd45abe135 + name: DENSITY_MQTT + type: TRAP + key: 'mqtt.trap[operationalValue/density]' + history: '0' + value_type: TEXT + - uuid: 87e0a14266984247b81fdc757dea5bde + name: ERROR_MQTT + type: TRAP + key: 'mqtt.trap[operationalValue/error]' + history: '0' + value_type: TEXT + - uuid: 644b0ec2e3d9448da1a69561ec10d19d + name: SIGNALSTRENGTH_MQTT + type: TRAP + key: 'mqtt.trap[operationalValue/signalstrength]' + history: '0' + value_type: TEXT + - uuid: 67c01d7334a24823832bba74073cf356 + name: TEMPERATURE_MQTT + type: TRAP + key: 'mqtt.trap[operationalValue/temperature]' + history: '0' + value_type: TEXT + tags: + - tag: attributeName + value: temperature + - tag: GRAPHQL_TYPE + value: Int + - tag: hasValue + value: 'true' + - tag: subscribeMqtt + value: 'true' + - tag: topicType + value: operationalValue + - uuid: 0352c80c749d4d91b386dab9c74ef3c6 + name: VOLTAGE_MQTT + type: TRAP + key: 'mqtt.trap[operationalValue/voltage]' + history: '0' + value_type: TEXT + - uuid: 7aac8212c94044d28ada982c422f2bf7 + name: DENSITY + type: DEPENDENT + key: state.operational.density + units: Kfz/Min + preprocessing: + - type: JSONPATH + parameters: + - $.density + master_item: + key: 'mqtt.trap[operationalValue/density]' + tags: + - tag: attributeName + value: density + - tag: hasValue + value: 'true' + - tag: topicType + value: operationalValue + - uuid: 6c8c2f4cdc304b019d02026e7c3225ce + name: ERROR_HIGH + type: DEPENDENT + key: state.operational.errorHigh + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - '$[?(@.code>=1000)]' + error_handler: CUSTOM_VALUE + master_item: + key: state.operational.json_error + triggers: + - uuid: bc381851c3d84534866f3262828817a9 + expression: 'last(/OPERATIONAL_DATA/state.operational.errorHigh)<>""' + name: DEVICE_ERROR_HIGH + event_name: DEVICE_ERROR_HIGH + priority: HIGH + manual_close: 'YES' + - uuid: 7634372683af42e7bb807bd2ee0e600a + name: ERROR_INFO + type: DEPENDENT + key: state.operational.errorInfo + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - '$[?(@.code=0)]' + error_handler: CUSTOM_VALUE + master_item: + key: state.operational.json_error + triggers: + - uuid: 201be3f4fd484651a0d2bb34586b408b + expression: 'last(/OPERATIONAL_DATA/state.operational.errorInfo)<>""' + name: DEVICE_ERROR_INFO + event_name: DEVICE_ERROR_INFO + priority: INFO + manual_close: 'YES' + - uuid: 58beaf63a07c44bd918f3f7c93be6d16 + name: ERROR_WARNING + type: DEPENDENT + key: state.operational.errorWarning + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - '$[?(@.code>0&&@.code<1000)]' + error_handler: CUSTOM_VALUE + master_item: + key: state.operational.json_error + triggers: + - uuid: 208ef0feec0b469c8b3a8edc3ef12680 + expression: 'last(/OPERATIONAL_DATA/state.operational.errorWarning)<>""' + name: DEVICE_ERROR_WARNING + event_name: DEVICE_ERROR_WARNING + priority: WARNING + manual_close: 'YES' + - uuid: cf7b08cec47a46ddb7ea110feab42c94 + name: ERROR + type: DEPENDENT + key: state.operational.json_error + history: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.error + master_item: + key: 'mqtt.trap[operationalValue/error]' + tags: + - tag: attributeName + value: error + - tag: hasValue + value: 'true' + - tag: topicType + value: operationalValue + - uuid: a711e858297e4c80a61952b9848dd217 + name: SIGNALSTRENGTH + type: DEPENDENT + key: state.operational.signalstrength + history: 90d + value_type: FLOAT + trends: '0' + units: dBm + preprocessing: + - type: JSONPATH + parameters: + - $.signalstrength + master_item: + key: 'mqtt.trap[operationalValue/signalstrength]' + tags: + - tag: attributeName + value: signalstrength + - tag: hasValue + value: 'true' + triggers: + - uuid: 610d7f2cd5db4dc8938a61ffe81eb8e3 + expression: 'nodata(/OPERATIONAL_DATA/state.operational.signalstrength,70s)=1' + name: NO_OPERATIONAL_VALUE + priority: WARNING + - uuid: 93df1a6ad1d640c883f446c85a220bd3 + name: TEMPERATURE + type: DEPENDENT + key: state.operational.temperature + history: 90d + value_type: FLOAT + trends: '0' + units: °C + preprocessing: + - type: JSONPATH + parameters: + - $.temperature + master_item: + key: 'mqtt.trap[operationalValue/temperature]' + tags: + - tag: attributeName + value: temperature + - tag: hasValue + value: 'true' + triggers: + - uuid: 8bc17b97d7fe4e4f9ac236e90d0b315d + expression: 'last(/OPERATIONAL_DATA/state.operational.temperature,#3)>60' + recovery_mode: RECOVERY_EXPRESSION + recovery_expression: 'last(/OPERATIONAL_DATA/state.operational.temperature,#3)<=60' + name: TEMPERATURE_HIGH + event_name: TEMPERATURE_HIGH + priority: HIGH + dependencies: + - name: TEMPERATURE_WARNING + expression: 'last(/OPERATIONAL_DATA/state.operational.temperature,#3)>50' + recovery_expression: 'last(/OPERATIONAL_DATA/state.operational.temperature,#3)<=50' + - uuid: 086f026b15e5410793d0604b4c7d51f6 + expression: 'last(/OPERATIONAL_DATA/state.operational.temperature,#3)>50' + recovery_mode: RECOVERY_EXPRESSION + recovery_expression: 'last(/OPERATIONAL_DATA/state.operational.temperature,#3)<=50' + name: TEMPERATURE_WARNING + event_name: TEMPERATURE_WARNING + priority: AVERAGE + - uuid: cbc60c96e65d4111b902e3b133681067 + name: TIMESTAMP + type: CALCULATED + key: state.operational.timestamp + value_type: TEXT + params: 'max(last(/{HOST.HOST}/state.operational.timestampSignalstrength),last(/{HOST.HOST}/state.operational.timestampVoltage))' + inventory_link: POC_2_NOTES + - uuid: ca1a1397f1aa458b88e531f699cacfeb + name: TIMESTAMP_SIGNALSTRENGTH + type: DEPENDENT + key: state.operational.timestampSignalstrength + history: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.timestamp + master_item: + key: 'mqtt.trap[operationalValue/signalstrength]' + tags: + - tag: attributeName + value: timestamp + - tag: hasValue + value: 'true' + - tag: subscribeMqtt + value: 'false' + - uuid: 919a09b55b304fc391bcd569f444b979 + name: TIMESTAMP_VOLTAGE + type: DEPENDENT + key: state.operational.timestampVoltage + history: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.timestamp + master_item: + key: 'mqtt.trap[operationalValue/voltage]' + tags: + - tag: attributeName + value: timestamp + - tag: hasValue + value: 'true' + - tag: subscribeMqtt + value: 'false' + - uuid: 3363ed4409b545b48ae7c6197a56aae2 + name: VOLTAGE + type: DEPENDENT + key: state.operational.voltage + history: 90d + value_type: FLOAT + trends: '0' + units: V + preprocessing: + - type: JSONPATH + parameters: + - $.voltage + master_item: + key: 'mqtt.trap[operationalValue/voltage]' + tags: + - tag: attributeName + value: voltage + - tag: hasValue + value: 'true' + - tag: topicType + value: operationalValue + triggers: + - uuid: b411e1c0527a470184cac731c072fec2 + expression: 'last(/OPERATIONAL_DATA/state.operational.voltage,#3)<=12' + recovery_mode: RECOVERY_EXPRESSION + recovery_expression: 'last(/OPERATIONAL_DATA/state.operational.voltage,#3)>=12' + name: VOLTAGE_LOW + event_name: VOLTAGE_LOW + priority: WARNING + description: | + warning if voltage < 24 of last 3 values + recovery if voltage >=24 of last 3 values + - uuid: f5c351920d544fc6abcf00fd9b26f51b + expression: 'last(/OPERATIONAL_DATA/state.operational.voltage,#3)<=11' + recovery_mode: RECOVERY_EXPRESSION + recovery_expression: 'last(/OPERATIONAL_DATA/state.operational.voltage,#3)>=11' + name: VOLTAGE_TOO_LOW + priority: DISASTER + tags: + - tag: class + value: roadwork + macros: + - macro: '{$DEVICEKEY}' + value: '{HOST.HOST}' + - uuid: 2b814a4751b745bcb08b5ee98f295dc9 + template: ROADWORK_DEVICE + name: ROADWORK_DEVICE + vendor: + name: 'Hilbig IT GmbH' + version: 2.1.1 + groups: + - name: Templates/Roadwork/Devices + tags: + - tag: class + value: roadwork + - tag: hostType + value: Roadwork/Devices + - uuid: c53977cb7c6a45b18700502fd841db56 + template: TEMPLATEGROUP_EXPORT_DUMMY_TEMPLATE + name: TEMPLATEGROUP_EXPORT_DUMMY_TEMPLATE + description: 'This template is linked to all template group which shall be part of the export templates - action because empty template groups will not be exported' + vendor: + name: 'Hilbig IT GmbH' + version: 2.1.1 + groups: + - name: Permissions/Automatism + - name: Permissions/Automatism/Status + - name: Permissions/ConstructionSite + - name: Permissions/Library + - name: Permissions/Library/Item + - name: Templates/Roadwork/Controller + - name: Templates/Roadwork/Device-Capabilities + - name: Templates/Roadwork/Device-Capabilities/FLASH_ATTACHABLE + - name: Templates/Roadwork/Device-Capabilities/GEOLOCALIZABLE + - name: Templates/Roadwork/Device-Capabilities/HAS_OPERATIONAL_DATA + - name: Templates/Roadwork/Devices + - name: Templates/Roadwork/DisplayLibrary + tags: + - tag: class + value: roadwork + graphs: + - uuid: 2c8b16c6937a4d07912b6485ac8a1339 + name: 'Mac address count' + graph_items: + - drawtype: GRADIENT_LINE + color: 1A7C11 + calc_fnc: ALL + item: + host: BT_TRACKER + key: state.current.values.count