'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, } from 'lucide-react'; import { toast } from 'sonner'; import { HostManager } from '@/components/zabbix/host-manager'; 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} ); } export default function ZabbixWanPage() { 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); // 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 (
{/* Header */}

Zabbix WAN Monitor Setup

Create or update Zabbix hosts with WAN IPs and Autotask macros for alert routing

{/* 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.
)}
); }