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:
parent
c518eefdb2
commit
b98c67482a
40 changed files with 6223 additions and 15 deletions
246
app/admin/qbo/page.tsx
Normal file
246
app/admin/qbo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
456
app/admin/ticket-digest/page.tsx
Normal file
456
app/admin/ticket-digest/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue