feat: QuickBooks Online integration

- 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
This commit is contained in:
lorentz 2026-03-17 07:39:55 -04:00
parent c518eefdb2
commit b98c67482a
40 changed files with 6223 additions and 15 deletions

246
app/admin/qbo/page.tsx Normal file
View file

@ -0,0 +1,246 @@
'use client';
import { useState, useEffect, useCallback, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import {
CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2,
Link2, Link2Off, FileText, CreditCard, Building2, ArrowDownToLine, BarChart3,
} from 'lucide-react';
interface QboStatus {
tokenStatus: 'valid' | 'expired' | 'missing';
counts: {
invoices: number;
payments: number;
deposits: number;
transactions: number;
reports: number;
};
lastSync: {
invoices: string | null;
payments: string | null;
deposits: string | null;
transactions: string | null;
reports: string | null;
};
}
function fmtDate(d: string | null) {
if (!d) return 'Never';
const date = new Date(d);
const diff = Date.now() - date.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function fmtNum(n: number) {
return n.toLocaleString();
}
const ENTITY_META = [
{ key: 'invoices', label: 'Invoices', icon: FileText, color: 'text-blue-400' },
{ key: 'payments', label: 'Payments', icon: CreditCard, color: 'text-green-400' },
{ key: 'deposits', label: 'Deposits', icon: Building2, color: 'text-purple-400' },
{ key: 'transactions', label: 'Transactions', icon: ArrowDownToLine, color: 'text-orange-400' },
{ key: 'reports', label: 'Reports', icon: BarChart3, color: 'text-cyan-400' },
] as const;
function QboPageInner() {
const searchParams = useSearchParams();
const connected = searchParams.get('connected');
const disconnected = searchParams.get('disconnected');
const errorParam = searchParams.get('error');
const [status, setStatus] = useState<QboStatus | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const [banner, setBanner] = useState<{ type: 'success' | 'error' | 'info'; msg: string } | null>(null);
const fetchStatus = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/qbo/sync');
const data = await res.json();
setStatus(data);
} catch {
setStatus(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
useEffect(() => {
if (connected === 'true') setBanner({ type: 'success', msg: 'QuickBooks Online connected successfully.' });
else if (disconnected === 'true') setBanner({ type: 'info', msg: 'QuickBooks Online disconnected.' });
else if (errorParam) setBanner({ type: 'error', msg: decodeURIComponent(errorParam) });
}, [connected, disconnected, errorParam]);
async function triggerSync(syncType: 'full' | 'incremental') {
setSyncing(true);
setSyncMessage(null);
try {
const res = await fetch('/api/qbo/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncType, triggeredBy: 'admin-ui' }),
});
const data = await res.json();
if (res.ok) {
setSyncMessage(`${syncType === 'full' ? 'Full' : 'Incremental'} sync started. This may take a few minutes.`);
setTimeout(() => fetchStatus(), 10000);
setTimeout(() => fetchStatus(), 30000);
setTimeout(() => { fetchStatus(); setSyncing(false); }, 60000);
} else {
setSyncMessage(`Error: ${data.error}`);
setSyncing(false);
}
} catch (err) {
setSyncMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
setSyncing(false);
}
}
const tokenOk = status?.tokenStatus === 'valid';
const tokenBadge = {
valid: { icon: CheckCircle2, label: 'Connected', cls: 'text-green-400' },
expired: { icon: AlertTriangle, label: 'Token Expired', cls: 'text-yellow-400' },
missing: { icon: XCircle, label: 'Not Connected', cls: 'text-red-400' },
}[status?.tokenStatus ?? 'missing'];
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">QuickBooks Online</h1>
<p className="text-muted-foreground text-sm mt-1">Sync invoices, payments, deposits, transactions and financial reports</p>
</div>
<Button variant="outline" size="sm" onClick={fetchStatus} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Banner */}
{banner && (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm ${
banner.type === 'success' ? 'bg-green-500/10 border-green-500/30 text-green-300' :
banner.type === 'error' ? 'bg-red-500/10 border-red-500/30 text-red-300' :
'bg-blue-500/10 border-blue-500/30 text-blue-300'
}`}>
{banner.type === 'success' ? <CheckCircle2 className="w-4 h-4 shrink-0" /> :
banner.type === 'error' ? <XCircle className="w-4 h-4 shrink-0" /> :
<AlertTriangle className="w-4 h-4 shrink-0" />}
{banner.msg}
<button className="ml-auto opacity-60 hover:opacity-100" onClick={() => setBanner(null)}></button>
</div>
)}
{/* Connection Status */}
<div className="rounded-xl border bg-card p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base">Connection Status</h2>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<div className={`flex items-center gap-1.5 text-sm font-medium ${tokenBadge.cls}`}>
<tokenBadge.icon className="w-4 h-4" />
{tokenBadge.label}
</div>
)}
</div>
<div className="flex gap-3 flex-wrap">
<a href="/api/qbo/auth">
<Button variant="outline" size="sm" className="gap-2">
<Link2 className="w-4 h-4" />
{tokenOk ? 'Reconnect' : 'Connect to QuickBooks'}
</Button>
</a>
{tokenOk && (
<a href="/api/qbo/disconnect">
<Button variant="outline" size="sm" className="gap-2 text-red-400 border-red-500/30 hover:bg-red-500/10">
<Link2Off className="w-4 h-4" />
Disconnect
</Button>
</a>
)}
</div>
</div>
{/* Sync Controls */}
<div className="rounded-xl border bg-card p-5 space-y-4">
<h2 className="font-semibold text-base">Sync</h2>
<div className="flex gap-3 flex-wrap">
<Button
onClick={() => triggerSync('full')}
disabled={syncing || !tokenOk}
size="sm"
className="gap-2"
>
{syncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Full Sync
</Button>
<Button
onClick={() => triggerSync('incremental')}
disabled={syncing || !tokenOk}
variant="outline"
size="sm"
className="gap-2"
>
{syncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Incremental Sync
</Button>
</div>
{syncMessage && (
<p className="text-sm text-muted-foreground">{syncMessage}</p>
)}
</div>
{/* Entity Counts */}
<div className="rounded-xl border bg-card p-5 space-y-4">
<h2 className="font-semibold text-base">Synced Data</h2>
{loading ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin" /> Loading...
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{ENTITY_META.map(({ key, label, icon: Icon, color }) => (
<div key={key} className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border">
<Icon className={`w-5 h-5 shrink-0 ${color}`} />
<div>
<div className="text-lg font-semibold leading-none">
{fmtNum(status?.counts[key] ?? 0)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
<div className="text-xs text-muted-foreground/60 mt-0.5">
{fmtDate(status?.lastSync[key] ?? null)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default function QboAdminPage() {
return (
<Suspense fallback={<div className="p-6 text-muted-foreground text-sm">Loading...</div>}>
<QboPageInner />
</Suspense>
);
}

View file

@ -0,0 +1,456 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Send, RefreshCw, CheckCircle2, XCircle,
Clock, Loader2, ChevronDown, ChevronUp, BarChart3, Brain,
Calendar, CalendarDays, CalendarRange, MessageSquare, Bell, Globe, ExternalLink,
} from 'lucide-react';
interface DigestConfig {
daily_enabled: boolean;
weekly_enabled: boolean;
monthly_enabled: boolean;
daily_cron: string;
weekly_cron: string;
monthly_cron: string;
llm_provider: string;
llm_model: string;
include_noise_analysis: boolean;
include_sla_analysis: boolean;
include_resource_analysis: boolean;
include_client_analysis: boolean;
include_recommendations: boolean;
channel_ids: number[];
}
interface NotificationChannel {
id: number;
name: string;
channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook';
config: Record<string, any>;
is_active: boolean;
}
interface DigestReport {
id: number;
period_type: string;
period_start: string;
period_end: string;
generated_at: string;
stats: any;
llm_analysis: string | null;
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
tokens_used: number | null;
processing_time_ms: number | null;
}
function fmtDate(d: string | null) {
if (!d) return 'Never';
const date = new Date(d);
const diff = Date.now() - date.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function ChannelIcon({ type }: { type: string }) {
if (type === 'teams') return <MessageSquare className="h-4 w-4 text-indigo-500" />;
if (type === 'telegram') return <Send className="h-4 w-4 text-blue-500" />;
if (type === 'ntfy') return <Bell className="h-4 w-4 text-green-500" />;
return <Globe className="h-4 w-4 text-gray-500" />;
}
function PeriodIcon({ period }: { period: string }) {
if (period === 'daily') return <Calendar className="h-4 w-4 text-blue-500" />;
if (period === 'weekly') return <CalendarDays className="h-4 w-4 text-purple-500" />;
return <CalendarRange className="h-4 w-4 text-orange-500" />;
}
export default function TicketDigestPage() {
const [config, setConfig] = useState<DigestConfig | null>(null);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [history, setHistory] = useState<DigestReport[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
const [expandedReport, setExpandedReport] = useState<number | null>(null);
const [previewData, setPreviewData] = useState<any>(null);
const [previewPeriod, setPreviewPeriod] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const showToast = useCallback((msg: string, ok: boolean) => {
setToast({ msg, ok });
setTimeout(() => setToast(null), 4000);
}, []);
const loadData = useCallback(async () => {
try {
const res = await fetch('/api/reports/ticket-digest');
const data = await res.json();
setConfig(data.config);
setChannels(data.channels || []);
setHistory(data.history || []);
} catch (e) {
showToast('Failed to load data', false);
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => { loadData(); }, [loadData]);
const generateReport = async (period: string) => {
setGenerating(period);
try {
const res = await fetch('/api/reports/ticket-digest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ period }),
});
const data = await res.json();
if (data.success) {
showToast(`${period} digest generated (${data.processingTimeMs}ms)`, true);
loadData();
} else {
showToast(data.error || 'Generation failed', false);
}
} catch (e) {
showToast('Network error', false);
} finally {
setGenerating(null);
}
};
const loadPreview = async (period: string) => {
if (previewPeriod === period) { setPreviewPeriod(null); setPreviewData(null); return; }
setPreviewLoading(true);
setPreviewPeriod(period);
try {
const res = await fetch(`/api/reports/ticket-digest?preview=${period}`);
const data = await res.json();
setPreviewData(data.stats);
} catch {
showToast('Failed to load preview', false);
} finally {
setPreviewLoading(false);
}
};
const updateConfig = async (updates: Partial<DigestConfig>) => {
try {
const res = await fetch('/api/reports/ticket-digest/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const data = await res.json();
setConfig(data.config);
showToast('Config updated', true);
} catch {
showToast('Failed to update config', false);
}
};
const toggleChannelId = async (id: number) => {
if (!config) return;
const current = config.channel_ids || [];
const updated = current.includes(id)
? current.filter(c => c !== id)
: [...current, id];
await updateConfig({ channel_ids: updated });
};
if (loading) return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
return (
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg text-sm text-white ${toast.ok ? 'bg-green-600' : 'bg-red-600'}`}>
{toast.msg}
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<BarChart3 className="h-6 w-6" /> Ticket Digest Reports
</h1>
<p className="text-muted-foreground text-sm mt-1">
LLM-analyzed ticket reports delivered to Teams daily, weekly, and monthly
</p>
</div>
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</Button>
</div>
{/* Generate Reports */}
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2">
<Brain className="h-5 w-5" /> Generate Report
</h2>
<div className="grid grid-cols-3 gap-3">
{(['daily', 'weekly', 'monthly'] as const).map(p => (
<div key={p} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<PeriodIcon period={p} />
<span className="font-medium capitalize">{p}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => generateReport(p)}
disabled={!!generating}
className="flex-1"
>
{generating === p ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Send className="h-4 w-4 mr-1" />}
Generate & Send
</Button>
<Button
size="sm"
variant="outline"
onClick={() => loadPreview(p)}
disabled={previewLoading}
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{/* Preview */}
{previewPeriod && previewData && (
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold capitalize">{previewPeriod} Preview {previewData.period?.label}</h3>
<Button size="sm" variant="ghost" onClick={() => { setPreviewPeriod(null); setPreviewData(null); }}>
<XCircle className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-3 text-center">
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.total_created ?? 0}</div>
<div className="text-xs text-muted-foreground">Created</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.total_resolved ?? 0}</div>
<div className="text-xs text-muted-foreground">Resolved</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.avg_resolution_hours ?? '—'}h</div>
<div className="text-xs text-muted-foreground">Avg Resolve</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{(previewData.overview?.total_hours_worked ?? 0).toFixed(1)}h</div>
<div className="text-xs text-muted-foreground">Hours Worked</div>
</div>
</div>
{previewData.noise_candidates?.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-1">🔁 Noise Candidates ({previewData.noise_candidates.length})</h4>
<div className="text-xs space-y-1 max-h-40 overflow-y-auto">
{previewData.noise_candidates.slice(0, 10).map((n: any, i: number) => (
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
<span className="truncate">{n.title}</span>
<span className="text-muted-foreground shrink-0 ml-2">{n.count}× · {n.source_label}</span>
</div>
))}
</div>
</div>
)}
{previewData.top_clients?.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-1">🏢 Top Clients</h4>
<div className="text-xs space-y-1">
{previewData.top_clients.slice(0, 5).map((c: any, i: number) => (
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
<span>{c.company_name}</span>
<span className="text-muted-foreground">{c.ticket_count} tickets · {c.hours_worked.toFixed(1)}h</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Analysis Sections Config */}
{config && (
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg">Analysis Sections</h2>
<div className="grid grid-cols-2 gap-3">
{([
{ key: 'include_noise_analysis', label: '🔁 Noise & Automation' },
{ key: 'include_sla_analysis', label: '⏱️ SLA & Response Times' },
{ key: 'include_resource_analysis', label: '👥 Team Workload' },
{ key: 'include_client_analysis', label: '🏢 Client Spotlight' },
{ key: 'include_recommendations', label: '💡 Recommendations' },
] as const).map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer p-2 rounded hover:bg-muted/50">
<input
type="checkbox"
checked={(config as any)[key]}
onChange={() => updateConfig({ [key]: !(config as any)[key] })}
className="rounded"
/>
<span className="text-sm">{label}</span>
</label>
))}
</div>
<div className="flex items-center gap-4 text-sm">
<label className="flex items-center gap-2">
Provider:
<select
value={config.llm_provider}
onChange={e => updateConfig({ llm_provider: e.target.value })}
className="border rounded px-2 py-1 bg-background"
>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
</select>
</label>
<label className="flex items-center gap-2">
Model:
<input
value={config.llm_model}
onChange={e => updateConfig({ llm_model: e.target.value })}
className="border rounded px-2 py-1 bg-background w-56"
/>
</label>
</div>
</div>
)}
{/* Notification Channels */}
<div className="border rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-lg">Delivery Channels</h2>
<p className="text-xs text-muted-foreground mt-0.5">Select which notification channels receive these reports. Manage channels in <a href="/admin/workflow/channels" className="underline">Notification Channels</a>.</p>
</div>
<a href="/admin/workflow/channels" className="text-xs text-muted-foreground flex items-center gap-1 hover:text-foreground">
<ExternalLink className="h-3 w-3" /> Manage Channels
</a>
</div>
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No notification channels configured yet.{' '}
<a href="/admin/workflow/channels" className="underline">Add one here.</a>
</p>
) : (
<div className="space-y-2">
{channels.map(ch => {
const selected = config?.channel_ids?.includes(ch.id) ?? false;
return (
<label
key={ch.id}
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-colors ${
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'
} ${!ch.is_active ? 'opacity-50' : ''}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleChannelId(ch.id)}
className="rounded"
disabled={!ch.is_active}
/>
<ChannelIcon type={ch.channel_type} />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{ch.name}</div>
<div className="text-xs text-muted-foreground capitalize">{ch.channel_type}{!ch.is_active ? ' · Inactive' : ''}</div>
</div>
{selected && (
<span className="text-xs text-primary font-medium">Selected</span>
)}
</label>
);
})}
</div>
)}
{config && (config.channel_ids?.length ?? 0) === 0 && channels.length > 0 && (
<p className="text-xs text-amber-500"> No channels selected reports will be generated but not delivered.</p>
)}
</div>
{/* History */}
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg">Report History</h2>
{history.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No reports generated yet. Generate your first report above.</p>
) : (
<div className="space-y-2">
{history.map(report => {
const isExpanded = expandedReport === report.id;
const ov = report.stats?.overview;
return (
<div key={report.id} className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between p-3 hover:bg-muted/30 text-left"
onClick={() => setExpandedReport(isExpanded ? null : report.id)}
>
<div className="flex items-center gap-3">
<PeriodIcon period={report.period_type} />
<div>
<span className="font-medium text-sm capitalize">{report.period_type}</span>
<span className="text-xs text-muted-foreground ml-2">
{new Date(report.generated_at).toLocaleString()}
</span>
</div>
</div>
<div className="flex items-center gap-4 text-xs">
{ov && (
<span className="text-muted-foreground">
{ov.total_created} created · {ov.total_resolved} resolved
</span>
)}
{report.tokens_used && (
<span className="text-muted-foreground">{report.tokens_used} tokens</span>
)}
{report.processing_time_ms && (
<span className="text-muted-foreground">{(report.processing_time_ms / 1000).toFixed(1)}s</span>
)}
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</button>
{isExpanded && report.llm_analysis && (
<div className="px-4 pb-4 border-t">
<div className="mt-3 prose prose-sm max-w-none dark:prose-invert text-sm whitespace-pre-wrap">
{report.llm_analysis}
</div>
{report.delivery_status && Object.keys(report.delivery_status).length > 0 && (
<div className="mt-3 border-t pt-2">
<span className="text-xs font-medium text-muted-foreground">Delivery:</span>
{Object.entries(report.delivery_status).map(([whId, st]) => (
<span key={whId} className={`text-xs ml-2 ${(st as any).success ? 'text-green-500' : 'text-red-500'}`}>
#{whId}: {(st as any).success ? 'OK' : (st as any).error || 'Failed'}
</span>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View file

@ -41,6 +41,12 @@ import {
ChevronDown,
ChevronRight,
Network,
ShieldAlert,
Layers,
BookOpen,
Activity,
Clipboard,
ClipboardCheck,
} from 'lucide-react';
import { toast } from 'sonner';
import { HostManager } from '@/components/zabbix/host-manager';
@ -102,7 +108,37 @@ function ActionBadge({ action }: { action: string }) {
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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>('');
@ -122,6 +158,117 @@ export default function ZabbixWanPage() {
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('');
@ -289,11 +436,6 @@ export default function ZabbixWanPage() {
<div className="container mx-auto py-8 max-w-6xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/admin/sync">
<Button variant="ghost" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" /> Back
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Globe className="w-6 h-6" /> Zabbix WAN Monitor Setup
@ -304,6 +446,29 @@ export default function ZabbixWanPage() {
</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">
@ -732,6 +897,531 @@ export default function ZabbixWanPage() {
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>
);
}