wulf-pulse/lib/services/zabbix-client.ts

149 lines
4.3 KiB
TypeScript

import {
ZabbixConfig,
ZabbixHost,
ZabbixHostGroup,
ZabbixTemplate,
ZabbixHostCreateParams,
ZabbixHostUpdateParams,
ZabbixRpcResponse,
} from '@/lib/types/zabbix';
export type { ZabbixHostTag } from '@/lib/types/zabbix';
export class ZabbixClient {
private config: ZabbixConfig;
private rpcId = 0;
constructor(config: ZabbixConfig) {
this.config = config;
}
/**
* Make a JSON-RPC 2.0 call to the Zabbix API.
* Auth is stateless: Bearer token in Authorization header (Zabbix 6.0+ API token).
*/
private async rpc<T>(method: string, params: Record<string, any>): Promise<T> {
const id = ++this.rpcId;
const url = `${this.config.apiUrl.replace(/\/$/, '')}/api_jsonrpc.php`;
const body = JSON.stringify({
jsonrpc: '2.0',
method,
params,
id,
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiToken}`,
},
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Zabbix HTTP ${response.status}: ${text.substring(0, 200)}`);
}
const data: ZabbixRpcResponse<T> = await response.json();
if (data.error) {
throw new Error(
`Zabbix RPC error (${data.error.code}): ${data.error.message}` +
(data.error.data ? `${data.error.data}` : '')
);
}
return data.result as T;
}
/**
* Get or create a host group by name. Returns the groupid.
*/
async ensureHostGroup(name: string): Promise<string> {
const existing = await this.rpc<ZabbixHostGroup[]>('hostgroup.get', {
output: ['groupid', 'name'],
filter: { name: [name] },
});
if (existing.length > 0) {
return existing[0].groupid;
}
const created = await this.rpc<{ groupids: string[] }>('hostgroup.create', {
name,
});
return created.groupids[0];
}
/**
* Find a template by exact or partial host name. Returns the first match or null.
*/
async findTemplate(name: string): Promise<ZabbixTemplate | null> {
const results = await this.rpc<ZabbixTemplate[]>('template.get', {
output: ['templateid', 'host', 'name'],
search: { host: name },
searchByAny: false,
});
return results.length > 0 ? results[0] : null;
}
/**
* Find a host by its technical name (host field), with a fallback search by
* display name. The fallback handles legacy hosts created before hostname
* sanitization was introduced (their host == name == full display string).
*/
async findHostByName(hostname: string, displayName?: string): Promise<ZabbixHost | null> {
const results = await this.rpc<ZabbixHost[]>('host.get', {
output: ['hostid', 'host', 'name', 'status'],
filter: { host: [hostname] },
});
if (results.length > 0) return results[0];
// Fallback: search by display name (catches pre-sanitization legacy hosts)
if (displayName && displayName !== hostname) {
const byName = await this.rpc<ZabbixHost[]>('host.get', {
output: ['hostid', 'host', 'name', 'status'],
filter: { name: [displayName] },
});
if (byName.length > 0) return byName[0];
}
return null;
}
/**
* Create or update a Zabbix host. Idempotent — looks up by host name first.
* Returns the hostid and whether the host was created or updated.
*/
async upsertHost(params: ZabbixHostCreateParams): Promise<{
action: 'created' | 'updated';
hostid: string;
}> {
const existing = await this.findHostByName(params.host, params.name);
if (!existing) {
const result = await this.rpc<{ hostids: string[] }>('host.create', params);
return { action: 'created', hostid: result.hostids[0] };
}
const updateParams: ZabbixHostUpdateParams = {
hostid: existing.hostid,
name: params.name,
description: params.description,
// Do not pass interfaces on update — Zabbix rejects changes when items
// are already linked to the existing interface.
groups: params.groups,
templates: params.templates,
macros: params.macros,
tags: params.tags,
};
await this.rpc<{ hostids: string[] }>('host.update', updateParams);
return { action: 'updated', hostid: existing.hostid };
}
}