- 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
360 lines
13 KiB
TypeScript
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' },
|
|
});
|
|
}
|