'use client'; import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { ArrowLeft, Loader2, Play, Globe, CheckCircle2, XCircle, AlertTriangle, MinusCircle, Filter, RefreshCw, Building2, Server, GitFork, Plus, ChevronDown, ChevronRight, Network, ShieldAlert, Layers, BookOpen, Activity, Clipboard, ClipboardCheck, } from 'lucide-react'; import { toast } from 'sonner'; import { HostManager } from '@/components/zabbix/host-manager'; import { PageHeader } from '@/components/navigation/page-header'; type SyncMode = 'all' | 'client' | 'site'; 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: 'created' | 'updated' | 'filtered' | 'no-ip' | 'error' | 'skipped'; hostId: string | null; filterReason?: string; error?: string; } interface Mapping { company_id: number; company_name: string; rmm_site_uid: string; rmm_site_name: string; } interface Stats { total: number; created: number; updated: number; filtered: number; noIp: number; errors: number; skipped: number; multiWan: number; } const ACTION_CONFIG: Record = { created: { label: 'Created', variant: 'default', icon: CheckCircle2 }, updated: { label: 'Updated', variant: 'secondary', icon: RefreshCw }, filtered: { label: 'Filtered', variant: 'outline', icon: Filter }, 'no-ip': { label: 'No IP', variant: 'outline', icon: MinusCircle }, skipped: { label: 'Dry Run', variant: 'outline', icon: MinusCircle }, error: { label: 'Error', variant: 'destructive', icon: XCircle }, }; function ActionBadge({ action }: { action: string }) { const cfg = ACTION_CONFIG[action] ?? { label: action, variant: 'outline' as const, icon: MinusCircle }; const Icon = cfg.icon; return ( {cfg.label} ); } // ───────────────────────────────────────────────────────────────────────────── // Gap Analysis types // ───────────────────────────────────────────────────────────────────────────── interface GapSummary { total_rmm_sites: string; total_zabbix_hosts: string; zabbix_enabled: string; rmm_sites_no_zabbix: string; total_itg_circuits: string; itg_circuits_monitored: string; itg_circuits_gap: string; zabbix_last_synced: string | null; itg_last_synced: string | null; } interface RmmGap { rmm_site_uid: string; rmm_site_name: string; company_name: string; company_id: number; number_of_devices: number; number_of_online_devices: number; } interface ItgGap { itg_asset_id: number; org_name: string; autotask_company_id: number | null; provider: string; link_type: string; static_ips: string[]; location_name: string; location_city: string; upload_mbps: number; download_mbps: number; } interface MultiCircuit { org_name: string; autotask_company_id: number | null; total_circuits: string; monitored_circuits: string; gap_circuits: string; circuits: Array<{ id: number; provider: string; link_type: string; static_ips: string[]; location_name: string; zabbix_hostid: string | null; is_decommissioned: boolean; }>; } interface ProblemRow { hostid: string; display_name: string; wan_ip: string; isp_name: string; autotask_company_name: string; autotask_company_id: number; rmm_site_uid: string; last_problem_at: string; last_problem_name: string; open_rmm_alerts_24h: string; latest_rmm_network_alert: string | null; monitor_tickets_24h: string; } // ───────────────────────────────────────────────────────────────────────────── // Correlation types // ───────────────────────────────────────────────────────────────────────────── interface CorrSummary { days: number; window_mins: number; zabbix_events_total: number; zabbix_with_rmm_match: number; zabbix_without_rmm_match: number; rmm_only_alerts: number; last_event_sync: string | null; } interface RmmAlertRef { 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; } interface ZabbixEventRow { 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: RmmAlertRef[] | null; } interface RmmOnlyRow { 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; } type PageTab = 'sync' | 'gaps' | 'correlation'; export default function ZabbixWanPage() { const [activeTab, setActiveTab] = useState('sync'); const [mode, setMode] = useState('all'); const [companyId, setCompanyId] = useState(''); const [siteUid, setSiteUid] = useState(''); const [minDevices, setMinDevices] = useState(2); const [maxLastSeenHours, setMaxLastSeenHours] = useState(48); const [allowSingleDevice, setAllowSingleDevice] = useState(false); const [dryRun, setDryRun] = useState(true); const [mappings, setMappings] = useState([]); const [loadingMappings, setLoadingMappings] = useState(true); const [running, setRunning] = useState(false); const [results, setResults] = useState([]); const [stats, setStats] = useState(null); const [fatalError, setFatalError] = useState(null); const abortRef = useRef(null); const tableBottomRef = useRef(null); // Gap analysis state const [gapLoading, setGapLoading] = useState(false); const [syncing, setSyncing] = useState<'zabbix' | 'itg' | null>(null); const [gapSummary, setGapSummary] = useState(null); const [rmmGaps, setRmmGaps] = useState([]); const [itgGaps, setItgGaps] = useState([]); const [multiCircuit, setMultiCircuit] = useState([]); const [problems, setProblems] = useState([]); const [gapError, setGapError] = useState(null); const [expandedMulti, setExpandedMulti] = useState>(new Set()); const loadGapAnalysis = async () => { setGapLoading(true); setGapError(null); try { const r = await fetch('/api/zabbix/wan-gap-analysis'); const d = await r.json(); if (!r.ok) throw new Error(d.error); setGapSummary(d.summary); setRmmGaps(d.rmm_gaps ?? []); setItgGaps(d.itg_gaps ?? []); setMultiCircuit(d.multi_circuit ?? []); setProblems(d.problems ?? []); } catch (e) { setGapError(String(e)); } finally { setGapLoading(false); } }; const syncZabbixHosts = async () => { setSyncing('zabbix'); try { const r = await fetch('/api/zabbix/sync-hosts', { method: 'POST' }); const d = await r.json(); if (!r.ok) throw new Error(d.error); toast.success(`Zabbix hosts synced — ${d.upserted} upserted, ${d.removed} removed`); await loadGapAnalysis(); } catch (e) { toast.error('Zabbix sync failed: ' + String(e)); } finally { setSyncing(null); } }; const syncItgCircuits = async () => { setSyncing('itg'); try { const r = await fetch('/api/itglue/sync-wan', { method: 'POST' }); const d = await r.json(); if (!r.ok) throw new Error(d.error); toast.success(`IT Glue WAN synced — ${d.upserted} circuits, ${d.matched_zabbix} matched to Zabbix`); await loadGapAnalysis(); } catch (e) { toast.error('IT Glue sync failed: ' + String(e)); } finally { setSyncing(null); } }; const fmtSynced = (ts: string | null) => ts ? new Date(ts).toLocaleString() : 'Never'; const fmtTs = (ts: string | null) => ts ? new Date(ts).toLocaleString() : '—'; const fmtDuration = (s: number | null, hasRecovery?: boolean) => { if (s === null || s === undefined) return hasRecovery ? '< 1m' : 'Open'; if (s === 0) return hasRecovery ? '< 1m' : 'Open'; if (s < 60) return `${s}s`; if (s < 3600) return `${Math.round(s/60)}m`; return `${Math.floor(s/3600)}h ${Math.round((s%3600)/60)}m`; }; const severityLabel = (s: number) => ['','Info','Warning','Average','High','Disaster'][s] ?? String(s); const severityClass = (s: number) => s >= 4 ? 'text-red-600 font-semibold' : s === 3 ? 'text-orange-500' : 'text-yellow-500'; // Correlation state const [corrDays, setCorrDays] = useState(30); const [corrWindow, setCorrWindow] = useState(120); const [corrLoading, setCorrLoading] = useState(false); const [corrSummary, setCorrSummary] = useState(null); const [zabbixEvents, setZabbixEvents] = useState([]); const [rmmOnly, setRmmOnly] = useState([]); const [corrError, setCorrError] = useState(null); const [expandedEvent, setExpandedEvent] = useState>(new Set()); const [corrFilter, setCorrFilter] = useState<'all' | 'matched' | 'unmatched'>('all'); const [webhookConfig, setWebhookConfig] = useState<{ script: string; parameters: Array<{name:string;value:string}>; webhook_url: string } | null>(null); const [copied, setCopied] = useState<'script'|'url'|null>(null); const [webhookCollapsed, setWebhookCollapsed] = useState(true); const [rmmOnlyCollapsed, setRmmOnlyCollapsed] = useState(true); const loadWebhookConfig = async () => { try { const r = await fetch('/api/zabbix/webhook'); const d = await r.json(); setWebhookConfig(d); } catch { /* ignore */ } }; const copyText = async (text: string, key: 'script'|'url') => { await navigator.clipboard.writeText(text); setCopied(key); setTimeout(() => setCopied(null), 2000); }; const loadCorrelation = async () => { setCorrLoading(true); setCorrError(null); try { const r = await fetch(`/api/zabbix/alert-correlation?days=${corrDays}&windowMins=${corrWindow}`); const d = await r.json(); if (!r.ok) throw new Error(d.error); setCorrSummary(d.summary); setZabbixEvents(d.zabbix_events ?? []); setRmmOnly(d.rmm_only ?? []); } catch (e) { setCorrError(String(e)); } finally { setCorrLoading(false); } }; const filteredEvents = zabbixEvents.filter(e => corrFilter === 'all' ? true : corrFilter === 'matched' ? Number(e.rmm_alert_count) > 0 : Number(e.rmm_alert_count) === 0 ); // Manual host creation state const [manualOpen, setManualOpen] = useState(false); const [manualIp, setManualIp] = useState(''); const [manualSiteName, setManualSiteName] = useState(''); const [manualCompanyId, setManualCompanyId] = useState(''); const [manualDryRun, setManualDryRun] = useState(true); const [manualRunning, setManualRunning] = useState(false); const [manualResult, setManualResult] = useState<{ action: string; dryRun: boolean; siteName: string; ip: string; companyName: string | null; isp: string | null; asn: string | null; hostId: string | null; error?: string; } | null>(null); useEffect(() => { fetch('/api/rmm/site-mappings') .then((r) => r.json()) .then((d) => setMappings(d.mappings ?? [])) .catch(() => toast.error('Failed to load site mappings')) .finally(() => setLoadingMappings(false)); // Pre-fill manual IP with the user's current public IP (client-side) fetch('https://ipinfo.io/json') .then((r) => r.json()) .then((d) => { if (d.ip) setManualIp(d.ip); }) .catch(() => { /* ignore */ }); }, []); // Scroll results table as rows stream in useEffect(() => { if (running) tableBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [results.length, running]); // Deduplicated company list const companies = Array.from( new Map(mappings.filter((m) => m.company_id).map((m) => [m.company_id, m.company_name])).entries() ) .map(([id, name]) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name)); // Sites list (for site mode) — sorted by name const sites = [...mappings].sort((a, b) => a.rmm_site_name.localeCompare(b.rmm_site_name)); // Sites filtered by selected company (for client mode label display) const selectedCompanyName = companies.find((c) => String(c.id) === companyId)?.name; const selectedSiteName = sites.find((s) => s.rmm_site_uid === siteUid)?.rmm_site_name; const canRun = !running && !loadingMappings && (mode === 'all' || (mode === 'client' && !!companyId) || (mode === 'site' && !!siteUid)); const handleRun = async () => { setRunning(true); setResults([]); setStats(null); setFatalError(null); const ctrl = new AbortController(); abortRef.current = ctrl; try { const resp = await fetch('/api/zabbix/sync-wan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode, companyId: companyId ? Number(companyId) : undefined, siteUid: siteUid || undefined, minDevices, maxLastSeenHours, allowSingleDevice, dryRun, }), signal: ctrl.signal, }); if (!resp.ok || !resp.body) { throw new Error(`Server error: ${resp.status}`); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop() ?? ''; for (const line of lines) { if (!line.trim()) continue; try { const msg = JSON.parse(line); if (msg.type === 'site' && msg.result) { setResults((prev) => [...prev, msg.result]); } else if (msg.type === 'summary') { setStats(msg.stats); } else if (msg.type === 'error') { setFatalError(msg.message); toast.error(msg.message); } } catch { /* skip malformed line */ } } } } catch (err: any) { if (err.name !== 'AbortError') { setFatalError(String(err)); toast.error('Run failed: ' + String(err)); } } finally { setRunning(false); abortRef.current = null; } }; const handleStop = () => { abortRef.current?.abort(); setRunning(false); }; // Manual host creation const ipv4Valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(manualIp); const canCreateManual = !manualRunning && manualIp.trim() !== '' && manualSiteName.trim() !== '' && ipv4Valid; const handleManualCreate = async () => { setManualRunning(true); setManualResult(null); try { const resp = await fetch('/api/zabbix/create-host', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip: manualIp.trim(), siteName: manualSiteName.trim(), companyId: manualCompanyId && manualCompanyId !== 'none' ? Number(manualCompanyId) : undefined, dryRun: manualDryRun, }), }); const data = await resp.json(); if (!resp.ok) { setManualResult({ action: 'error', dryRun: manualDryRun, siteName: manualSiteName, ip: manualIp, companyName: null, isp: null, asn: null, hostId: null, error: data.error }); toast.error(data.error ?? 'Failed to create host'); } else { setManualResult(data); if (data.action === 'created') toast.success(`Host "${data.siteName}" created (id=${data.hostId})`); else if (data.action === 'updated') toast.success(`Host "${data.siteName}" updated (id=${data.hostId})`); else if (data.action === 'skipped') toast.info('Dry run — no changes written to Zabbix'); } } catch (err) { setManualResult({ action: 'error', dryRun: manualDryRun, siteName: manualSiteName, ip: manualIp, companyName: null, isp: null, asn: null, hostId: null, error: String(err) }); toast.error('Request failed: ' + String(err)); } finally { setManualRunning(false); } }; return ( <>
{/* Tabs */}
{([['sync', Globe, 'WAN Sync'], ['gaps', ShieldAlert, 'Gap Analysis'], ['correlation', Activity, 'Alert Correlation']] as const).map(([tab, Icon, label]) => ( ))}
{/* ── WAN Sync tab ─────────────────────────────────────────────────── */} {activeTab === 'sync' && (<> {/* Config card */} Run Configuration Select scope, filters, and whether to write to Zabbix {/* Mode */}
{(['all', 'client', 'site'] as SyncMode[]).map((m) => ( ))}
{/* Client selector */} {mode === 'client' && (
)} {/* Site selector */} {mode === 'site' && (
)} {/* Filters */}
setMinDevices(Math.max(1, Number(e.target.value)))} className="w-24" /> device(s)

Skip site if fewer than this many devices share the top WAN IP

setMaxLastSeenHours(Math.max(1, Number(e.target.value)))} className="w-24" /> hours

Only count devices seen within this window

{/* Single-device fallback */}

If laptop exclusion removes all IPs, accept any single device (desktop, server, network, etc.)

{/* Dry-run + actions */}

Preview what would happen — no writes to Zabbix

{running && ( )}
{/* Manual Host Creation */} setManualOpen((v) => !v)} >
{manualOpen ? : } Manual Host
Create a Zabbix host from an IP address — for testing or clients not in RMM
{manualOpen && (
{/* IP Address */}
setManualIp(e.target.value)} className={`w-56 font-mono ${manualIp && !ipv4Valid ? 'border-destructive' : ''}`} /> {manualIp && !ipv4Valid && (

Enter a valid IPv4 address

)}
{/* Site Name */}
setManualSiteName(e.target.value)} className="w-80" />

Becomes the Zabbix host display name

{/* Client selector */}

Links the host to an Autotask client with macros, tags, and a client host group

{/* Dry-run + actions */}

Preview only — no writes to Zabbix

{/* Manual result */} {manualResult && (
{manualResult.siteName} {manualResult.ip}
{manualResult.companyName && Client: {manualResult.companyName}} {manualResult.isp && ISP: {manualResult.isp}} {manualResult.asn && {manualResult.asn}} {manualResult.hostId && Zabbix ID: {manualResult.hostId}} {manualResult.dryRun && Dry run — no changes written}
{manualResult.error && (

{manualResult.error}

)}
)}
)}
{/* Host Manager */} {/* Results */} {(results.length > 0 || running || fatalError) && (
Results {running && } {results.length} site{results.length !== 1 ? 's' : ''} processed {stats ? '' : running ? '…' : ''} {/* Summary stats */} {stats && (
{stats.created > 0 && {stats.created} created} {stats.updated > 0 && {stats.updated} updated} {stats.skipped > 0 && {stats.skipped} dry-run} {stats.filtered > 0 && {stats.filtered} filtered} {stats.noIp > 0 && {stats.noIp} no-ip} {stats.multiWan > 0 && {stats.multiWan} multi-WAN} {stats.errors > 0 && {stats.errors} errors}
)}
{fatalError && (
{fatalError}
)}
Site Client WAN IP ISP Devices Action Reason Zabbix ID {results.map((r, i) => ( {r.siteName} {r.companyName ?? unmapped}
{r.wanIp ?? } {r.multiWan && (
Multi-WAN
)} {r.singleDeviceFallback && (
Single device
)}
{r.isp ? (
{r.isp} {r.asn &&
{r.asn}
}
) : ( )}
{r.qualifyingDevices > 0 ? r.qualifyingDevices : '—'}
{r.error && (

{r.error}

)}
{r.filterReason ?? '—'} {r.hostId ?? '—'}
))}
)} {/* Empty state */} {results.length === 0 && !running && !fatalError && (
Configure your options above and click {dryRun ? 'Preview' : 'Run'} to start.
)} )} {/* ── Alert Correlation tab ──────────────────────────────────────── */} {activeTab === 'correlation' && (
{/* Webhook setup */} {webhookConfig && ( setWebhookCollapsed(v => !v)}>
Zabbix Webhook Setup {webhookCollapsed ? : }
{webhookCollapsed && Click to expand setup instructions} {!webhookCollapsed && Configure a Webhook media type in Zabbix → Administration → Media types, then create an action that sends to all WAN hosts}
{!webhookCollapsed &&
{webhookConfig.webhook_url}

Media type script

{webhookConfig.script}

Parameters to add

{webhookConfig.parameters.map(p => (
{p.name} {p.value}
))}
}
)} {/* Controls */}
Alert Correlation Compare Zabbix WAN events against Datto RMM ping/offline alerts for the same company and time window
setCorrDays(Math.max(1,Number(e.target.value)))} className="w-20" /> days
setCorrWindow(Math.max(5,Number(e.target.value)))} className="w-20" /> min ±
{corrSummary && ( <>

Last event sync: {fmtSynced(corrSummary.last_event_sync)}

{corrSummary.zabbix_events_total}

Zabbix events

{corrSummary.zabbix_with_rmm_match}

Zabbix + RMM correlated

{corrSummary.zabbix_without_rmm_match}

Zabbix only (no RMM match)

{corrSummary.rmm_only_alerts}

RMM only (no Zabbix event)

)}
{corrError && (
{corrError}
)} {/* Zabbix events table */} {(filteredEvents.length > 0 || corrSummary) && (
Zabbix WAN Events
{(['all','matched','unmatched'] as const).map(f => ( ))}
Host / Client WAN IP Problem Severity Started Duration RMM Alerts {filteredEvents.map(ev => { const matched = Number(ev.rmm_alert_count) > 0; const expanded = expandedEvent.has(ev.eventid); const alerts: RmmAlertRef[] = ev.rmm_alerts ?? []; return (<> setExpandedEvent(prev => { const n = new Set(prev); expanded ? n.delete(ev.eventid) : n.add(ev.eventid); return n; })} >

{ev.host_name}

{ev.autotask_company_name}

{ev.wan_ip} {ev.name} {severityLabel(ev.severity)} {fmtTs(ev.clock)} {fmtDuration(ev.duration_seconds, !!ev.r_clock)} {matched ? {ev.rmm_alert_count} : 0}
{expanded && alerts.length > 0 && alerts.map(a => ( {a.device_name && {a.device_name}} · {a.site_name} {a.alert_class} {a.alert_message ?? '—'} {fmtTs(a.timestamp)} {a.resolved ? 'Resolved' : 'Open'} ))} {expanded && alerts.length === 0 && ( No RMM alerts found within ±{corrWindow}min for this company )} ); })}
)} {/* RMM-only alerts */} {rmmOnly.length > 0 && ( setRmmOnlyCollapsed(v => !v)}>
RMM-Only Alerts {rmmOnly.length} {rmmOnlyCollapsed ? : }
RMM ping/offline alerts with no matching Zabbix event — potential Zabbix coverage gap
{!rmmOnlyCollapsed &&
Company Site Device Type Message Time Status {rmmOnly.map(a => ( {a.autotask_company_name ?? '—'} {a.site_name} {a.device_name ?? '—'} {a.alert_class} {a.alert_message ?? '—'} {fmtTs(a.timestamp)} {a.resolved ? 'Resolved' : 'Open'} ))}
}
)} {!corrLoading && !corrSummary && !corrError && (
Click Refresh to load correlation data.
)}
)} {/* ── Gap Analysis tab ─────────────────────────────────────────────── */} {activeTab === 'gaps' && (
{/* Sync controls */}
Data Sources Sync Zabbix hosts and IT Glue WAN circuits into local cache, then run gap analysis
{/* Zabbix */}

Zabbix Hosts

Last synced: {fmtSynced(gapSummary?.zabbix_last_synced ?? null)}

{gapSummary && (

Total hosts

{gapSummary.total_zabbix_hosts}

Enabled

{gapSummary.zabbix_enabled}

)}
{/* IT Glue */}

IT Glue WAN Circuits

Last synced: {fmtSynced(gapSummary?.itg_last_synced ?? null)}

{gapSummary && (

Total circuits

{gapSummary.total_itg_circuits}

Monitored

{gapSummary.itg_circuits_monitored}

Gaps

{gapSummary.itg_circuits_gap}

)}
{gapSummary && (

{gapSummary.total_rmm_sites}

RMM sites mapped

0 ? 'text-amber-500' : 'text-green-600'}`}> {gapSummary.rmm_sites_no_zabbix}

RMM sites without Zabbix host

0 ? 'text-amber-500' : 'text-green-600'}`}> {gapSummary.itg_circuits_gap}

IT Glue circuits unmonitored

)}
{gapError && (
{gapError}
)} {/* Current Zabbix problems + RMM correlation */} {problems.length > 0 && ( Active / Recent Problems {problems.length} Zabbix WAN problems in the last 24h correlated with RMM alerts and monitor tickets Host / Client WAN IP ISP Problem RMM Alerts 24h Monitor Tickets 24h Latest RMM Network Alert {problems.map(p => (

{p.display_name}

{p.autotask_company_name}

{p.wan_ip} {p.isp_name ?? '—'} {p.last_problem_name ?? '—'} 0 ? 'destructive' : 'outline'}>{p.open_rmm_alerts_24h} 0 ? 'secondary' : 'outline'}>{p.monitor_tickets_24h} {p.latest_rmm_network_alert ?? None}
))}
)} {/* Multi-circuit companies */} {multiCircuit.length > 0 && ( Multi-Circuit Companies Companies with more than one WAN circuit in IT Glue — check each is covered in Zabbix Company Circuits Monitored Gaps {multiCircuit.map(mc => { const key = mc.org_name; const expanded = expandedMulti.has(key); return (<> 0 ? 'bg-amber-500/5' : ''}`} onClick={() => setExpandedMulti(prev => { const n = new Set(prev); expanded ? n.delete(key) : n.add(key); return n; })} > {mc.org_name} {mc.total_circuits} 0 ? 'default' : 'outline'} className="bg-green-600">{mc.monitored_circuits} {Number(mc.gap_circuits) > 0 ? {mc.gap_circuits} : 0} {expanded ? : } {expanded && mc.circuits.map(c => ( {c.provider} {c.location_name && — {c.location_name}} {c.is_decommissioned && Decommissioned} {c.link_type} {c.static_ips?.join(', ') || '—'} {c.zabbix_hostid ? Monitored : c.is_decommissioned ? Decommissioned : No Zabbix Host } ))} ); })}
)} {/* IT Glue gaps */} {itgGaps.length > 0 && ( IT Glue Circuits Without Zabbix Monitoring {itgGaps.length} Active circuits with documented static IPs in IT Glue that have no matching Zabbix host Organization Provider Type Static IPs Location Speed {itgGaps.map(g => ( {g.org_name} {g.provider ?? '—'} {g.link_type ?? '—'} {g.static_ips?.join(', ') || '—'} {[g.location_name, g.location_city].filter(Boolean).join(', ') || '—'} {g.download_mbps ? `↓${g.download_mbps}` : ''}{g.upload_mbps ? ` ↑${g.upload_mbps} Mbps` : ''} ))}
)} {/* RMM sites without Zabbix */} {rmmGaps.length > 0 && ( RMM Sites Without Zabbix Host {rmmGaps.length} Online RMM sites that have no corresponding Zabbix WAN monitor — run WAN Sync to add them Site Company Devices Online {rmmGaps.map(g => ( {g.rmm_site_name} {g.company_name} {g.number_of_devices} {g.number_of_online_devices} ))}
)} {!gapLoading && gapSummary && rmmGaps.length === 0 && itgGaps.length === 0 && problems.length === 0 && (
All monitored sites and IT Glue circuits are covered in Zabbix.
)} {!gapLoading && !gapSummary && !gapError && (
Sync Zabbix hosts and IT Glue circuits above to run gap analysis.
)}
)}
); }