wulf-pulse/app/api/zabbix/alert-correlation/route.ts

155 lines
5.9 KiB
TypeScript
Raw Normal View History

/**
* GET /api/zabbix/alert-correlation
* Cross-references Zabbix WAN problem events against Datto RMM ping/offline alerts
* for the same company within a configurable time window.
*
* Query params:
* days look-back window in days (default 30)
* windowMins match window ± minutes around Zabbix event (default 120)
* companyId optional: filter to one Autotask company
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const days = Number(searchParams.get('days') ?? 30);
const windowMins = Number(searchParams.get('windowMins') ?? 120);
const companyId = searchParams.get('companyId');
try {
const since = new Date(Date.now() - days * 86400 * 1000);
// ── 1. Zabbix events with matched RMM alert count ────────────────────────
const zabbixRows = await postgresClient.query<{
eventid: string;
name: string;
severity: number;
clock: string;
r_clock: string | null;
duration_seconds: number | null;
host_name: string;
wan_ip: string;
isp_name: string | null;
autotask_company_id: number;
autotask_company_name: string;
rmm_site_uid: string | null;
rmm_alert_count: string;
rmm_alerts: string; // JSON array
}>(`
SELECT
ze.eventid,
ze.name,
ze.severity,
ze.clock,
ze.r_clock,
ze.duration_seconds,
ze.host_name,
ze.wan_ip,
ze.isp_name,
ze.autotask_company_id,
ze.autotask_company_name,
ze.rmm_site_uid,
COUNT(ra.alert_uid)::text AS rmm_alert_count,
json_agg(json_build_object(
'alert_uid', ra.alert_uid,
'site_name', ra.site_name,
'alert_class', ra.alert_context->>'@class',
'alert_message', ra.alert_message_en,
'timestamp', ra.timestamp,
'resolved', ra.resolved,
'resolved_on', ra.resolved_on,
'device_name', ra.device_name
) ORDER BY ra.timestamp)
FILTER (WHERE ra.alert_uid IS NOT NULL) AS rmm_alerts
FROM zabbix_events ze
LEFT JOIN datto_rmm_alerts ra
ON ra.timestamp BETWEEN ze.clock - ($1 * INTERVAL '1 minute')
AND ze.clock + ($1 * INTERVAL '1 minute')
AND EXISTS (
SELECT 1 FROM datto_rmm_sites ds
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = ds.uid
WHERE ds.uid = ra.site_uid
AND rsm.company_id = ze.autotask_company_id
)
WHERE ze.clock >= $2
${companyId ? 'AND ze.autotask_company_id = $3' : ''}
GROUP BY
ze.eventid, ze.name, ze.severity, ze.clock, ze.r_clock,
ze.duration_seconds, ze.host_name, ze.wan_ip, ze.isp_name,
ze.autotask_company_id, ze.autotask_company_name, ze.rmm_site_uid
ORDER BY ze.clock DESC
`, companyId
? [windowMins, since, Number(companyId)]
: [windowMins, since]);
// ── 2. RMM ping/offline alerts with NO matching Zabbix event ────────────
const rmmOnlyRows = await postgresClient.query<{
alert_uid: string;
site_name: string;
alert_class: string;
alert_message: string | null;
timestamp: string;
resolved: boolean;
resolved_on: string | null;
device_name: string | null;
autotask_company_id: number | null;
autotask_company_name: string | null;
}>(`
SELECT
ra.alert_uid,
ra.site_name,
ra.alert_context->>'@class' AS alert_class,
ra.alert_message_en AS alert_message,
ra.timestamp,
ra.resolved,
ra.resolved_on,
ra.device_name,
rsm.company_id AS autotask_company_id,
c.company_name AS autotask_company_name
FROM datto_rmm_alerts ra
JOIN datto_rmm_sites ds ON ds.uid = ra.site_uid
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = ds.uid
JOIN companies c ON c.id = rsm.company_id
WHERE ra.timestamp >= $1
AND ra.alert_context->>'@class' IN ('ping_ctx', 'device_offline_ctx', 'network_ctx')
AND NOT EXISTS (
SELECT 1 FROM zabbix_events ze
WHERE ze.autotask_company_id = rsm.company_id
AND ze.clock BETWEEN ra.timestamp - ($2 * INTERVAL '1 minute')
AND ra.timestamp + ($2 * INTERVAL '1 minute')
)
${companyId ? 'AND rsm.company_id = $3' : ''}
ORDER BY ra.timestamp DESC
LIMIT 500
`, companyId
? [since, windowMins, Number(companyId)]
: [since, windowMins]);
// ── 3. Summary stats ─────────────────────────────────────────────────────
const zabbixWithRmm = zabbixRows.rows.filter(r => Number(r.rmm_alert_count) > 0).length;
const zabbixWithoutRmm = zabbixRows.rows.filter(r => Number(r.rmm_alert_count) === 0).length;
const lastEventSync = await postgresClient.query<{ last_sync: string | null }>(
'SELECT MAX(last_synced_at) AS last_sync FROM zabbix_events'
);
return NextResponse.json({
summary: {
days,
window_mins: windowMins,
zabbix_events_total: zabbixRows.rows.length,
zabbix_with_rmm_match: zabbixWithRmm,
zabbix_without_rmm_match: zabbixWithoutRmm,
rmm_only_alerts: rmmOnlyRows.rows.length,
last_event_sync: lastEventSync.rows[0]?.last_sync ?? null,
},
zabbix_events: zabbixRows.rows,
rmm_only: rmmOnlyRows.rows,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}