diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index c161fad..4fc0c1d 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -5,17 +5,12 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
@@ -26,7 +21,7 @@
-
+
@@ -215,7 +210,8 @@
-
+
+
diff --git a/.junie/guidelines.md b/.junie/guidelines.md
index b2940a4..3ecfdac 100644
--- a/.junie/guidelines.md
+++ b/.junie/guidelines.md
@@ -28,14 +28,15 @@ The [Roadmap](../roadmap.md) is to be considered as outlook giving constraints o
## Common Scripts
- `npm run start`: Launches the development server with `tsx` and `nodemon` for hot-reloading.
- `npm run test`: Executes the Jest test suite.
-- `npm run codegen`: Generates TypeScript types based on the GraphQL schema definitions.
+- `npm run codegen`: Starts GraphQL Codegen in watch mode (for continuous development).
+- `npx graphql-codegen --config codegen.ts`: Generates TypeScript types once (use this for one-off updates).
- `npm run compile`: Compiles TypeScript source files into the `dist/` directory.
- `npm run prod`: Prepares the schema and runs the compiled production build.
## Best Practices & Standards
- **ESM & Imports**: The project uses ECMAScript Modules (ESM). Always use the `.js` extension when importing local files (e.g. `import { Config } from "../common_utils.js";`), even though the source files are `.ts`.
- **Configuration**: Always use the `Config` class to access environment variables. Avoid direct `process.env` calls.
-- **Type Safety**: Leverage types generated via `npm run codegen` for resolvers and data handling to ensure consistency with the schema.
+- **Type Safety**: Leverage types generated via `npx graphql-codegen --config codegen.ts` (or `npm run codegen` for watch mode) for resolvers and data handling to ensure consistency with the schema.
- **Import Optimization**:
- Always optimize imports before committing.
- Project setting `OPTIMIZE_IMPORTS_BEFORE_PROJECT_COMMIT` is enabled.
@@ -44,6 +45,14 @@ The [Roadmap](../roadmap.md) is to be considered as outlook giving constraints o
- **Testing**: Write reproduction tests for bugs and cover new features with both unit and integration tests in `src/test/`.
- **Grammar & Style**: Avoid using a comma after "e.g." or "i.e." (e.g. use "e.g. example" instead of "e.g., example").
+## Verification & Deployment
+- **Pre-commit Verification**: Always add a verification stage to your plan before committing.
+ - *Action*: Run the `Smoketest` tool using MCP to ensure basic functionality is intact.
+ - *Action*: Monitor the API logs for errors after each service restart.
+- **Environment Restart**: Always include a step to rebuild and restart the API and MCP server as a final check.
+ - *Command*: `docker compose up -d --build`
+ - *Requirement*: Ask the user if everything looks okay before executing the restart, and offer the option to skip this step.
+
### Documentation Style
- **Bullet Points**: Use bullet points instead of enumerations for lists to maintain consistency across all documentation.
- **Visual Style**: Use icons in headers and bold subjects for primary list items (e.g. `- **Feature**: Description`) to match the `README.md` style.
diff --git a/docs/howtos/cookbook.md b/docs/howtos/cookbook.md
index b3d12e1..a35a97d 100644
--- a/docs/howtos/cookbook.md
+++ b/docs/howtos/cookbook.md
@@ -140,14 +140,15 @@ Execute the `importTemplates` mutation to create the template and items automati
Verify that the new type is available and correctly mapped by creating a test host and querying it.
#### 1. Create a Test Host
-Use the `importHosts` mutation (or `createHost` if IDs are already known) to create a host and explicitly set its `deviceType` to `DistanceTrackerDevice`.
+Use the `importHosts` mutation (or `createHost` if IDs are already known) to create a host. Set its `deviceType` to `DistanceTrackerDevice` and link it to the `DISTANCE_TRACKER` template (created in Step 3) using the `templateNames` parameter.
```graphql
-mutation CreateTestDistanceTracker($host: String!, $groupNames: [String!]!) {
+mutation CreateTestDistanceTracker($host: String!, $groupNames: [String!]!, $templateNames: [String]) {
importHosts(hosts: [{
deviceKey: $host,
deviceType: "DistanceTrackerDevice",
- groupNames: $groupNames
+ groupNames: $groupNames,
+ templateNames: $templateNames
}]) {
hostid
message
@@ -209,8 +210,8 @@ For more details on the input fields, see the [Reference: createHost](../../sche
AI agents should prefer using the `importHosts` MCP tool for provisioning as it allows using names for host groups instead of IDs.
```graphql
-mutation CreateNewHost($host: String!, $groups: [Int!]!, $templates: [Int!]!) {
- createHost(host: $host, hostgroupids: $groups, templateids: $templates) {
+mutation CreateNewHost($host: String!, $groups: [Int!]!, $templates: [Int], $templateNames: [String]) {
+ createHost(host: $host, hostgroupids: $groups, templateids: $templates, templateNames: $templateNames) {
hostids
error {
message
diff --git a/docs/howtos/maintenance.md b/docs/howtos/maintenance.md
index 5dcbfd3..b9c65b1 100644
--- a/docs/howtos/maintenance.md
+++ b/docs/howtos/maintenance.md
@@ -12,8 +12,14 @@ The project uses [GraphQL Codegen](https://the-guild.dev/graphql/codegen) to gen
- **Generated Output**: `src/schema/generated/graphql.ts`
#### How to Regenerate Types
-Whenever you modify any `.graphql` files in the `schema/` directory, you must regenerate the TypeScript types:
+Whenever you modify any `.graphql` files in the `schema/` directory, you must regenerate the TypeScript types.
+For a one-off update (e.g. in a script or before commit):
+```bash
+npx graphql-codegen --config codegen.ts
+```
+
+If you are a developer and want to watch for schema changes continuously:
```bash
npm run codegen
```
diff --git a/mcp/operations/createHost.graphql b/mcp/operations/createHost.graphql
index a01c372..c77961e 100644
--- a/mcp/operations/createHost.graphql
+++ b/mcp/operations/createHost.graphql
@@ -1,5 +1,5 @@
-mutation CreateHost($host: String!, $hostgroupids: [Int!]!, $templateids: [Int!]!) {
- createHost(host: $host, hostgroupids: $hostgroupids, templateids: $templateids) {
+mutation CreateHost($host: String!, $hostgroupids: [Int!]!, $templateids: [Int], $templateNames: [String]) {
+ createHost(host: $host, hostgroupids: $hostgroupids, templateids: $templateids, templateNames: $templateNames) {
hostids
error {
message
diff --git a/mcp/operations/createVerificationHost.graphql b/mcp/operations/createVerificationHost.graphql
index e583b91..5c1beb8 100644
--- a/mcp/operations/createVerificationHost.graphql
+++ b/mcp/operations/createVerificationHost.graphql
@@ -1,8 +1,9 @@
-mutation CreateVerificationHost($deviceKey: String!, $deviceType: String!, $groupNames: [String!]!) {
+mutation CreateVerificationHost($deviceKey: String!, $deviceType: String!, $groupNames: [String!]!, $templateNames: [String]) {
importHosts(hosts: [{
deviceKey: $deviceKey,
deviceType: $deviceType,
- groupNames: $groupNames
+ groupNames: $groupNames,
+ templateNames: $templateNames
}]) {
hostid
message
diff --git a/mcp/operations/importHosts.graphql b/mcp/operations/importHosts.graphql
index 4dba3df..c02177a 100644
--- a/mcp/operations/importHosts.graphql
+++ b/mcp/operations/importHosts.graphql
@@ -1,5 +1,6 @@
# Import multiple hosts/devices into Zabbix.
# This is a powerful tool for bulk provisioning of hosts using their names and types.
+# It supports linking templates by ID (templateids) or by name (templateNames).
mutation ImportHosts($hosts: [CreateHost!]!) {
importHosts(hosts: $hosts) {
hostid
diff --git a/mcp/operations/runSmoketest.graphql b/mcp/operations/runSmoketest.graphql
new file mode 100644
index 0000000..eeb3d48
--- /dev/null
+++ b/mcp/operations/runSmoketest.graphql
@@ -0,0 +1,14 @@
+# Run a complete smoketest: creates a template, host group, and host,
+# verifies their creation and linkage, and then cleans up everything.
+# Variables: hostName, templateName, groupName
+mutation RunSmoketest($hostName: String!, $templateName: String!, $groupName: String!) {
+ runSmoketest(hostName: $hostName, templateName: $templateName, groupName: $groupName) {
+ success
+ message
+ steps {
+ name
+ success
+ message
+ }
+ }
+}
diff --git a/schema/mutations.graphql b/schema/mutations.graphql
index 1a41ffb..9cd1019 100644
--- a/schema/mutations.graphql
+++ b/schema/mutations.graphql
@@ -11,7 +11,9 @@ type Mutation {
"""List of host group IDs to assign the host to."""
hostgroupids:[Int!]!,
"""List of template IDs to link to the host."""
- templateids: [Int!]!,
+ templateids: [Int],
+ """List of template names to link to the host."""
+ templateNames: [String],
"""Optional location information for the host inventory."""
location: LocationInput
): CreateHostResponse
@@ -100,6 +102,78 @@ type Mutation {
"""Wildcard name pattern for template groups to delete."""
name_pattern: String
): [DeleteResponse!]
+
+ """
+ Delete hosts by their IDs or by a name pattern.
+
+ Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
+ """
+ deleteHosts(
+ """List of host IDs to delete."""
+ hostids: [Int!],
+ """Wildcard name pattern for hosts to delete."""
+ name_pattern: String
+ ): [DeleteResponse!]
+
+ """
+ Delete host groups by their IDs or by a name pattern.
+
+ Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
+ """
+ deleteHostGroups(
+ """List of host group IDs to delete."""
+ groupids: [Int!],
+ """Wildcard name pattern for host groups to delete."""
+ name_pattern: String
+ ): [DeleteResponse!]
+
+ """
+ Runs a smoketest: creates a template, links a host, verifies it, and cleans up.
+ """
+ runSmoketest(
+ """Technical name for the smoketest host."""
+ hostName: String!,
+ """Technical name for the smoketest template."""
+ templateName: String!,
+ """Technical name for the smoketest host group."""
+ groupName: String!
+ ): SmoketestResponse!
+}
+
+"""
+Response object for the smoketest operation.
+"""
+type SmoketestResponse {
+ """
+ True if all steps of the smoketest succeeded.
+ """
+ success: Boolean!
+ """
+ Overall status message.
+ """
+ message: String
+ """
+ Detailed results for each step.
+ """
+ steps: [SmoketestStep!]!
+}
+
+"""
+Results for a single step in the smoketest.
+"""
+type SmoketestStep {
+ """
+ Name of the step (e.g. 'Create Template').
+ """
+ name: String!
+ """
+ True if the step succeeded.
+ """
+ success: Boolean!
+ """
+ Status message or error message for the step.
+ """
+ message: String
}
####################################################################
@@ -413,6 +487,14 @@ input CreateHost {
"""
groupids: [Int]
"""
+ List of template IDs to link to the host.
+ """
+ templateids: [Int]
+ """
+ List of template names to link to the host.
+ """
+ templateNames: [String]
+ """
Location information for the host.
"""
location: LocationInput
diff --git a/src/api/resolvers.ts b/src/api/resolvers.ts
index ed83dc2..3c8df7a 100644
--- a/src/api/resolvers.ts
+++ b/src/api/resolvers.ts
@@ -25,6 +25,8 @@ import {
} from "../schema/generated/graphql.js";
import {HostImporter} from "../execution/host_importer.js";
+import {HostDeleter} from "../execution/host_deleter.js";
+import {SmoketestExecutor} from "../execution/smoketest_executor.js";
import {TemplateImporter} from "../execution/template_importer.js";
import {TemplateDeleter} from "../execution/template_deleter.js";
import {HostValueExporter} from "../execution/host_exporter.js";
@@ -48,7 +50,11 @@ import {
ZabbixImportUserRolesRequest,
ZabbixQueryUserRolesRequest
} from "../datasources/zabbix-userroles.js";
-import {ZabbixQueryTemplateGroupRequest, ZabbixQueryTemplatesRequest} from "../datasources/zabbix-templates.js";
+import {
+ TemplateHelper,
+ ZabbixQueryTemplateGroupRequest,
+ ZabbixQueryTemplatesRequest
+} from "../datasources/zabbix-templates.js";
import {zabbixAPI} from "../datasources/zabbix-api.js";
import {GraphQLInterfaceType, GraphQLList} from "graphql/type/index.js";
import {isDevice} from "./resolver_helpers.js";
@@ -182,6 +188,17 @@ export function createResolvers(): Resolvers {
zabbixAuthToken,
cookie
}: any) => {
+ if (args.templateNames?.length) {
+ const templateidsByName = await TemplateHelper.findTemplateIdsByName(args.templateNames as string[], zabbixAPI, zabbixAuthToken, cookie);
+ if (!templateidsByName) {
+ return {
+ error: {
+ message: `Unable to find templates: ${args.templateNames}`
+ }
+ }
+ }
+ args.templateids = (args.templateids || []).concat(templateidsByName);
+ }
return await new ZabbixCreateHostRequest(zabbixAuthToken, cookie).executeRequestThrowError(
zabbixAPI,
new ParsedArgs(args)
@@ -241,6 +258,24 @@ export function createResolvers(): Resolvers {
cookie
}: any) => {
return TemplateDeleter.deleteTemplateGroups(args.groupids, args.name_pattern, zabbixAuthToken, cookie)
+ },
+ deleteHosts: async (_parent: any, args: any, {
+ zabbixAuthToken,
+ cookie
+ }: any) => {
+ return HostDeleter.deleteHosts(args.hostids, args.name_pattern, zabbixAuthToken, cookie)
+ },
+ deleteHostGroups: async (_parent: any, args: any, {
+ zabbixAuthToken,
+ cookie
+ }: any) => {
+ return HostDeleter.deleteHostGroups(args.groupids, args.name_pattern, zabbixAuthToken, cookie)
+ },
+ runSmoketest: async (_parent: any, args: any, {
+ zabbixAuthToken,
+ cookie
+ }: any) => {
+ return SmoketestExecutor.runSmoketest(args.hostName, args.templateName, args.groupName, zabbixAuthToken, cookie)
}
},
diff --git a/src/datasources/zabbix-hostgroups.ts b/src/datasources/zabbix-hostgroups.ts
index 2881db9..67a3d22 100644
--- a/src/datasources/zabbix-hostgroups.ts
+++ b/src/datasources/zabbix-hostgroups.ts
@@ -69,6 +69,11 @@ export class ZabbixQueryHostgroupsRequest extends ZabbixRequestWithPermissions {
+ constructor(authToken?: string | null, cookie?: string | null) {
+ super("hostgroup.delete", authToken, cookie, hostGroupReadWritePermissions);
+ }
+}
export class GroupHelper {
public static groupFullName(groupName: string) {
diff --git a/src/datasources/zabbix-hosts.ts b/src/datasources/zabbix-hosts.ts
index f0874d0..ea2ad4b 100644
--- a/src/datasources/zabbix-hosts.ts
+++ b/src/datasources/zabbix-hosts.ts
@@ -228,3 +228,9 @@ export class ZabbixCreateHostRequest extends ZabbixRequest {
return args?.zabbix_params || {};
}
}
+
+export class ZabbixDeleteHostsRequest extends ZabbixRequest<{ hostids: string[] }> {
+ constructor(authToken?: string | null, cookie?: string | null) {
+ super("host.delete", authToken, cookie);
+ }
+}
diff --git a/src/datasources/zabbix-templates.ts b/src/datasources/zabbix-templates.ts
index 826158a..1708f3e 100644
--- a/src/datasources/zabbix-templates.ts
+++ b/src/datasources/zabbix-templates.ts
@@ -1,4 +1,6 @@
-import {ZabbixRequest} from "./zabbix-request.js";
+import {ZabbixRequest, ParsedArgs, isZabbixErrorResult} from "./zabbix-request.js";
+import {ZabbixAPI} from "./zabbix-api.js";
+import {logger} from "../logging/logger.js";
export interface ZabbixQueryTemplateResponse {
@@ -65,3 +67,22 @@ export class ZabbixDeleteTemplateGroupsRequest extends ZabbixRequest<{ groupids:
}
+export class TemplateHelper {
+ public static async findTemplateIdsByName(templateNames: string[], zabbixApi: ZabbixAPI, zabbixAuthToken?: string, cookie?: string) {
+ let result: number[] = []
+ for (let templateName of templateNames) {
+ let templates = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixApi, new ParsedArgs({
+ filter_name: templateName
+ }))
+
+ if (isZabbixErrorResult(templates) || !templates?.length) {
+ logger.error(`Unable to find templateName=${templateName}`)
+ return null
+ }
+ result.push(...templates.map((t) => Number(t.templateid)))
+ }
+ return result
+ }
+}
+
+
diff --git a/src/execution/host_deleter.ts b/src/execution/host_deleter.ts
new file mode 100644
index 0000000..7288189
--- /dev/null
+++ b/src/execution/host_deleter.ts
@@ -0,0 +1,110 @@
+import {DeleteResponse} from "../schema/generated/graphql.js";
+import {
+ ZabbixDeleteHostsRequest,
+ ZabbixQueryHostsGenericRequest,
+} from "../datasources/zabbix-hosts.js";
+import {
+ ZabbixDeleteHostGroupsRequest,
+ ZabbixQueryHostgroupsRequest,
+ ZabbixQueryHostgroupsParams,
+ GroupHelper
+} from "../datasources/zabbix-hostgroups.js";
+import {isZabbixErrorResult, ParsedArgs} from "../datasources/zabbix-request.js";
+import {zabbixAPI} from "../datasources/zabbix-api.js";
+
+export class HostDeleter {
+
+ public static async deleteHosts(hostids: number[] | null | undefined, name_pattern?: string | null, zabbixAuthToken?: string, cookie?: string): Promise {
+ const result: DeleteResponse[] = [];
+ let idsToDelete = hostids ? [...hostids] : [];
+
+ if (name_pattern) {
+ const queryResult = await new ZabbixQueryHostsGenericRequest("host.get", zabbixAuthToken, cookie)
+ .executeRequestReturnError(zabbixAPI, new ParsedArgs({ name_pattern: name_pattern }));
+
+ if (!isZabbixErrorResult(queryResult) && Array.isArray(queryResult)) {
+ const foundIds = queryResult.map((t: any) => Number(t.hostid));
+ // Merge and deduplicate
+ idsToDelete = Array.from(new Set([...idsToDelete, ...foundIds]));
+ }
+ }
+
+ if (idsToDelete.length === 0) {
+ return [];
+ }
+
+ const deleteResult = await new ZabbixDeleteHostsRequest(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?.hostids) {
+ for (const id of idsToDelete) {
+ result.push({
+ id: id,
+ message: `Host ${id} deleted successfully`
+ });
+ }
+ }
+
+ return result;
+ }
+
+ public static async deleteHostGroups(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 ZabbixQueryHostgroupsRequest(zabbixAuthToken, cookie)
+ .executeRequestReturnError(zabbixAPI, new ZabbixQueryHostgroupsParams({
+ filter_name: GroupHelper.groupFullName(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 ZabbixDeleteHostGroupsRequest(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: `Host group ${id} deleted successfully`
+ });
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/src/execution/host_importer.ts b/src/execution/host_importer.ts
index cee84e6..94f643e 100644
--- a/src/execution/host_importer.ts
+++ b/src/execution/host_importer.ts
@@ -6,7 +6,8 @@ import {
InputMaybe
} from "../schema/generated/graphql.js";
import {logger} from "../logging/logger.js";
-import {ZabbixQueryTemplatesRequest} from "../datasources/zabbix-templates.js";
+import {ZabbixCreateHostRequest} from "../datasources/zabbix-hosts.js";
+import {ZabbixQueryTemplatesRequest, TemplateHelper} from "../datasources/zabbix-templates.js";
import {isZabbixErrorResult, ParsedArgs, ZabbixErrorResult} from "../datasources/zabbix-request.js";
import {CreateHostGroupResult, GroupHelper, ZabbixCreateHostGroupRequest} from "../datasources/zabbix-hostgroups.js";
import {ZABBIX_EDGE_DEVICE_BASE_GROUP, zabbixAPI} from "../datasources/zabbix-api.js";
@@ -110,32 +111,49 @@ export class HostImporter {
break
}
}
- let deviceImportResult: {
- hostids?: string[];
- error?: any;
- } = await zabbixAPI.requestByPath("host.create", new ParsedArgs(
+
+ let templateids = device.templateids ? [...device.templateids as number[]] : [];
+ if (device.templateNames?.length) {
+ const resolvedTemplateids = await TemplateHelper.findTemplateIdsByName(device.templateNames as string[], zabbixAPI, zabbixAuthToken, cookie);
+ if (resolvedTemplateids) {
+ templateids.push(...resolvedTemplateids);
+ } else {
+ result.push({
+ deviceKey: device.deviceKey,
+ message: `Unable to find templates: ${device.templateNames}`
+ });
+ continue;
+ }
+ }
+
+ if (templateids.length === 0) {
+ const defaultTemplateId = await HostImporter.getTemplateIdForDeviceType(device.deviceType, zabbixAuthToken, cookie);
+ if (defaultTemplateId) {
+ templateids.push(defaultTemplateId);
+ }
+ }
+
+ let deviceImportResult = await new ZabbixCreateHostRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs(
{
host: device.deviceKey,
name: device.name,
location: device.location,
- templateids: [
- await HostImporter.getTemplateIdForDeviceType(
- device.deviceType, zabbixAuthToken, cookie)],
+ templateids: templateids,
hostgroupids: groupids
}
- ), zabbixAuthToken, cookie)
- if (deviceImportResult?.hostids?.length) {
- result.push({
- deviceKey: device.deviceKey,
- hostid: deviceImportResult.hostids[0],
- })
- } else {
+ ))
+
+ if (isZabbixErrorResult(deviceImportResult)) {
result.push({
deviceKey: device.deviceKey,
message: `Unable to import deviceKey=${device.deviceKey}: ${deviceImportResult.error.message}`,
error: deviceImportResult.error
})
-
+ } else {
+ result.push({
+ deviceKey: device.deviceKey,
+ hostid: deviceImportResult.hostids![0]?.toString(),
+ })
}
}
diff --git a/src/execution/smoketest_executor.ts b/src/execution/smoketest_executor.ts
new file mode 100644
index 0000000..497789f
--- /dev/null
+++ b/src/execution/smoketest_executor.ts
@@ -0,0 +1,158 @@
+import {SmoketestResponse, SmoketestStep} from "../schema/generated/graphql.js";
+import {TemplateImporter} from "./template_importer.js";
+import {HostImporter} from "./host_importer.js";
+import {HostDeleter} from "./host_deleter.js";
+import {TemplateDeleter} from "./template_deleter.js";
+import {zabbixAPI} from "../datasources/zabbix-api.js";
+import {ZabbixQueryHostsGenericRequest} from "../datasources/zabbix-hosts.js";
+import {ParsedArgs} from "../datasources/zabbix-request.js";
+
+export class SmoketestExecutor {
+ public static async runSmoketest(hostName: string, templateName: string, groupName: string, zabbixAuthToken?: string, cookie?: string): Promise {
+ const steps: SmoketestStep[] = [];
+ let success = true;
+
+ try {
+ // Step 0: Create Template Group
+ const templateGroupResult = await TemplateImporter.importTemplateGroups([{
+ groupName: groupName
+ }], zabbixAuthToken, cookie);
+ const templateGroupSuccess = !!templateGroupResult?.length && !templateGroupResult[0].error;
+ steps.push({
+ name: "Create Template Group",
+ success: templateGroupSuccess,
+ message: templateGroupSuccess ? `Template group ${groupName} created` : `Failed: ${templateGroupResult?.[0]?.error?.message || "Unknown error"}`
+ });
+ if (!templateGroupSuccess) success = false;
+
+ // Step 1: Create Template
+ if (success) {
+ const templateResult = await TemplateImporter.importTemplates([{
+ host: templateName,
+ name: templateName,
+ groupNames: [groupName]
+ }], zabbixAuthToken, cookie);
+
+ const templateSuccess = !!templateResult?.length && !templateResult[0].error;
+ steps.push({
+ name: "Create Template",
+ success: templateSuccess,
+ message: templateSuccess ? `Template ${templateName} created` : `Failed: ${templateResult?.[0]?.error?.message || "Unknown error"}`
+ });
+ if (!templateSuccess) success = false;
+ } else {
+ steps.push({ name: "Create Template", success: false, message: "Skipped due to previous failures" });
+ }
+
+ // Step 2: Create Host Group
+ const groupResult = await HostImporter.importHostGroups([{
+ groupName: groupName
+ }], zabbixAuthToken, cookie);
+
+ const groupSuccess = !!groupResult?.length && !groupResult[0].error;
+ steps.push({
+ name: "Create Host Group",
+ success: groupSuccess,
+ message: groupSuccess ? `Host group ${groupName} created` : `Failed: ${groupResult?.[0]?.error?.message || "Unknown error"}`
+ });
+ if (!groupSuccess) success = false;
+
+ // Step 3: Create Host and Link to Template
+ if (success) {
+ const hostResult = await HostImporter.importHosts([{
+ deviceKey: hostName,
+ deviceType: "ZabbixHost",
+ groupNames: [groupName],
+ templateNames: [templateName]
+ }], zabbixAuthToken, cookie);
+
+ const hostSuccess = !!hostResult?.length && !hostResult[0].error;
+ steps.push({
+ name: "Create and Link Host",
+ success: hostSuccess,
+ message: hostSuccess ? `Host ${hostName} created and linked to ${templateName}` : `Failed: ${hostResult?.[0]?.error?.message || "Unknown error"}`
+ });
+ if (!hostSuccess) success = false;
+ } else {
+ steps.push({ name: "Create and Link Host", success: false, message: "Skipped due to previous failures" });
+ }
+
+ // Step 4: Verify Host Linkage
+ if (success) {
+ const verifyResult = await new ZabbixQueryHostsGenericRequest("host.get", zabbixAuthToken, cookie)
+ .executeRequestReturnError(zabbixAPI, new ParsedArgs({
+ filter_host: hostName,
+ selectParentTemplates: ["name"]
+ }));
+
+ let verified = false;
+ if (Array.isArray(verifyResult) && verifyResult.length > 0) {
+ const host = verifyResult[0] as any;
+ const linkedTemplates = host.parentTemplates || [];
+ verified = linkedTemplates.some((t: any) => t.name === templateName);
+ }
+
+ steps.push({
+ name: "Verify Host Linkage",
+ success: verified,
+ message: verified ? `Verification successful: Host ${hostName} is linked to ${templateName}` : `Verification failed: Host or linkage not found`
+ });
+ if (!verified) success = false;
+ } else {
+ steps.push({ name: "Verify Host Linkage", success: false, message: "Skipped due to previous failures" });
+ }
+
+ } catch (error: any) {
+ success = false;
+ steps.push({
+ name: "Execution Error",
+ success: false,
+ message: error.message || String(error)
+ });
+ } finally {
+ // Step 5: Cleanup
+ const cleanupSteps: SmoketestStep[] = [];
+
+ // Delete Host
+ const deleteHostRes = await HostDeleter.deleteHosts(null, hostName, zabbixAuthToken, cookie);
+ cleanupSteps.push({
+ name: "Cleanup: Delete Host",
+ success: deleteHostRes.every(r => !r.error),
+ message: deleteHostRes.length > 0 ? deleteHostRes[0].message : "Host not found for deletion"
+ });
+
+ // Delete Template
+ const deleteTemplateRes = await TemplateDeleter.deleteTemplates(null, templateName, zabbixAuthToken, cookie);
+ cleanupSteps.push({
+ name: "Cleanup: Delete Template",
+ success: deleteTemplateRes.every(r => !r.error),
+ message: deleteTemplateRes.length > 0 ? deleteTemplateRes[0].message : "Template not found for deletion"
+ });
+
+ // Delete Host Group
+ const deleteGroupRes = await HostDeleter.deleteHostGroups(null, groupName, zabbixAuthToken, cookie);
+ cleanupSteps.push({
+ name: "Cleanup: Delete Host Group",
+ success: deleteGroupRes.every(r => !r.error),
+ message: deleteGroupRes.length > 0 ? deleteGroupRes[0].message : "Host group not found for deletion"
+ });
+
+ // We also need to delete the template group if it's different or just try to delete it
+ // In our setup, TemplateImporter creates it if it doesn't exist.
+ const deleteTemplateGroupRes = await TemplateDeleter.deleteTemplateGroups(null, groupName, zabbixAuthToken, cookie);
+ cleanupSteps.push({
+ name: "Cleanup: Delete Template Group",
+ success: deleteTemplateGroupRes.every(r => !r.error),
+ message: deleteTemplateGroupRes.length > 0 ? deleteTemplateGroupRes[0].message : "Template group not found for deletion"
+ });
+
+ steps.push(...cleanupSteps);
+ }
+
+ return {
+ success,
+ message: success ? "Smoketest passed successfully" : "Smoketest failed",
+ steps
+ };
+ }
+}
diff --git a/src/schema/generated/graphql.ts b/src/schema/generated/graphql.ts
index c630ddd..c81a054 100644
--- a/src/schema/generated/graphql.ts
+++ b/src/schema/generated/graphql.ts
@@ -54,6 +54,10 @@ export interface CreateHost {
location?: InputMaybe;
/** Optional display name of the device (must be unique if provided - default is to set display name to deviceKey). */
name?: InputMaybe;
+ /** List of template names to link to the host. */
+ templateNames?: InputMaybe>>;
+ /** List of template IDs to link to the host. */
+ templateids?: InputMaybe>>;
}
/** Input for creating or identifying a host group. */
@@ -478,6 +482,18 @@ export interface Mutation {
* Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
*/
createHost?: Maybe;
+ /**
+ * Delete host groups by their IDs or by a name pattern.
+ *
+ * Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
+ */
+ deleteHostGroups?: Maybe>;
+ /**
+ * Delete hosts by their IDs or by a name pattern.
+ *
+ * Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
+ */
+ deleteHosts?: Maybe>;
/**
* Delete template groups by their IDs or by a name pattern.
*
@@ -528,6 +544,8 @@ export interface Mutation {
* Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
*/
importUserRights?: Maybe;
+ /** Runs a smoketest: creates a template, links a host, verifies it, and cleans up. */
+ runSmoketest: SmoketestResponse;
}
@@ -535,7 +553,20 @@ export interface MutationCreateHostArgs {
host: Scalars['String']['input'];
hostgroupids: Array;
location?: InputMaybe;
- templateids: Array;
+ templateNames?: InputMaybe>>;
+ templateids?: InputMaybe>>;
+}
+
+
+export interface MutationDeleteHostGroupsArgs {
+ groupids?: InputMaybe>;
+ name_pattern?: InputMaybe;
+}
+
+
+export interface MutationDeleteHostsArgs {
+ hostids?: InputMaybe>;
+ name_pattern?: InputMaybe;
}
@@ -576,6 +607,13 @@ export interface MutationImportUserRightsArgs {
input: UserRightsInput;
}
+
+export interface MutationRunSmoketestArgs {
+ groupName: Scalars['String']['input'];
+ hostName: Scalars['String']['input'];
+ templateName: Scalars['String']['input'];
+}
+
/** Operational data common to most devices. */
export interface OperationalDeviceData {
__typename?: 'OperationalDeviceData';
@@ -744,6 +782,28 @@ export interface QueryUserPermissionsArgs {
objectNames?: InputMaybe>;
}
+/** Response object for the smoketest operation. */
+export interface SmoketestResponse {
+ __typename?: 'SmoketestResponse';
+ /** Overall status message. */
+ message?: Maybe;
+ /** Detailed results for each step. */
+ steps: Array;
+ /** True if all steps of the smoketest succeeded. */
+ success: Scalars['Boolean']['output'];
+}
+
+/** Results for a single step in the smoketest. */
+export interface SmoketestStep {
+ __typename?: 'SmoketestStep';
+ /** Status message or error message for the step. */
+ message?: Maybe;
+ /** Name of the step (e.g. 'Create Template'). */
+ name: Scalars['String']['output'];
+ /** True if the step succeeded. */
+ success: Scalars['Boolean']['output'];
+}
+
export enum SortOrder {
/** Deliver values in ascending order */
Asc = 'asc',
@@ -1155,6 +1215,8 @@ export type ResolversTypes = {
Permission: Permission;
PermissionRequest: PermissionRequest;
Query: ResolverTypeWrapper<{}>;
+ SmoketestResponse: ResolverTypeWrapper;
+ SmoketestStep: ResolverTypeWrapper;
SortOrder: SortOrder;
StorageItemType: StorageItemType;
String: ResolverTypeWrapper;
@@ -1227,6 +1289,8 @@ export type ResolversParentTypes = {
OperationalDeviceData: OperationalDeviceData;
PermissionRequest: PermissionRequest;
Query: {};
+ SmoketestResponse: SmoketestResponse;
+ SmoketestStep: SmoketestStep;
String: Scalars['String']['output'];
Template: Template;
Time: Scalars['Time']['output'];
@@ -1449,7 +1513,9 @@ export type LocationResolvers = {
- createHost?: Resolver, ParentType, ContextType, RequireFields>;
+ createHost?: Resolver, ParentType, ContextType, RequireFields>;
+ deleteHostGroups?: Resolver>, ParentType, ContextType, Partial>;
+ deleteHosts?: Resolver>, ParentType, ContextType, Partial>;
deleteTemplateGroups?: Resolver>, ParentType, ContextType, Partial>;
deleteTemplates?: Resolver>, ParentType, ContextType, Partial>;
importHostGroups?: Resolver>, ParentType, ContextType, RequireFields>;
@@ -1457,6 +1523,7 @@ export type MutationResolvers>, ParentType, ContextType, RequireFields>;
importTemplates?: Resolver>, ParentType, ContextType, RequireFields>;
importUserRights?: Resolver, ParentType, ContextType, RequireFields>;
+ runSmoketest?: Resolver>;
};
export type OperationalDeviceDataResolvers = {
@@ -1488,6 +1555,20 @@ export type QueryResolvers, ParentType, ContextType>;
};
+export type SmoketestResponseResolvers = {
+ message?: Resolver, ParentType, ContextType>;
+ steps?: Resolver, ParentType, ContextType>;
+ success?: Resolver;
+ __isTypeOf?: IsTypeOfResolverFn;
+};
+
+export type SmoketestStepResolvers = {
+ message?: Resolver, ParentType, ContextType>;
+ name?: Resolver;
+ success?: Resolver;
+ __isTypeOf?: IsTypeOfResolverFn;
+};
+
export type StorageItemTypeResolvers = EnumResolverSignature<{ FLOAT?: any, INT?: any, TEXT?: any }, ResolversTypes['StorageItemType']>;
export type TemplateResolvers = {
@@ -1636,6 +1717,8 @@ export type Resolvers = {
OperationalDeviceData?: OperationalDeviceDataResolvers;
Permission?: PermissionResolvers;
Query?: QueryResolvers;
+ SmoketestResponse?: SmoketestResponseResolvers;
+ SmoketestStep?: SmoketestStepResolvers;
StorageItemType?: StorageItemTypeResolvers;
Template?: TemplateResolvers;
Time?: GraphQLScalarType;
diff --git a/src/test/host_importer.test.ts b/src/test/host_importer.test.ts
index b31bba0..d36dc39 100644
--- a/src/test/host_importer.test.ts
+++ b/src/test/host_importer.test.ts
@@ -71,8 +71,8 @@ describe("HostImporter", () => {
// Mocking template lookup for deviceType
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ templateid: "301" }]);
- // Mocking host.create via requestByPath
- (zabbixAPI.requestByPath as jest.Mock).mockResolvedValueOnce({ hostids: ["401"] });
+ // Mocking host.create via post (called by ZabbixCreateHostRequest)
+ (zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ hostids: ["401"] });
const result = await HostImporter.importHosts(hosts, "token");
diff --git a/src/test/host_integration.test.ts b/src/test/host_integration.test.ts
index b7be340..00d1544 100644
--- a/src/test/host_integration.test.ts
+++ b/src/test/host_integration.test.ts
@@ -62,9 +62,8 @@ describe("Host Integration Tests", () => {
(zabbixAPI.post as jest.Mock)
.mockResolvedValueOnce([{ groupid: "201", name: ZABBIX_EDGE_DEVICE_BASE_GROUP }]) // Base group
.mockResolvedValueOnce([{ groupid: "202", name: ZABBIX_EDGE_DEVICE_BASE_GROUP + "/ConstructionSite/Test" }]) // Specific group
- .mockResolvedValueOnce([{ templateid: "301" }]); // Template lookup
-
- (zabbixAPI.requestByPath as jest.Mock).mockResolvedValueOnce({ hostids: ["401"] });
+ .mockResolvedValueOnce([{ templateid: "301" }]) // Template lookup
+ .mockResolvedValueOnce({ hostids: ["401"] }); // Host creation
const response = await server.executeOperation({
query: mutation,
diff --git a/src/test/template_link.test.ts b/src/test/template_link.test.ts
new file mode 100644
index 0000000..ead11e1
--- /dev/null
+++ b/src/test/template_link.test.ts
@@ -0,0 +1,95 @@
+import {ApolloServer} from '@apollo/server';
+import {schema_loader} from '../api/schema.js';
+import {zabbixAPI} from '../datasources/zabbix-api.js';
+
+// Mocking ZabbixAPI
+jest.mock("../datasources/zabbix-api.js", () => ({
+ zabbixAPI: {
+ post: jest.fn(),
+ executeRequest: jest.fn(),
+ baseURL: 'http://localhost/zabbix',
+ requestByPath: jest.fn()
+ },
+ ZABBIX_EDGE_DEVICE_BASE_GROUP: "Roadwork"
+}));
+
+describe("Template Linking Tests", () => {
+ let server: ApolloServer;
+
+ beforeAll(async () => {
+ const schema = await schema_loader();
+ server = new ApolloServer({
+ schema,
+ });
+ });
+
+ test("createHost with templateNames", async () => {
+ const mutation = `
+ mutation CreateHost($host: String!, $hostgroupids: [Int!]!, $templateNames: [String!]!) {
+ createHost(host: $host, hostgroupids: $hostgroupids, templateNames: $templateNames) {
+ hostids
+ }
+ }
+ `;
+ const variables = {
+ host: "TestHost",
+ hostgroupids: [1],
+ templateNames: ["Test Template"]
+ };
+
+ (zabbixAPI.post as jest.Mock)
+ .mockResolvedValueOnce([{ templateid: "101", name: "Test Template" }]) // Template lookup
+ .mockResolvedValueOnce({ hostids: ["201"] }); // Host creation
+
+ const response = await server.executeOperation({
+ query: mutation,
+ variables: variables,
+ }, {
+ contextValue: { zabbixAuthToken: 'test-token', dataSources: { zabbixAPI: zabbixAPI } }
+ });
+
+ expect(response.body.kind).toBe('single');
+ // @ts-ignore
+ const result = response.body.singleResult;
+ expect(result.errors).toBeUndefined();
+ expect(result.data.createHost.hostids).toContain(201);
+ });
+
+ test("importHosts with templateids and templateNames", async () => {
+ const mutation = `
+ mutation ImportHosts($hosts: [CreateHost!]!) {
+ importHosts(hosts: $hosts) {
+ hostid
+ }
+ }
+ `;
+ const variables = {
+ hosts: [{
+ deviceKey: "TestDevice",
+ deviceType: "TestType",
+ groupNames: ["TestGroup"],
+ templateids: [101],
+ templateNames: ["Another Template"]
+ }]
+ };
+
+ (zabbixAPI.post as jest.Mock)
+ .mockResolvedValueOnce([{ groupid: "501", name: "Roadwork" }]) // Base group lookup
+ .mockResolvedValueOnce([{ groupid: "502", name: "Roadwork/TestGroup" }]) // Specific group lookup
+ .mockResolvedValueOnce([{ templateid: "102", name: "Another Template" }]) // Template lookup
+ .mockResolvedValueOnce({ hostids: ["202"] }); // Host creation
+
+ const response = await server.executeOperation({
+ query: mutation,
+ variables: variables,
+ }, {
+ contextValue: { zabbixAuthToken: 'test-token', dataSources: { zabbixAPI: zabbixAPI } }
+ });
+
+ expect(response.body.kind).toBe('single');
+ // @ts-ignore
+ const result = response.body.singleResult;
+ expect(result.errors).toBeUndefined();
+ expect(result.data.importHosts[0].hostid).toBe("202");
+ });
+});