import { ZabbixConfig, ZabbixHost, ZabbixHostGroup, ZabbixTemplate, ZabbixHostCreateParams, ZabbixHostUpdateParams, ZabbixRpcResponse, ZabbixProblem, ZabbixEvent, } 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(method: string, params: Record): Promise { 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 = 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 { const existing = await this.rpc('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 { const results = await this.rpc('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 { const results = await this.rpc('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('host.get', { output: ['hostid', 'host', 'name', 'status'], filter: { name: [displayName] }, }); if (byName.length > 0) return byName[0]; } return null; } /** * Fetch all hosts with full detail: interfaces, tags, macros, groups. */ async getHosts(): Promise { return this.rpc('host.get', { output: 'extend', selectInterfaces: 'extend', selectTags: 'extend', selectMacros: 'extend', selectGroups: 'extend', selectParentTemplates: ['templateid', 'host', 'name'], }); } /** * Fetch PROBLEM trigger events (value=1) for a set of hostids within a time range. * Used to build the local zabbix_events correlation cache. */ async getEvents(params: { hostIds: string[]; from: Date; to: Date; limit?: number; }): Promise; }>> { // problem.get supports r_clock output; event.get does not return this.rpc('problem.get', { output: ['eventid', 'objectid', 'name', 'severity', 'clock', 'r_eventid', 'r_clock'], time_from: Math.floor(params.from.getTime() / 1000), time_till: Math.floor(params.to.getTime() / 1000), hostids: params.hostIds, selectHosts: ['hostid'], recent: false, sortfield: 'eventid', sortorder: 'DESC', limit: params.limit ?? 10000, }); } /** * Delete one or more hosts by their hostids. */ async deleteHosts(hostids: string[]): Promise { await this.rpc<{ hostids: string[] }>('host.delete', hostids); } /** * Get open problems with hosts inline. * Only returns problems whose trigger is linked to at least one enabled host. * severities: 2=Warning,3=Average,4=High,5=Disaster */ async getOpenProblems(minSeverity = 2): Promise { return this.rpc('problem.get', { output: 'extend', selectAcknowledges: 'extend', selectSuppressionData: 'extend', severities: [2, 3, 4, 5].filter(s => s >= minSeverity), recent: true, sortfield: 'eventid', sortorder: 'DESC', }); } /** * Get problems that were resolved within the given window. * Uses problem.get with time_from/time_till (filters on problem clock) and * r_eventid IS SET (meaning the problem has a recovery event = resolved). * Returns problems only — r_clock is the resolution timestamp. */ async getResolvedEvents(from: Date, to: Date): Promise { const fromSec = Math.floor(from.getTime() / 1000); const toSec = Math.floor(to.getTime() / 1000); // event.get with value:1 returns PROBLEM trigger events. // time_from/time_till filter on event creation (problem start) time. // We then keep only those that have an r_clock (= resolved) within the window. const events = await this.rpc('event.get', { output: 'extend', source: 0, object: 0, value: 1, time_from: fromSec, time_till: toSec, severities: [2, 3, 4, 5], sortfield: 'eventid', sortorder: 'DESC', limit: 500, }); return events.filter(e => e.r_eventid && e.r_eventid !== '0'); } /** * Fetch group names for a specific list of hostids. * Returns a Map for client name resolution. */ async getHostGroupMap(hostids: string[]): Promise> { if (hostids.length === 0) return new Map(); const hosts = await this.rpc; }>>('host.get', { output: ['hostid'], hostids, selectGroups: ['groupid', 'name'], }); const map = new Map(); for (const h of hosts) { map.set(h.hostid, (h.groups ?? []).map((g: { name: string }) => g.name)); } return map; } /** * For a list of triggerids, return a map of triggerid → { hostid, hostName } * filtered to only enabled hosts (status '0'). * Triggerids with no enabled host are omitted from the map. */ async getTriggerEnabledHosts(triggerids: string[]): Promise> { if (triggerids.length === 0) return new Map(); const triggers = await this.rpc; }>>('trigger.get', { output: ['triggerid'], triggerids, selectHosts: ['hostid', 'name', 'status'], }); const map = new Map(); for (const t of triggers) { const enabled = (t.hosts ?? []).find(h => h.status === '0'); if (enabled) map.set(t.triggerid, { hostid: enabled.hostid, hostName: enabled.name }); } return map; } /** * 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 }; } /** * Find hosts by DNS name or IP address across all interfaces. * Used to correlate Datto RMM ping targets with Zabbix hosts. */ async findHostsByInterface(dnsOrIp: string): Promise> { return this.rpc>('host.get', { output: ['hostid', 'name'], filter: { ip: [dnsOrIp], dns: [dnsOrIp] }, searchByAny: true, }); } /** * Get open problems for a host. */ async getOpenProblemsForHost(hostid: string): Promise { return this.rpc('problem.get', { output: 'extend', hostids: [hostid], recent: true, selectAcknowledges: 'count', selectSuppressionData: 'extend', }); } /** * Suppress a Zabbix problem event until a given timestamp. * action=16 = suppress, action=4 = acknowledge with message. * We combine both (action=20) to add a note and suppress. */ async suppressProblem(eventid: string, _suppressUntil: Date, message: string): Promise { await this.rpc<{ eventids: string[] }>('event.acknowledge', { eventids: [eventid], action: 20, // 4 (add message) + 16 (suppress) message, }); } }