wulf-pulse/lib/services/zabbix-client.ts
lorentz b98c67482a feat: QuickBooks Online integration
- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts)
- Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts)
- Add QBO types (lib/types/qbo.ts)
- Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect
- Add /admin/qbo status and sync management page
- Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment)
- Add QBO nav link under Admin
- Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all
- Add CashFlow report type alongside P&L and BalanceSheet
- Add NoReportData check to skip empty report months
- Add intuit_tid capture in error messages
- Add redirect: follow for cluster routing
- Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables

Also includes earlier work:
- Ping flap suppression pipeline step
- Ticket digest reports with LLM analysis
- Zabbix WAN monitor and gap analysis
- Kiosk is_deleted filter fixes
- Datto RMM ping target enrichment
- Entity sync soft-delete detection
2026-03-17 07:39:55 -04:00

335 lines
10 KiB
TypeScript

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<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;
}
/**
* Fetch all hosts with full detail: interfaces, tags, macros, groups.
*/
async getHosts(): Promise<ZabbixHost[]> {
return this.rpc<ZabbixHost[]>('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<Array<{
eventid: string;
objectid: string;
name: string;
severity: string;
clock: string;
r_eventid: string;
r_clock: string;
hosts: Array<{ hostid: string }>;
}>> {
// 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<void> {
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<ZabbixProblem[]> {
return this.rpc<ZabbixProblem[]>('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<ZabbixEvent[]> {
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<ZabbixEvent[]>('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<hostid, groupName[]> for client name resolution.
*/
async getHostGroupMap(hostids: string[]): Promise<Map<string, string[]>> {
if (hostids.length === 0) return new Map();
const hosts = await this.rpc<Array<{
hostid: string;
groups: Array<{ groupid: string; name: string }>;
}>>('host.get', {
output: ['hostid'],
hostids,
selectGroups: ['groupid', 'name'],
});
const map = new Map<string, string[]>();
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<Map<string, { hostid: string; hostName: string }>> {
if (triggerids.length === 0) return new Map();
const triggers = await this.rpc<Array<{
triggerid: string;
hosts: Array<{ hostid: string; name: string; status: string }>;
}>>('trigger.get', {
output: ['triggerid'],
triggerids,
selectHosts: ['hostid', 'name', 'status'],
});
const map = new Map<string, { hostid: string; hostName: string }>();
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<Array<{ hostid: string; name: string }>> {
return this.rpc<Array<{ hostid: string; name: string }>>('host.get', {
output: ['hostid', 'name'],
filter: { ip: [dnsOrIp], dns: [dnsOrIp] },
searchByAny: true,
});
}
/**
* Get open problems for a host.
*/
async getOpenProblemsForHost(hostid: string): Promise<ZabbixProblem[]> {
return this.rpc<ZabbixProblem[]>('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<void> {
await this.rpc<{ eventids: string[] }>('event.acknowledge', {
eventids: [eventid],
action: 20, // 4 (add message) + 16 (suppress)
message,
});
}
}