- 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
206 lines
7.3 KiB
TypeScript
206 lines
7.3 KiB
TypeScript
/**
|
||
* Shared utilities for Zabbix WAN host management.
|
||
* Used by both the RMM discovery sync and the manual host creation endpoints.
|
||
*/
|
||
|
||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||
import { ZabbixHostMacro, ZabbixHostTag, ZabbixHostCreateParams } from '@/lib/types/zabbix';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ISP info type
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface IspInfo {
|
||
isp: string; // "Comcast Cable Communications, LLC"
|
||
asn: string; // "AS7922"
|
||
city: string;
|
||
region: string;
|
||
country: string;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Zabbix host technical name sanitization
|
||
// Zabbix rejects: + ' , . & ( ) and other special chars in the `host` field.
|
||
// We sanitize to alphanumeric, spaces, hyphens, underscores only.
|
||
// The display `name` field is left as-is (accepts any UTF-8).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function sanitizeHostname(name: string): string {
|
||
return name
|
||
.replace(/[^a-zA-Z0-9 \-_]/g, '') // strip disallowed chars
|
||
.replace(/\s+/g, ' ') // collapse multiple spaces
|
||
.trim();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ISP lookup via ipinfo.io (free, no key required for basic fields)
|
||
// Results are cached within the module lifetime to avoid duplicate lookups.
|
||
// Call clearIspCache() at the start of each request if needed.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ispCache = new Map<string, IspInfo | null>();
|
||
|
||
export function clearIspCache(): void {
|
||
ispCache.clear();
|
||
}
|
||
|
||
export async function lookupIsp(ip: string): Promise<IspInfo | null> {
|
||
if (ispCache.has(ip)) return ispCache.get(ip)!;
|
||
|
||
try {
|
||
const token = process.env.IPINFO_TOKEN;
|
||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(`https://ipinfo.io/${ip}/json`, {
|
||
headers,
|
||
cache: 'no-store',
|
||
signal: AbortSignal.timeout(6000),
|
||
});
|
||
if (!res.ok) { ispCache.set(ip, null); return null; }
|
||
|
||
const data = await res.json();
|
||
// org field format: "AS7922 Comcast Cable Communications, LLC"
|
||
const org: string = data.org ?? '';
|
||
const m = org.match(/^(AS\d+)\s+(.+)$/);
|
||
|
||
const info: IspInfo = {
|
||
isp: m ? m[2] : org,
|
||
asn: m ? m[1] : '',
|
||
city: data.city ?? '',
|
||
region: data.region ?? '',
|
||
country: data.country ?? '',
|
||
};
|
||
ispCache.set(ip, info);
|
||
return info;
|
||
} catch {
|
||
ispCache.set(ip, null);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Build the full Zabbix host upsert params from common inputs.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface BuildHostParamsInput {
|
||
siteName: string;
|
||
wanIp: string;
|
||
companyId?: number;
|
||
companyName?: string;
|
||
rmmSiteUid?: string;
|
||
ispInfo: IspInfo | null;
|
||
multiWan?: boolean;
|
||
allIps?: string[];
|
||
singleDeviceFallback?: boolean;
|
||
onlineDeviceCount?: number;
|
||
source?: string; // tag value for "source" (default "datto-rmm")
|
||
icmpTemplateId: string | null;
|
||
globalGroupId: string;
|
||
zabbix: ZabbixClient;
|
||
}
|
||
|
||
export async function buildHostParams(input: BuildHostParamsInput): Promise<ZabbixHostCreateParams> {
|
||
const {
|
||
siteName, wanIp, companyId, companyName, rmmSiteUid,
|
||
ispInfo, multiWan, allIps, singleDeviceFallback,
|
||
onlineDeviceCount, source, icmpTemplateId, globalGroupId, zabbix,
|
||
} = input;
|
||
|
||
const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined;
|
||
|
||
// Build groups: always global, + per-client, + per-ISP
|
||
const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }];
|
||
|
||
if (companyName) {
|
||
const clientGroupId = await zabbix.ensureHostGroup(`Clients/${companyName}`);
|
||
groups.push({ groupid: clientGroupId });
|
||
}
|
||
if (ispInfo?.isp) {
|
||
const ispGroupId = await zabbix.ensureHostGroup(`ISP/${ispInfo.isp}`);
|
||
groups.push({ groupid: ispGroupId });
|
||
}
|
||
|
||
// Build macros: Autotask identity + ISP context
|
||
const macros: ZabbixHostMacro[] = [];
|
||
if (companyId && companyName) {
|
||
macros.push(
|
||
{ macro: '{$AUTOTASK_COMPANY_ID}', value: String(companyId), description: 'Autotask company ID' },
|
||
{ macro: '{$AUTOTASK_COMPANY_NAME}', value: companyName, description: 'Autotask company name' },
|
||
);
|
||
}
|
||
if (rmmSiteUid) {
|
||
macros.push(
|
||
{ macro: '{$RMM_SITE_UID}', value: rmmSiteUid, description: 'Datto RMM site UID' },
|
||
);
|
||
}
|
||
if (ispInfo) {
|
||
macros.push(
|
||
{ macro: '{$ISP_NAME}', value: ispInfo.isp, description: 'ISP / carrier name' },
|
||
{ macro: '{$ASN}', value: ispInfo.asn, description: 'Autonomous System Number' },
|
||
{ macro: '{$ISP_CITY}', value: ispInfo.city, description: 'City (from IP geolocation)' },
|
||
{ macro: '{$ISP_REGION}', value: ispInfo.region, description: 'Region (from IP geolocation)' },
|
||
{ macro: '{$ISP_COUNTRY}', value: ispInfo.country, description: 'Country code (from IP geolocation)' },
|
||
);
|
||
}
|
||
if (multiWan && allIps) {
|
||
macros.push({ macro: '{$MULTI_WAN_IPS}', value: allIps.join(', '), description: 'All public IPs seen (multi-WAN site)' });
|
||
}
|
||
|
||
// Build tags: for dashboard filtering and problem correlation
|
||
const sourceTag = source ?? 'datto-rmm';
|
||
const tags: ZabbixHostTag[] = [{ tag: 'source', value: sourceTag }];
|
||
if (companyName) {
|
||
tags.push({ tag: 'client', value: companyName });
|
||
}
|
||
if (ispInfo?.isp) {
|
||
tags.push({ tag: 'isp', value: ispInfo.isp });
|
||
}
|
||
if (ispInfo?.asn) {
|
||
tags.push({ tag: 'asn', value: ispInfo.asn });
|
||
}
|
||
if (multiWan) {
|
||
tags.push({ tag: 'multi-wan', value: 'true' });
|
||
}
|
||
if (singleDeviceFallback) {
|
||
tags.push({ tag: 'single-device-fallback', value: 'true' });
|
||
}
|
||
|
||
const descParts = [
|
||
onlineDeviceCount != null
|
||
? `Datto RMM site – WAN IP from ${onlineDeviceCount} online devices`
|
||
: `Manual host – WAN IP ${wanIp}`,
|
||
ispInfo ? `ISP: ${ispInfo.isp} (${ispInfo.asn}) — ${ispInfo.city}, ${ispInfo.region}, ${ispInfo.country}` : null,
|
||
multiWan && allIps ? `Multi-WAN detected: ${allIps.join(', ')}` : null,
|
||
singleDeviceFallback ? `Note: IP sourced from single device (no multi-device confirmation)` : null,
|
||
].filter(Boolean).join('\n');
|
||
|
||
return {
|
||
host: sanitizeHostname(siteName),
|
||
name: siteName,
|
||
description: descParts,
|
||
interfaces: [{ type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050' }],
|
||
groups,
|
||
templates,
|
||
macros: macros.length > 0 ? macros : undefined,
|
||
tags,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Discover ICMP template — tries multiple common names
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ICMP_TEMPLATE_NAMES = [
|
||
'ICMP Ping',
|
||
'Template Module ICMP Ping',
|
||
'Template Module ICMP Ping by Zabbix agent',
|
||
];
|
||
|
||
export async function discoverIcmpTemplate(zabbix: ZabbixClient): Promise<string | null> {
|
||
for (const name of ICMP_TEMPLATE_NAMES) {
|
||
const tmpl = await zabbix.findTemplate(name);
|
||
if (tmpl) return tmpl.templateid;
|
||
}
|
||
return null;
|
||
}
|