- 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
335 lines
10 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|