wulf-pulse/app/api/zabbix/sync-wan/route.ts
lorentz c518eefdb2 feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder
- Add webhook delivery system with Teams incoming webhooks
- Add admin UI at /admin/morning-summary for webhook/config management
- Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history
- Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM)
- Add outages_only filter (Unavailable triggers only)
- Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts
- Fix resolved events: event.get value:1 scoped to window with r_eventid filter
- Remove emojis from fact rows and section headers in card
- Remove Open Zabbix button (duplicate of View Problems)
- Add migrations: morning_summary_config + morning_summaries tables
- Add outages_only column to morning_summary_config
2026-03-11 09:34:51 -04:00

360 lines
13 KiB
TypeScript

import { NextRequest } from 'next/server';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
import { ZabbixClient } from '@/lib/services/zabbix-client';
import { postgresClient } from '@/lib/services/postgres-client';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
import {
lookupIsp,
clearIspCache,
buildHostParams,
discoverIcmpTemplate,
} from '@/lib/services/zabbix-wan-utils';
export const maxDuration = 300;
type SyncMode = 'all' | 'client' | 'site';
type SiteAction = 'created' | 'updated' | 'filtered' | 'no-ip' | 'error' | 'skipped';
interface WanResolution {
ip: string | null;
count: number;
multiWan: boolean; // true when qualifying devices report 2+ distinct IPs
allIps: string[]; // all distinct IPs seen (for multi-WAN visibility)
singleDeviceFallback: boolean; // true when result came from the single-device fallback
noIpReason?: string; // set only when ip is null
}
interface SiteResult {
siteName: string;
siteUid: string;
companyId: number | null;
companyName: string | null;
wanIp: string | null;
qualifyingDevices: number;
multiWan: boolean;
singleDeviceFallback: boolean;
isp: string | null;
asn: string | null;
action: SiteAction;
hostId: string | null;
filterReason?: string;
error?: string;
}
interface StreamMessage {
type: 'site' | 'summary' | 'error';
result?: SiteResult;
stats?: {
total: number;
created: number;
updated: number;
filtered: number;
noIp: number;
errors: number;
skipped: number;
multiWan: number;
};
message?: string;
}
// ---------------------------------------------------------------------------
// WAN IP resolution
// ---------------------------------------------------------------------------
function isLaptop(d: DattoRMMDevice): boolean {
const cat = (d.deviceType?.category ?? '').toLowerCase();
const type = (d.deviceType?.type ?? '').toLowerCase();
return cat.includes('laptop') || cat.includes('notebook') ||
type.includes('laptop') || type.includes('notebook');
}
function resolveWanIp(
devices: DattoRMMDevice[],
minDevices: number,
maxLastSeenHours: number,
allowSingleDevice: boolean,
): WanResolution {
const cutoffMs = Date.now() - maxLastSeenHours * 3600 * 1000;
const qualifying = devices.filter(
(d) =>
!d.suspended &&
!d.deleted &&
d.extIpAddress &&
d.extIpAddress !== '0.0.0.0' &&
d.extIpAddress.trim() !== '' &&
d.lastSeen != null &&
d.lastSeen > cutoffMs
);
// Group devices by IP
const ipDevices = new Map<string, DattoRMMDevice[]>();
for (const d of qualifying) {
const ip = d.extIpAddress;
if (!ipDevices.has(ip)) ipDevices.set(ip, []);
ipDevices.get(ip)!.push(d);
}
// Drop IPs seen only once from a single laptop — likely a remote/travelling device
const filtered = new Map(ipDevices);
for (const [ip, devs] of filtered) {
if (devs.length === 1 && isLaptop(devs[0])) {
filtered.delete(ip);
}
}
// If filtering wiped everything out and allowSingleDevice is on, fall back to
// the full set (accepts any device type including a lone laptop/server/etc.)
const effective = filtered.size > 0
? filtered
: allowSingleDevice && ipDevices.size > 0
? ipDevices
: null;
if (!effective) {
let noIpReason = 'No qualifying devices';
if (devices.length === 0) {
noIpReason = 'No devices in site';
} else if (qualifying.length === 0) {
const active = devices.filter((d) => !d.suspended && !d.deleted);
if (active.length === 0) {
noIpReason = `All ${devices.length} devices suspended or deleted`;
} else {
const withIp = active.filter(
(d) => d.extIpAddress && d.extIpAddress !== '0.0.0.0' && d.extIpAddress.trim() !== ''
);
if (withIp.length === 0) {
noIpReason = `${active.length} active device${active.length !== 1 ? 's' : ''}, none report a public IP`;
} else {
noIpReason = `${withIp.length} device${withIp.length !== 1 ? 's' : ''} have an IP but none seen in last ${maxLastSeenHours}h`;
}
}
} else {
// qualifying > 0 but all unique-laptop IPs were dropped and fallback is off
noIpReason = 'Only laptops found — enable single-device fallback';
}
return { ip: null, count: 0, multiWan: false, allIps: [], singleDeviceFallback: false, noIpReason };
}
const sorted = Array.from(effective.entries())
.map(([ip, devs]) => [ip, devs.length] as [string, number])
.sort((a, b) => b[1] - a[1]);
const allIps = sorted.map(([ip]) => ip);
const [topIp, topCount] = sorted[0];
const multiWan = sorted.length > 1;
const singleDeviceFallback = filtered.size === 0; // used the fallback path
return { ip: topIp, count: topCount, multiWan, allIps, singleDeviceFallback };
}
// ISP lookup, hostname sanitization, and host param building imported from
// @/lib/services/zabbix-wan-utils
// ---------------------------------------------------------------------------
// API route
// ---------------------------------------------------------------------------
export async function POST(request: NextRequest) {
const body = await request.json();
const {
mode = 'all' as SyncMode,
companyId,
siteUid,
minDevices = 1,
maxLastSeenHours = 48,
allowSingleDevice = false,
dryRun = false,
} = body;
clearIspCache(); // fresh cache per request
const encoder = new TextEncoder();
const transform = new TransformStream<Uint8Array, Uint8Array>();
const writer = transform.writable.getWriter();
const send = async (msg: StreamMessage) => {
await writer.write(encoder.encode(JSON.stringify(msg) + '\n'));
};
(async () => {
try {
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
await send({ type: 'error', message: 'Zabbix is not configured. Add ZABBIX_API_URL and ZABBIX_API_TOKEN to your environment.' });
return;
}
const rmmClient = getDattoRMMClient();
const zabbix = new ZabbixClient({
apiUrl: process.env.ZABBIX_API_URL!,
apiToken: process.env.ZABBIX_API_TOKEN!,
});
// Ensure base group + ICMP template (skipped in dry-run)
let globalGroupId = 'dry-run';
let icmpTemplateId: string | null = null;
if (!dryRun) {
globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites');
icmpTemplateId = await discoverIcmpTemplate(zabbix);
}
// Load site → Autotask mappings (keyed by RMM site UID)
const mappingRows = await postgresClient.query<{
rmm_site_uid: string;
company_id: number;
company_name: string;
}>(
`SELECT rsm.rmm_site_uid, rsm.company_id, c.company_name
FROM rmm_site_mappings rsm
JOIN companies c ON c.id = rsm.company_id`
);
const mappingBySiteUid = new Map(
mappingRows.rows.map((r) => [r.rmm_site_uid, { companyId: r.company_id, companyName: r.company_name }])
);
// Determine which sites to process
let sites: { uid: string; name: string }[] = [];
if (mode === 'all') {
const allSites = await rmmClient.getAllSites();
sites = allSites
.filter((s) => mappingBySiteUid.has(s.uid))
.map((s) => ({ uid: s.uid, name: s.name }));
} else if (mode === 'client' && companyId) {
const res = await postgresClient.query<{ rmm_site_uid: string; rmm_site_name: string }>(
'SELECT rmm_site_uid, rmm_site_name FROM rmm_site_mappings WHERE company_id = $1',
[companyId]
);
sites = res.rows.map((r) => ({ uid: r.rmm_site_uid, name: r.rmm_site_name }));
} else if (mode === 'site' && siteUid) {
const res = await postgresClient.query<{ rmm_site_name: string }>(
'SELECT rmm_site_name FROM rmm_site_mappings WHERE rmm_site_uid = $1 LIMIT 1',
[siteUid]
);
sites = [{ uid: siteUid, name: res.rows[0]?.rmm_site_name ?? siteUid }];
}
if (sites.length === 0) {
await send({ type: 'error', message: 'No sites found to process.' });
return;
}
const stats = { total: sites.length, created: 0, updated: 0, filtered: 0, noIp: 0, errors: 0, skipped: 0, multiWan: 0 };
for (const site of sites) {
const mapping = mappingBySiteUid.get(site.uid);
let devices: DattoRMMDevice[] = [];
try {
devices = await rmmClient.getDevicesBySite(site.uid);
} catch (err) {
stats.errors++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp: null, qualifyingDevices: 0, multiWan: false, singleDeviceFallback: false, isp: null, asn: null,
action: 'error', hostId: null, error: String(err),
}});
continue;
}
const { ip: wanIp, count: qualifyingDevices, multiWan, allIps, singleDeviceFallback, noIpReason } = resolveWanIp(devices, minDevices, maxLastSeenHours, allowSingleDevice);
if (multiWan) stats.multiWan++;
if (!wanIp) {
stats.noIp++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp: null, qualifyingDevices: 0, multiWan, singleDeviceFallback: false, isp: null, asn: null,
action: 'no-ip', hostId: null, filterReason: noIpReason,
}});
continue;
}
// ISP lookup (runs even in dry-run and for filtered sites so we can show it in preview)
const ispInfo = await lookupIsp(wanIp);
// Skip minDevices gate if the single-device fallback is active — the user
// explicitly opted in, so enforcing the threshold here would silently undo it.
if (!singleDeviceFallback && qualifyingDevices < minDevices) {
stats.filtered++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback: false,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action: 'filtered', hostId: null,
filterReason: `${qualifyingDevices} device${qualifyingDevices !== 1 ? 's' : ''} at IP (min ${minDevices})`,
}});
continue;
}
if (dryRun) {
stats.skipped++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action: 'skipped', hostId: null,
}});
continue;
}
try {
const onlineCount = devices.filter((d) => d.online && !d.suspended && !d.deleted).length;
const hostParams = await buildHostParams({
siteName: site.name,
wanIp,
companyId: mapping?.companyId,
companyName: mapping?.companyName,
rmmSiteUid: site.uid,
ispInfo,
multiWan,
allIps,
singleDeviceFallback,
onlineDeviceCount: onlineCount,
source: 'datto-rmm',
icmpTemplateId,
globalGroupId,
zabbix,
});
const { action, hostid } = await zabbix.upsertHost(hostParams);
if (action === 'created') stats.created++; else stats.updated++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action, hostId: hostid,
}});
} catch (err) {
stats.errors++;
await send({ type: 'site', result: {
siteName: site.name, siteUid: site.uid,
companyId: mapping?.companyId ?? null, companyName: mapping?.companyName ?? null,
wanIp, qualifyingDevices, multiWan, singleDeviceFallback,
isp: ispInfo?.isp ?? null, asn: ispInfo?.asn ?? null,
action: 'error', hostId: null, error: String(err),
}});
}
}
await send({ type: 'summary', stats });
} catch (err) {
await send({ type: 'error', message: String(err) });
} finally {
await writer.close();
}
})();
return new Response(transform.readable, {
headers: { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' },
});
}