- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts) - Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts) - Add QBO types (lib/types/qbo.ts) - Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect - Add /admin/qbo status and sync management page - Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment) - Add QBO nav link under Admin - Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all - Add CashFlow report type alongside P&L and BalanceSheet - Add NoReportData check to skip empty report months - Add intuit_tid capture in error messages - Add redirect: follow for cluster routing - Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables Also includes earlier work: - Ping flap suppression pipeline step - Ticket digest reports with LLM analysis - Zabbix WAN monitor and gap analysis - Kiosk is_deleted filter fixes - Datto RMM ping target enrichment - Entity sync soft-delete detection
1427 lines
69 KiB
TypeScript
1427 lines
69 KiB
TypeScript
'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';
|
|
|
|
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<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; icon: React.ElementType }> = {
|
|
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 (
|
|
<Badge variant={cfg.variant} className="gap-1 text-xs">
|
|
<Icon className="w-3 h-3" />
|
|
{cfg.label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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<PageTab>('sync');
|
|
const [mode, setMode] = useState<SyncMode>('all');
|
|
const [companyId, setCompanyId] = useState<string>('');
|
|
const [siteUid, setSiteUid] = useState<string>('');
|
|
const [minDevices, setMinDevices] = useState(2);
|
|
const [maxLastSeenHours, setMaxLastSeenHours] = useState(48);
|
|
const [allowSingleDevice, setAllowSingleDevice] = useState(false);
|
|
const [dryRun, setDryRun] = useState(true);
|
|
|
|
const [mappings, setMappings] = useState<Mapping[]>([]);
|
|
const [loadingMappings, setLoadingMappings] = useState(true);
|
|
|
|
const [running, setRunning] = useState(false);
|
|
const [results, setResults] = useState<SiteResult[]>([]);
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [fatalError, setFatalError] = useState<string | null>(null);
|
|
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const tableBottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Gap analysis state
|
|
const [gapLoading, setGapLoading] = useState(false);
|
|
const [syncing, setSyncing] = useState<'zabbix' | 'itg' | null>(null);
|
|
const [gapSummary, setGapSummary] = useState<GapSummary | null>(null);
|
|
const [rmmGaps, setRmmGaps] = useState<RmmGap[]>([]);
|
|
const [itgGaps, setItgGaps] = useState<ItgGap[]>([]);
|
|
const [multiCircuit, setMultiCircuit] = useState<MultiCircuit[]>([]);
|
|
const [problems, setProblems] = useState<ProblemRow[]>([]);
|
|
const [gapError, setGapError] = useState<string | null>(null);
|
|
const [expandedMulti, setExpandedMulti] = useState<Set<string>>(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<CorrSummary | null>(null);
|
|
const [zabbixEvents, setZabbixEvents] = useState<ZabbixEventRow[]>([]);
|
|
const [rmmOnly, setRmmOnly] = useState<RmmOnlyRow[]>([]);
|
|
const [corrError, setCorrError] = useState<string | null>(null);
|
|
const [expandedEvent, setExpandedEvent] = useState<Set<string>>(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<string>('');
|
|
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 (
|
|
<div className="container mx-auto py-8 max-w-6xl space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
|
<Globe className="w-6 h-6" /> Zabbix WAN Monitor Setup
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
Create or update Zabbix hosts with WAN IPs and Autotask macros for alert routing
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 border-b">
|
|
{([['sync', Globe, 'WAN Sync'], ['gaps', ShieldAlert, 'Gap Analysis'], ['correlation', Activity, 'Alert Correlation']] as const).map(([tab, Icon, label]) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => {
|
|
setActiveTab(tab as PageTab);
|
|
if (tab === 'gaps' && !gapSummary) loadGapAnalysis();
|
|
if (tab === 'correlation' && !webhookConfig) loadWebhookConfig();
|
|
}}
|
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === tab
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
<Icon className="w-4 h-4" /> {label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── WAN Sync tab ─────────────────────────────────────────────────── */}
|
|
{activeTab === 'sync' && (<>
|
|
{/* Config card */}
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="text-base">Run Configuration</CardTitle>
|
|
<CardDescription>Select scope, filters, and whether to write to Zabbix</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Mode */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">Scope</Label>
|
|
<div className="flex gap-2">
|
|
{(['all', 'client', 'site'] as SyncMode[]).map((m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => { setMode(m); setCompanyId(''); setSiteUid(''); }}
|
|
className={`px-4 py-2 rounded-md text-sm font-medium border transition-colors ${
|
|
mode === m
|
|
? 'bg-primary text-primary-foreground border-primary'
|
|
: 'bg-background border-border hover:bg-accent'
|
|
}`}
|
|
>
|
|
{m === 'all' ? 'All Sites' : m === 'client' ? 'By Client' : 'Single Site'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Client selector */}
|
|
{mode === 'client' && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium flex items-center gap-1.5">
|
|
<Building2 className="w-3.5 h-3.5" /> Client
|
|
</Label>
|
|
<Select value={companyId} onValueChange={setCompanyId} disabled={loadingMappings}>
|
|
<SelectTrigger className="w-80">
|
|
<SelectValue placeholder={loadingMappings ? 'Loading…' : 'Select a client'} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{companies.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Site selector */}
|
|
{mode === 'site' && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium flex items-center gap-1.5">
|
|
<Server className="w-3.5 h-3.5" /> Site
|
|
</Label>
|
|
<Select value={siteUid} onValueChange={setSiteUid} disabled={loadingMappings}>
|
|
<SelectTrigger className="w-80">
|
|
<SelectValue placeholder={loadingMappings ? 'Loading…' : 'Select a site'} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sites.map((s) => (
|
|
<SelectItem key={s.rmm_site_uid} value={s.rmm_site_uid}>
|
|
{s.rmm_site_name}
|
|
{s.company_name ? ` (${s.company_name})` : ''}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="min-devices" className="text-sm font-medium">
|
|
Min devices with same public IP
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
id="min-devices"
|
|
type="number"
|
|
min={1}
|
|
max={50}
|
|
value={minDevices}
|
|
onChange={(e) => setMinDevices(Math.max(1, Number(e.target.value)))}
|
|
className="w-24"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">device(s)</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Skip site if fewer than this many devices share the top WAN IP
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="last-seen" className="text-sm font-medium">
|
|
Max device last-seen age
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
id="last-seen"
|
|
type="number"
|
|
min={1}
|
|
max={8760}
|
|
value={maxLastSeenHours}
|
|
onChange={(e) => setMaxLastSeenHours(Math.max(1, Number(e.target.value)))}
|
|
className="w-24"
|
|
/>
|
|
<span className="text-sm text-muted-foreground">hours</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Only count devices seen within this window
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Single-device fallback */}
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
id="allow-single"
|
|
checked={allowSingleDevice}
|
|
onCheckedChange={setAllowSingleDevice}
|
|
/>
|
|
<div>
|
|
<Label htmlFor="allow-single" className="text-sm font-medium cursor-pointer">
|
|
Allow single-device fallback
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
If laptop exclusion removes all IPs, accept any single device (desktop, server, network, etc.)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dry-run + actions */}
|
|
<div className="flex items-center justify-between pt-2 border-t">
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
id="dry-run"
|
|
checked={dryRun}
|
|
onCheckedChange={setDryRun}
|
|
/>
|
|
<div>
|
|
<Label htmlFor="dry-run" className="text-sm font-medium cursor-pointer">
|
|
Dry run
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Preview what would happen — no writes to Zabbix
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{running && (
|
|
<Button variant="outline" size="sm" onClick={handleStop}>
|
|
Stop
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={handleRun}
|
|
disabled={!canRun}
|
|
className="gap-2"
|
|
>
|
|
{running ? (
|
|
<><Loader2 className="w-4 h-4 animate-spin" /> Running…</>
|
|
) : (
|
|
<><Play className="w-4 h-4" /> {dryRun ? 'Preview' : 'Run'}</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Manual Host Creation */}
|
|
<Card>
|
|
<CardHeader
|
|
className="pb-4 cursor-pointer select-none"
|
|
onClick={() => setManualOpen((v) => !v)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{manualOpen ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
|
<Network className="w-4 h-4" />
|
|
<CardTitle className="text-base">Manual Host</CardTitle>
|
|
</div>
|
|
<CardDescription className="mt-0">Create a Zabbix host from an IP address — for testing or clients not in RMM</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
{manualOpen && (
|
|
<CardContent className="space-y-5 pt-0">
|
|
<div className="grid grid-cols-2 gap-6">
|
|
{/* IP Address */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="manual-ip" className="text-sm font-medium">IP Address</Label>
|
|
<Input
|
|
id="manual-ip"
|
|
placeholder="203.0.113.42"
|
|
value={manualIp}
|
|
onChange={(e) => setManualIp(e.target.value)}
|
|
className={`w-56 font-mono ${manualIp && !ipv4Valid ? 'border-destructive' : ''}`}
|
|
/>
|
|
{manualIp && !ipv4Valid && (
|
|
<p className="text-xs text-destructive">Enter a valid IPv4 address</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Site Name */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="manual-site" className="text-sm font-medium">Site Name</Label>
|
|
<Input
|
|
id="manual-site"
|
|
placeholder="Acme Corp - Main Office"
|
|
value={manualSiteName}
|
|
onChange={(e) => setManualSiteName(e.target.value)}
|
|
className="w-80"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">Becomes the Zabbix host display name</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Client selector */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium flex items-center gap-1.5">
|
|
<Building2 className="w-3.5 h-3.5" /> Client (optional)
|
|
</Label>
|
|
<Select value={manualCompanyId} onValueChange={setManualCompanyId} disabled={loadingMappings}>
|
|
<SelectTrigger className="w-80">
|
|
<SelectValue placeholder={loadingMappings ? 'Loading…' : 'No client (unlinked)'} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">No client (unlinked)</SelectItem>
|
|
{companies.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Links the host to an Autotask client with macros, tags, and a client host group
|
|
</p>
|
|
</div>
|
|
|
|
{/* Dry-run + actions */}
|
|
<div className="flex items-center justify-between pt-2 border-t">
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
id="manual-dry-run"
|
|
checked={manualDryRun}
|
|
onCheckedChange={setManualDryRun}
|
|
/>
|
|
<div>
|
|
<Label htmlFor="manual-dry-run" className="text-sm font-medium cursor-pointer">
|
|
Dry run
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Preview only — no writes to Zabbix
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleManualCreate}
|
|
disabled={!canCreateManual}
|
|
className="gap-2"
|
|
>
|
|
{manualRunning ? (
|
|
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
|
|
) : (
|
|
<><Plus className="w-4 h-4" /> {manualDryRun ? 'Preview' : 'Create Host'}</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Manual result */}
|
|
{manualResult && (
|
|
<div className={`rounded-md border p-4 space-y-2 ${
|
|
manualResult.action === 'error'
|
|
? 'border-destructive/30 bg-destructive/5'
|
|
: manualResult.action === 'created'
|
|
? 'border-green-500/30 bg-green-500/5'
|
|
: manualResult.action === 'updated'
|
|
? 'border-blue-500/30 bg-blue-500/5'
|
|
: 'border-border bg-muted/20'
|
|
}`}>
|
|
<div className="flex items-center gap-3">
|
|
<ActionBadge action={manualResult.action} />
|
|
<span className="font-medium text-sm">{manualResult.siteName}</span>
|
|
<span className="font-mono text-sm text-muted-foreground">{manualResult.ip}</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
|
{manualResult.companyName && <span>Client: <strong>{manualResult.companyName}</strong></span>}
|
|
{manualResult.isp && <span>ISP: {manualResult.isp}</span>}
|
|
{manualResult.asn && <span>{manualResult.asn}</span>}
|
|
{manualResult.hostId && <span>Zabbix ID: <strong className="font-mono">{manualResult.hostId}</strong></span>}
|
|
{manualResult.dryRun && <span className="italic">Dry run — no changes written</span>}
|
|
</div>
|
|
{manualResult.error && (
|
|
<p className="text-xs text-destructive">{manualResult.error}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Host Manager */}
|
|
<HostManager companies={companies} />
|
|
|
|
{/* Results */}
|
|
{(results.length > 0 || running || fatalError) && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
Results
|
|
{running && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
{results.length} site{results.length !== 1 ? 's' : ''} processed
|
|
{stats ? '' : running ? '…' : ''}
|
|
</span>
|
|
</CardTitle>
|
|
|
|
{/* Summary stats */}
|
|
{stats && (
|
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
{stats.created > 0 && <span className="text-green-600 font-medium">{stats.created} created</span>}
|
|
{stats.updated > 0 && <span className="text-blue-600 font-medium">{stats.updated} updated</span>}
|
|
{stats.skipped > 0 && <span>{stats.skipped} dry-run</span>}
|
|
{stats.filtered > 0 && <span className="text-yellow-600">{stats.filtered} filtered</span>}
|
|
{stats.noIp > 0 && <span>{stats.noIp} no-ip</span>}
|
|
{stats.multiWan > 0 && <span className="text-orange-500 font-medium">{stats.multiWan} multi-WAN</span>}
|
|
{stats.errors > 0 && <span className="text-red-600 font-medium">{stats.errors} errors</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{fatalError && (
|
|
<div className="flex items-center gap-2 text-sm text-destructive rounded-md border border-destructive/30 bg-destructive/5 p-3 mt-2">
|
|
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
|
{fatalError}
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-0">
|
|
<div className="max-h-[520px] overflow-y-auto">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 bg-background z-10">
|
|
<TableRow>
|
|
<TableHead>Site</TableHead>
|
|
<TableHead>Client</TableHead>
|
|
<TableHead>WAN IP</TableHead>
|
|
<TableHead>ISP</TableHead>
|
|
<TableHead className="text-center">Devices</TableHead>
|
|
<TableHead>Action</TableHead>
|
|
<TableHead>Reason</TableHead>
|
|
<TableHead>Zabbix ID</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{results.map((r, i) => (
|
|
<TableRow key={i} className={r.action === 'error' ? 'bg-destructive/5' : ''}>
|
|
<TableCell className="font-medium text-sm">{r.siteName}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{r.companyName ?? <span className="italic text-muted-foreground/60">unmapped</span>}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-0.5">
|
|
<span className="font-mono text-sm">
|
|
{r.wanIp ?? <span className="text-muted-foreground">—</span>}
|
|
</span>
|
|
{r.multiWan && (
|
|
<div className="flex items-center gap-1 text-xs text-orange-500">
|
|
<GitFork className="w-3 h-3" /> Multi-WAN
|
|
</div>
|
|
)}
|
|
{r.singleDeviceFallback && (
|
|
<div className="flex items-center gap-1 text-xs text-yellow-500">
|
|
<AlertTriangle className="w-3 h-3" /> Single device
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{r.isp ? (
|
|
<div className="space-y-0.5">
|
|
<span>{r.isp}</span>
|
|
{r.asn && <div className="text-xs text-muted-foreground">{r.asn}</div>}
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-center text-sm tabular-nums">
|
|
{r.qualifyingDevices > 0 ? r.qualifyingDevices : '—'}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-1">
|
|
<ActionBadge action={r.action} />
|
|
{r.error && (
|
|
<p className="text-xs text-destructive leading-tight max-w-[240px] truncate" title={r.error}>
|
|
{r.error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground max-w-[220px]">
|
|
{r.filterReason ?? '—'}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm text-muted-foreground">
|
|
{r.hostId ?? '—'}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
<div ref={tableBottomRef} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{results.length === 0 && !running && !fatalError && (
|
|
<div className="text-center py-16 text-muted-foreground text-sm">
|
|
Configure your options above and click {dryRun ? 'Preview' : 'Run'} to start.
|
|
</div>
|
|
)}
|
|
</>)}
|
|
|
|
{/* ── Alert Correlation tab ──────────────────────────────────────── */}
|
|
{activeTab === 'correlation' && (
|
|
<div className="space-y-6">
|
|
{/* Webhook setup */}
|
|
{webhookConfig && (
|
|
<Card>
|
|
<CardHeader className="pb-3 cursor-pointer select-none" onClick={() => setWebhookCollapsed(v => !v)}>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base flex items-center gap-2"><Clipboard className="w-4 h-4" /> Zabbix Webhook Setup</CardTitle>
|
|
{webhookCollapsed ? <ChevronRight className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
|
</div>
|
|
{webhookCollapsed && <CardDescription>Click to expand setup instructions</CardDescription>}
|
|
{!webhookCollapsed && <CardDescription>Configure a Webhook media type in Zabbix → Administration → Media types, then create an action that sends to all WAN hosts</CardDescription>}
|
|
</CardHeader>
|
|
{!webhookCollapsed && <CardContent className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<code className="flex-1 bg-muted rounded px-3 py-2 text-sm font-mono truncate">{webhookConfig.webhook_url}</code>
|
|
<Button variant="outline" size="sm" onClick={() => copyText(webhookConfig.webhook_url, 'url')} className="gap-1.5 shrink-0">
|
|
{copied === 'url' ? <ClipboardCheck className="w-3.5 h-3.5 text-green-500" /> : <Clipboard className="w-3.5 h-3.5" />}
|
|
{copied === 'url' ? 'Copied' : 'Copy URL'}
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-medium">Media type script</p>
|
|
<Button variant="outline" size="sm" onClick={() => copyText(webhookConfig.script, 'script')} className="gap-1.5">
|
|
{copied === 'script' ? <ClipboardCheck className="w-3.5 h-3.5 text-green-500" /> : <Clipboard className="w-3.5 h-3.5" />}
|
|
{copied === 'script' ? 'Copied' : 'Copy Script'}
|
|
</Button>
|
|
</div>
|
|
<pre className="bg-muted rounded p-3 text-xs font-mono overflow-x-auto max-h-40 leading-relaxed">{webhookConfig.script}</pre>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<p className="text-sm font-medium">Parameters to add</p>
|
|
<div className="border rounded divide-y text-xs">
|
|
{webhookConfig.parameters.map(p => (
|
|
<div key={p.name} className="flex items-center px-3 py-1.5 gap-4">
|
|
<span className="font-mono w-36 shrink-0 text-muted-foreground">{p.name}</span>
|
|
<span className="font-mono">{p.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>}
|
|
</Card>
|
|
)}
|
|
|
|
{/* Controls */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-base flex items-center gap-2"><Activity className="w-4 h-4" /> Alert Correlation</CardTitle>
|
|
<CardDescription>Compare Zabbix WAN events against Datto RMM ping/offline alerts for the same company and time window</CardDescription>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={loadCorrelation} disabled={corrLoading} className="gap-2">
|
|
{corrLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />} Refresh
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-end gap-6">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">Look-back period</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input type="number" min={1} max={90} value={corrDays} onChange={e => setCorrDays(Math.max(1,Number(e.target.value)))} className="w-20" />
|
|
<span className="text-sm text-muted-foreground">days</span>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">Match window</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input type="number" min={5} max={480} value={corrWindow} onChange={e => setCorrWindow(Math.max(5,Number(e.target.value)))} className="w-20" />
|
|
<span className="text-sm text-muted-foreground">min ±</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{corrSummary && (
|
|
<>
|
|
<p className="text-xs text-muted-foreground">Last event sync: {fmtSynced(corrSummary.last_event_sync)}</p>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<div className="border rounded-lg p-3 text-center"><p className="text-2xl font-bold">{corrSummary.zabbix_events_total}</p><p className="text-xs text-muted-foreground mt-0.5">Zabbix events</p></div>
|
|
<div className="border rounded-lg p-3 text-center bg-green-500/5">
|
|
<p className="text-2xl font-bold text-green-600">{corrSummary.zabbix_with_rmm_match}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Zabbix + RMM correlated</p>
|
|
</div>
|
|
<div className="border rounded-lg p-3 text-center bg-amber-500/5">
|
|
<p className="text-2xl font-bold text-amber-600">{corrSummary.zabbix_without_rmm_match}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Zabbix only (no RMM match)</p>
|
|
</div>
|
|
<div className="border rounded-lg p-3 text-center bg-blue-500/5">
|
|
<p className="text-2xl font-bold text-blue-600">{corrSummary.rmm_only_alerts}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">RMM only (no Zabbix event)</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{corrError && (
|
|
<div className="flex items-center gap-2 text-sm text-destructive border border-destructive/30 bg-destructive/5 rounded-md p-3">
|
|
<AlertTriangle className="w-4 h-4" /> {corrError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Zabbix events table */}
|
|
{(filteredEvents.length > 0 || corrSummary) && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base flex items-center gap-2"><Globe className="w-4 h-4" /> Zabbix WAN Events</CardTitle>
|
|
<div className="flex gap-1">
|
|
{(['all','matched','unmatched'] as const).map(f => (
|
|
<button key={f} onClick={() => setCorrFilter(f)}
|
|
className={`px-3 py-1 rounded text-xs font-medium border transition-colors ${
|
|
corrFilter === f ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-accent'
|
|
}`}>
|
|
{f === 'all' ? `All (${zabbixEvents.length})` : f === 'matched' ? `RMM match (${corrSummary?.zabbix_with_rmm_match ?? 0})` : `No RMM (${corrSummary?.zabbix_without_rmm_match ?? 0})`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="max-h-[600px] overflow-y-auto">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 bg-background z-10">
|
|
<TableRow>
|
|
<TableHead>Host / Client</TableHead>
|
|
<TableHead>WAN IP</TableHead>
|
|
<TableHead>Problem</TableHead>
|
|
<TableHead>Severity</TableHead>
|
|
<TableHead>Started</TableHead>
|
|
<TableHead>Duration</TableHead>
|
|
<TableHead className="text-center">RMM Alerts</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredEvents.map(ev => {
|
|
const matched = Number(ev.rmm_alert_count) > 0;
|
|
const expanded = expandedEvent.has(ev.eventid);
|
|
const alerts: RmmAlertRef[] = ev.rmm_alerts ?? [];
|
|
return (<>
|
|
<TableRow
|
|
key={ev.eventid}
|
|
className={`cursor-pointer ${matched ? 'hover:bg-green-500/5' : 'hover:bg-amber-500/5 bg-amber-500/[0.03]'}`}
|
|
onClick={() => setExpandedEvent(prev => { const n = new Set(prev); expanded ? n.delete(ev.eventid) : n.add(ev.eventid); return n; })}
|
|
>
|
|
<TableCell>
|
|
<p className="font-medium text-sm">{ev.host_name}</p>
|
|
<p className="text-xs text-muted-foreground">{ev.autotask_company_name}</p>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">{ev.wan_ip}</TableCell>
|
|
<TableCell className="text-sm max-w-[200px] truncate" title={ev.name}>{ev.name}</TableCell>
|
|
<TableCell><span className={`text-sm ${severityClass(ev.severity)}`}>{severityLabel(ev.severity)}</span></TableCell>
|
|
<TableCell className="text-sm">{fmtTs(ev.clock)}</TableCell>
|
|
<TableCell className="text-sm tabular-nums">{fmtDuration(ev.duration_seconds, !!ev.r_clock)}</TableCell>
|
|
<TableCell className="text-center">
|
|
{matched
|
|
? <Badge variant="default" className="bg-green-600">{ev.rmm_alert_count}</Badge>
|
|
: <Badge variant="outline" className="text-amber-600 border-amber-400">0</Badge>}
|
|
</TableCell>
|
|
</TableRow>
|
|
{expanded && alerts.length > 0 && alerts.map(a => (
|
|
<TableRow key={a.alert_uid} className="bg-green-500/5">
|
|
<TableCell className="pl-8 text-xs text-muted-foreground" colSpan={2}>
|
|
{a.device_name && <span className="font-medium text-foreground">{a.device_name}</span>} · {a.site_name}
|
|
</TableCell>
|
|
<TableCell className="text-xs" colSpan={2}>
|
|
<Badge variant="outline" className="text-xs mr-2">{a.alert_class}</Badge>
|
|
<span className="text-muted-foreground truncate max-w-[200px]">{a.alert_message ?? '—'}</span>
|
|
</TableCell>
|
|
<TableCell className="text-xs">{fmtTs(a.timestamp)}</TableCell>
|
|
<TableCell className="text-xs">{a.resolved ? 'Resolved' : 'Open'}</TableCell>
|
|
<TableCell />
|
|
</TableRow>
|
|
))}
|
|
{expanded && alerts.length === 0 && (
|
|
<TableRow className="bg-amber-500/5">
|
|
<TableCell colSpan={7} className="pl-8 text-xs text-muted-foreground italic">No RMM alerts found within ±{corrWindow}min for this company</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</>);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* RMM-only alerts */}
|
|
{rmmOnly.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3 cursor-pointer select-none" onClick={() => setRmmOnlyCollapsed(v => !v)}>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Server className="w-4 h-4 text-blue-500" /> RMM-Only Alerts <Badge variant="outline">{rmmOnly.length}</Badge>
|
|
</CardTitle>
|
|
{rmmOnlyCollapsed ? <ChevronRight className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
|
</div>
|
|
<CardDescription>RMM ping/offline alerts with no matching Zabbix event — potential Zabbix coverage gap</CardDescription>
|
|
</CardHeader>
|
|
{!rmmOnlyCollapsed && <CardContent className="p-0">
|
|
<div className="max-h-[400px] overflow-y-auto">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 bg-background z-10">
|
|
<TableRow>
|
|
<TableHead>Company</TableHead>
|
|
<TableHead>Site</TableHead>
|
|
<TableHead>Device</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Message</TableHead>
|
|
<TableHead>Time</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rmmOnly.map(a => (
|
|
<TableRow key={a.alert_uid}>
|
|
<TableCell className="text-sm font-medium">{a.autotask_company_name ?? '—'}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">{a.site_name}</TableCell>
|
|
<TableCell className="text-sm">{a.device_name ?? '—'}</TableCell>
|
|
<TableCell><Badge variant="outline" className="text-xs">{a.alert_class}</Badge></TableCell>
|
|
<TableCell className="text-xs text-muted-foreground max-w-[220px] truncate" title={a.alert_message ?? ''}>{a.alert_message ?? '—'}</TableCell>
|
|
<TableCell className="text-sm">{fmtTs(a.timestamp)}</TableCell>
|
|
<TableCell><Badge variant={a.resolved ? 'outline' : 'destructive'} className="text-xs">{a.resolved ? 'Resolved' : 'Open'}</Badge></TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>}
|
|
</Card>
|
|
)}
|
|
|
|
{!corrLoading && !corrSummary && !corrError && (
|
|
<div className="text-center py-16 text-muted-foreground text-sm">
|
|
Click <strong>Refresh</strong> to load correlation data.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Gap Analysis tab ─────────────────────────────────────────────── */}
|
|
{activeTab === 'gaps' && (
|
|
<div className="space-y-6">
|
|
{/* Sync controls */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-base flex items-center gap-2"><Layers className="w-4 h-4" /> Data Sources</CardTitle>
|
|
<CardDescription>Sync Zabbix hosts and IT Glue WAN circuits into local cache, then run gap analysis</CardDescription>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={loadGapAnalysis} disabled={gapLoading} className="gap-2">
|
|
{gapLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />} Refresh
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Zabbix */}
|
|
<div className="border rounded-lg p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium text-sm flex items-center gap-2"><Globe className="w-4 h-4" /> Zabbix Hosts</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Last synced: {fmtSynced(gapSummary?.zabbix_last_synced ?? null)}</p>
|
|
</div>
|
|
<Button size="sm" onClick={syncZabbixHosts} disabled={!!syncing} className="gap-2">
|
|
{syncing === 'zabbix' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Sync
|
|
</Button>
|
|
</div>
|
|
{gapSummary && (
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Total hosts</p><p className="font-semibold text-base">{gapSummary.total_zabbix_hosts}</p></div>
|
|
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Enabled</p><p className="font-semibold text-base">{gapSummary.zabbix_enabled}</p></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* IT Glue */}
|
|
<div className="border rounded-lg p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium text-sm flex items-center gap-2"><BookOpen className="w-4 h-4" /> IT Glue WAN Circuits</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Last synced: {fmtSynced(gapSummary?.itg_last_synced ?? null)}</p>
|
|
</div>
|
|
<Button size="sm" onClick={syncItgCircuits} disabled={!!syncing} className="gap-2">
|
|
{syncing === 'itg' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Sync
|
|
</Button>
|
|
</div>
|
|
{gapSummary && (
|
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Total circuits</p><p className="font-semibold text-base">{gapSummary.total_itg_circuits}</p></div>
|
|
<div className="bg-green-500/10 rounded p-2"><p className="text-muted-foreground">Monitored</p><p className="font-semibold text-base text-green-600">{gapSummary.itg_circuits_monitored}</p></div>
|
|
<div className="bg-amber-500/10 rounded p-2"><p className="text-muted-foreground">Gaps</p><p className="font-semibold text-base text-amber-600">{gapSummary.itg_circuits_gap}</p></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{gapSummary && (
|
|
<div className="mt-4 grid grid-cols-3 gap-4 text-sm border-t pt-4">
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold">{gapSummary.total_rmm_sites}</p>
|
|
<p className="text-xs text-muted-foreground">RMM sites mapped</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className={`text-2xl font-bold ${Number(gapSummary.rmm_sites_no_zabbix) > 0 ? 'text-amber-500' : 'text-green-600'}`}>
|
|
{gapSummary.rmm_sites_no_zabbix}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">RMM sites without Zabbix host</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className={`text-2xl font-bold ${Number(gapSummary.itg_circuits_gap) > 0 ? 'text-amber-500' : 'text-green-600'}`}>
|
|
{gapSummary.itg_circuits_gap}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">IT Glue circuits unmonitored</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{gapError && (
|
|
<div className="flex items-center gap-2 text-sm text-destructive border border-destructive/30 bg-destructive/5 rounded-md p-3">
|
|
<AlertTriangle className="w-4 h-4" /> {gapError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Current Zabbix problems + RMM correlation */}
|
|
{problems.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base flex items-center gap-2"><Activity className="w-4 h-4 text-red-500" /> Active / Recent Problems <Badge variant="destructive">{problems.length}</Badge></CardTitle>
|
|
<CardDescription>Zabbix WAN problems in the last 24h correlated with RMM alerts and monitor tickets</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Host / Client</TableHead>
|
|
<TableHead>WAN IP</TableHead>
|
|
<TableHead>ISP</TableHead>
|
|
<TableHead>Problem</TableHead>
|
|
<TableHead className="text-center">RMM Alerts 24h</TableHead>
|
|
<TableHead className="text-center">Monitor Tickets 24h</TableHead>
|
|
<TableHead>Latest RMM Network Alert</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{problems.map(p => (
|
|
<TableRow key={p.hostid} className="bg-red-500/5">
|
|
<TableCell>
|
|
<p className="font-medium text-sm">{p.display_name}</p>
|
|
<p className="text-xs text-muted-foreground">{p.autotask_company_name}</p>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">{p.wan_ip}</TableCell>
|
|
<TableCell className="text-sm">{p.isp_name ?? '—'}</TableCell>
|
|
<TableCell className="text-sm max-w-[200px] truncate" title={p.last_problem_name}>{p.last_problem_name ?? '—'}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge variant={Number(p.open_rmm_alerts_24h) > 0 ? 'destructive' : 'outline'}>{p.open_rmm_alerts_24h}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge variant={Number(p.monitor_tickets_24h) > 0 ? 'secondary' : 'outline'}>{p.monitor_tickets_24h}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground max-w-[220px] truncate" title={p.latest_rmm_network_alert ?? ''}>
|
|
{p.latest_rmm_network_alert ?? <span className="italic">None</span>}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Multi-circuit companies */}
|
|
{multiCircuit.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base flex items-center gap-2"><GitFork className="w-4 h-4" /> Multi-Circuit Companies</CardTitle>
|
|
<CardDescription>Companies with more than one WAN circuit in IT Glue — check each is covered in Zabbix</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Company</TableHead>
|
|
<TableHead className="text-center">Circuits</TableHead>
|
|
<TableHead className="text-center">Monitored</TableHead>
|
|
<TableHead className="text-center">Gaps</TableHead>
|
|
<TableHead></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{multiCircuit.map(mc => {
|
|
const key = mc.org_name;
|
|
const expanded = expandedMulti.has(key);
|
|
return (<>
|
|
<TableRow
|
|
key={key}
|
|
className={`cursor-pointer hover:bg-muted/30 ${Number(mc.gap_circuits) > 0 ? 'bg-amber-500/5' : ''}`}
|
|
onClick={() => setExpandedMulti(prev => { const n = new Set(prev); expanded ? n.delete(key) : n.add(key); return n; })}
|
|
>
|
|
<TableCell className="font-medium text-sm">{mc.org_name}</TableCell>
|
|
<TableCell className="text-center">{mc.total_circuits}</TableCell>
|
|
<TableCell className="text-center"><Badge variant={Number(mc.monitored_circuits) > 0 ? 'default' : 'outline'} className="bg-green-600">{mc.monitored_circuits}</Badge></TableCell>
|
|
<TableCell className="text-center">{Number(mc.gap_circuits) > 0 ? <Badge variant="destructive">{mc.gap_circuits}</Badge> : <Badge variant="outline">0</Badge>}</TableCell>
|
|
<TableCell>{expanded ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}</TableCell>
|
|
</TableRow>
|
|
{expanded && mc.circuits.map(c => (
|
|
<TableRow key={c.id} className="bg-muted/20">
|
|
<TableCell colSpan={2} className="pl-8 text-sm">
|
|
<span className="font-medium">{c.provider}</span>
|
|
{c.location_name && <span className="text-muted-foreground"> — {c.location_name}</span>}
|
|
{c.is_decommissioned && <Badge variant="outline" className="ml-2 text-xs">Decommissioned</Badge>}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">{c.link_type}</TableCell>
|
|
<TableCell className="font-mono text-xs">{c.static_ips?.join(', ') || '—'}</TableCell>
|
|
<TableCell colSpan={2}>
|
|
{c.zabbix_hostid
|
|
? <Badge variant="default" className="bg-green-600 gap-1"><CheckCircle2 className="w-3 h-3" /> Monitored</Badge>
|
|
: c.is_decommissioned
|
|
? <Badge variant="outline" className="gap-1"><MinusCircle className="w-3 h-3" /> Decommissioned</Badge>
|
|
: <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" /> No Zabbix Host</Badge>
|
|
}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</>);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* IT Glue gaps */}
|
|
{itgGaps.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base flex items-center gap-2"><BookOpen className="w-4 h-4 text-amber-500" /> IT Glue Circuits Without Zabbix Monitoring <Badge variant="outline">{itgGaps.length}</Badge></CardTitle>
|
|
<CardDescription>Active circuits with documented static IPs in IT Glue that have no matching Zabbix host</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Organization</TableHead>
|
|
<TableHead>Provider</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Static IPs</TableHead>
|
|
<TableHead>Location</TableHead>
|
|
<TableHead>Speed</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{itgGaps.map(g => (
|
|
<TableRow key={g.itg_asset_id}>
|
|
<TableCell className="font-medium text-sm">{g.org_name}</TableCell>
|
|
<TableCell className="text-sm">{g.provider ?? '—'}</TableCell>
|
|
<TableCell className="text-sm">{g.link_type ?? '—'}</TableCell>
|
|
<TableCell className="font-mono text-xs">{g.static_ips?.join(', ') || '—'}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">{[g.location_name, g.location_city].filter(Boolean).join(', ') || '—'}</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{g.download_mbps ? `↓${g.download_mbps}` : ''}{g.upload_mbps ? ` ↑${g.upload_mbps} Mbps` : ''}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* RMM sites without Zabbix */}
|
|
{rmmGaps.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base flex items-center gap-2"><Server className="w-4 h-4 text-amber-500" /> RMM Sites Without Zabbix Host <Badge variant="outline">{rmmGaps.length}</Badge></CardTitle>
|
|
<CardDescription>Online RMM sites that have no corresponding Zabbix WAN monitor — run WAN Sync to add them</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Site</TableHead>
|
|
<TableHead>Company</TableHead>
|
|
<TableHead className="text-center">Devices</TableHead>
|
|
<TableHead className="text-center">Online</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rmmGaps.map(g => (
|
|
<TableRow key={g.rmm_site_uid}>
|
|
<TableCell className="font-medium text-sm">{g.rmm_site_name}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">{g.company_name}</TableCell>
|
|
<TableCell className="text-center tabular-nums">{g.number_of_devices}</TableCell>
|
|
<TableCell className="text-center tabular-nums text-green-600">{g.number_of_online_devices}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!gapLoading && gapSummary && rmmGaps.length === 0 && itgGaps.length === 0 && problems.length === 0 && (
|
|
<div className="text-center py-16 text-muted-foreground text-sm">
|
|
<CheckCircle2 className="w-8 h-8 mx-auto mb-3 text-green-500" />
|
|
All monitored sites and IT Glue circuits are covered in Zabbix.
|
|
</div>
|
|
)}
|
|
|
|
{!gapLoading && !gapSummary && !gapError && (
|
|
<div className="text-center py-16 text-muted-foreground text-sm">
|
|
Sync Zabbix hosts and IT Glue circuits above to run gap analysis.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|