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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
151
app/api/itglue/sync-wan/route.ts
Normal file
151
app/api/itglue/sync-wan/route.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* POST /api/itglue/sync-wan
|
||||
* Pulls all Internet/WAN flexible assets (type 3794) from the local itg_flexible_assets
|
||||
* table, parses static IPs, and upserts into itg_wan_circuits.
|
||||
* Also matches Zabbix WAN hosts by IP to populate zabbix_hostid.
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
// Extract public IPs from the raw HTML/text blob IT Glue stores
|
||||
function parseStaticIps(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
// Strip HTML tags
|
||||
const text = raw.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ');
|
||||
// Match IPv4 addresses
|
||||
const matches = text.match(/\b(\d{1,3}\.){3}\d{1,3}\b/g) ?? [];
|
||||
// Filter out private/RFC1918 ranges, subnet masks, gateways that look like masks
|
||||
return [...new Set(
|
||||
matches.filter(ip => {
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts[0] === 10) return false;
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
|
||||
if (parts[0] === 192 && parts[1] === 168) return false;
|
||||
if (parts[0] === 255) return false;
|
||||
if (parts[0] === 0) return false;
|
||||
if (ip === 'DHCP') return false;
|
||||
return true;
|
||||
})
|
||||
)];
|
||||
}
|
||||
|
||||
function isDecommissioned(notes: string | null): boolean {
|
||||
if (!notes) return false;
|
||||
const lower = notes.toLowerCase();
|
||||
return lower.includes('decommission') || lower.includes('decomission') || lower.includes('retired') || lower.includes('removed');
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
// Pull all IT Glue WAN flexible assets joined to org data
|
||||
const assets = await postgresClient.query<{
|
||||
id: number;
|
||||
organization_id: number;
|
||||
org_name: string;
|
||||
psa_id: string | null;
|
||||
provider: string | null;
|
||||
link_type: string | null;
|
||||
static_ips_raw: string | null;
|
||||
upload_mbps: string | null;
|
||||
download_mbps: string | null;
|
||||
location_name: string | null;
|
||||
location_city: string | null;
|
||||
notes: string | null;
|
||||
}>(`
|
||||
SELECT
|
||||
fa.id,
|
||||
fa.organization_id,
|
||||
io.name AS org_name,
|
||||
io.psa_id,
|
||||
fa.traits->>'provider' AS provider,
|
||||
fa.traits->>'link-type' AS link_type,
|
||||
fa.traits->>'static-ip-address-es' AS static_ips_raw,
|
||||
fa.traits->>'upload-speed-mbps' AS upload_mbps,
|
||||
fa.traits->>'download-speed-mbps' AS download_mbps,
|
||||
fa.traits->'location-s'->'values'->0->>'name' AS location_name,
|
||||
fa.traits->'location-s'->'values'->0->>'city' AS location_city,
|
||||
fa.traits->>'notes' AS notes
|
||||
FROM itg_flexible_assets fa
|
||||
JOIN itg_organizations io ON io.id = fa.organization_id
|
||||
WHERE fa.flexible_asset_type_id = 3794
|
||||
`);
|
||||
|
||||
// Build IP → hostid map from zabbix_wan_hosts for matching
|
||||
const zabbixRows = await postgresClient.query<{ hostid: string; wan_ip: string }>(
|
||||
'SELECT hostid, wan_ip FROM zabbix_wan_hosts WHERE wan_ip IS NOT NULL'
|
||||
);
|
||||
const zabbixByIp = new Map<string, string>();
|
||||
for (const r of zabbixRows.rows) {
|
||||
zabbixByIp.set(r.wan_ip, r.hostid);
|
||||
}
|
||||
|
||||
let upserted = 0;
|
||||
|
||||
for (const a of assets.rows) {
|
||||
const staticIps = parseStaticIps(a.static_ips_raw);
|
||||
const decommissioned = isDecommissioned(a.notes);
|
||||
const autotaskCompanyId = a.psa_id ? Number(a.psa_id) : null;
|
||||
|
||||
// Try to match a Zabbix host by any of the circuit's static IPs
|
||||
let zabbixHostid: string | null = null;
|
||||
for (const ip of staticIps) {
|
||||
if (zabbixByIp.has(ip)) { zabbixHostid = zabbixByIp.get(ip)!; break; }
|
||||
}
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_wan_circuits (
|
||||
id, organization_id, org_name, autotask_company_id,
|
||||
provider, link_type, static_ips, raw_ip_text,
|
||||
upload_mbps, download_mbps, location_name, location_city,
|
||||
notes, is_decommissioned, zabbix_hostid,
|
||||
last_synced_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
org_name = EXCLUDED.org_name,
|
||||
autotask_company_id = EXCLUDED.autotask_company_id,
|
||||
provider = EXCLUDED.provider,
|
||||
link_type = EXCLUDED.link_type,
|
||||
static_ips = EXCLUDED.static_ips,
|
||||
raw_ip_text = EXCLUDED.raw_ip_text,
|
||||
upload_mbps = EXCLUDED.upload_mbps,
|
||||
download_mbps = EXCLUDED.download_mbps,
|
||||
location_name = EXCLUDED.location_name,
|
||||
location_city = EXCLUDED.location_city,
|
||||
notes = EXCLUDED.notes,
|
||||
is_decommissioned = EXCLUDED.is_decommissioned,
|
||||
zabbix_hostid = EXCLUDED.zabbix_hostid,
|
||||
last_synced_at = NOW(),
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
a.id, a.organization_id, a.org_name, autotaskCompanyId,
|
||||
a.provider, a.link_type, staticIps, a.static_ips_raw,
|
||||
a.upload_mbps ? Number(a.upload_mbps) : null,
|
||||
a.download_mbps ? Number(a.download_mbps) : null,
|
||||
a.location_name, a.location_city,
|
||||
a.notes, decommissioned, zabbixHostid,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
// Summary stats
|
||||
const stats = await postgresClient.query<{
|
||||
total: string; with_ip: string; matched_zabbix: string; decommissioned: string; no_itg_org_link: string;
|
||||
}>(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE array_length(static_ips,1) > 0) AS with_ip,
|
||||
COUNT(*) FILTER (WHERE zabbix_hostid IS NOT NULL) AS matched_zabbix,
|
||||
COUNT(*) FILTER (WHERE is_decommissioned) AS decommissioned,
|
||||
COUNT(*) FILTER (WHERE autotask_company_id IS NULL) AS no_itg_org_link
|
||||
FROM itg_wan_circuits
|
||||
`);
|
||||
|
||||
return NextResponse.json({ upserted, ...stats.rows[0] });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ export async function GET(request: NextRequest) {
|
|||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
LEFT JOIN statuses s ON t.status = s.value
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
ORDER BY t.last_activity_date DESC NULLS LAST
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND priority <= 3
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -63,6 +64,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.priority <= 3
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -75,6 +77,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND status IN (21, 9, 19)
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -86,6 +89,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.status IN (21, 9, 19)
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -98,6 +102,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND last_activity_date < NOW() - INTERVAL '7 days'
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -109,6 +114,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.last_activity_date < NOW() - INTERVAL '7 days'
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -121,6 +127,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND due_date_time < NOW()
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -132,6 +139,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.due_date_time < NOW()
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -144,6 +152,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE completed_date IS NULL
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -153,6 +162,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets
|
||||
WHERE DATE(completed_date) = CURRENT_DATE
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -163,6 +173,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets t
|
||||
LEFT JOIN companies c ON t.company_id = c.id
|
||||
WHERE DATE(t.completed_date) = CURRENT_DATE
|
||||
AND t.is_deleted = false
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}
|
||||
ORDER BY t.completed_date DESC
|
||||
|
|
@ -175,6 +186,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets
|
||||
WHERE completed_date >= NOW() - INTERVAL '7 days'
|
||||
AND completed_date < NOW()
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -185,6 +197,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets
|
||||
WHERE completed_date >= NOW() - INTERVAL '30 days'
|
||||
AND completed_date < NOW()
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -195,6 +208,7 @@ export async function GET(request: NextRequest) {
|
|||
FROM tickets
|
||||
WHERE completed_date >= NOW() - INTERVAL '30 days'
|
||||
AND completed_date IS NOT NULL
|
||||
AND is_deleted = false
|
||||
AND (source IS NULL OR source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
);
|
||||
|
|
@ -221,6 +235,7 @@ export async function GET(request: NextRequest) {
|
|||
) te ON t.id = te.ticket_id AND r.id = te.resource_id
|
||||
WHERE t.completed_date >= NOW() - INTERVAL '30 days'
|
||||
AND t.completed_date IS NOT NULL
|
||||
AND t.is_deleted = false
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
AND r.id != 4
|
||||
${excludeCompanyFilter}
|
||||
|
|
@ -284,6 +299,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.status NOT IN (21, 9, 19)
|
||||
AND t.ticket_type != 4
|
||||
AND t.queue_id IN (29682833, 29749490)
|
||||
|
|
@ -302,6 +318,7 @@ export async function GET(request: NextRequest) {
|
|||
LEFT JOIN issue_types it ON t.issue_type = it.value
|
||||
LEFT JOIN sub_issue_types sit ON t.sub_issue_type = sit.value
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.status NOT IN (21, 9, 19)
|
||||
AND t.ticket_type != 4
|
||||
AND t.queue_id IN (29682833, 29749490)
|
||||
|
|
@ -318,6 +335,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.status NOT IN (21, 9, 19)
|
||||
AND t.ticket_type != 4
|
||||
AND t.queue_id IN (29853766, 29853700)
|
||||
|
|
@ -331,6 +349,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.ticket_category = 171
|
||||
AND (t.source IS NULL OR t.source != 8)
|
||||
${excludeCompanyFilter}`
|
||||
|
|
@ -341,6 +360,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.company_id = 29861375
|
||||
AND (t.source IS NULL OR t.source != 8)`
|
||||
);
|
||||
|
|
@ -350,6 +370,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.company_id = 29683407
|
||||
AND (t.source IS NULL OR t.source != 8)`
|
||||
);
|
||||
|
|
@ -359,6 +380,7 @@ export async function GET(request: NextRequest) {
|
|||
`SELECT COUNT(*) as count
|
||||
FROM tickets t
|
||||
WHERE t.completed_date IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND t.company_id IN (29861395, 29861424)
|
||||
AND (t.source IS NULL OR t.source != 8)`
|
||||
);
|
||||
|
|
|
|||
48
app/api/qbo/auth/route.ts
Normal file
48
app/api/qbo/auth/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* QBO OAuth2 Authorization
|
||||
* GET /api/qbo/auth — redirect to Intuit authorization page
|
||||
* GET /api/qbo/auth/callback — exchange code for tokens
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { QboClient } from '@/lib/services/qbo-client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const code = searchParams.get('code');
|
||||
const realmId = searchParams.get('realmId');
|
||||
const error = searchParams.get('error');
|
||||
|
||||
const redirectUri = `${process.env.NEXTAUTH_URL}/api/qbo/auth`;
|
||||
|
||||
// ── Callback from Intuit ──────────────────────────────────────────────────
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://pulse.wulfconsulting.cloud';
|
||||
|
||||
if (code && realmId) {
|
||||
try {
|
||||
const client = new QboClient();
|
||||
await client.exchangeCodeForToken(code, redirectUri);
|
||||
console.log(`[QBO Auth] Tokens saved for realm ${realmId}`);
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?connected=true`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[QBO Auth] Token exchange failed:', msg);
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?error=${encodeURIComponent(error)}`);
|
||||
}
|
||||
|
||||
// ── Initiate authorization ────────────────────────────────────────────────
|
||||
try {
|
||||
const client = new QboClient();
|
||||
const state = Math.random().toString(36).slice(2);
|
||||
const authUrl = client.getAuthorizationUrl(redirectUri, state);
|
||||
return NextResponse.redirect(authUrl);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
app/api/qbo/disconnect/route.ts
Normal file
21
app/api/qbo/disconnect/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* QBO Disconnect
|
||||
* GET /api/qbo/disconnect — revokes QBO token and removes from DB
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
const realmId = process.env.QBO_REALM_ID || '';
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://pulse.wulfconsulting.cloud';
|
||||
|
||||
try {
|
||||
await postgresClient.query(`DELETE FROM qbo_tokens WHERE realm_id = $1`, [realmId]);
|
||||
console.log(`[QBO] Disconnected realm ${realmId}`);
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?disconnected=true`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.redirect(`${baseUrl}/admin/qbo?error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
}
|
||||
83
app/api/qbo/sync/route.ts
Normal file
83
app/api/qbo/sync/route.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* QBO Sync API
|
||||
* POST /api/qbo/sync — trigger a full or incremental sync
|
||||
* GET /api/qbo/sync — get last sync status and record counts
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { QboSyncService } from '@/lib/services/qbo-sync-service';
|
||||
import { QboClient } from '@/lib/services/qbo-client';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const syncType: 'full' | 'incremental' = body.syncType === 'incremental' ? 'incremental' : 'full';
|
||||
const triggeredBy = body.triggeredBy || 'api';
|
||||
|
||||
const client = new QboClient();
|
||||
const service = new QboSyncService(client);
|
||||
|
||||
if (service.isSyncInProgress()) {
|
||||
return NextResponse.json({ error: 'QBO sync already in progress' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Run async — return immediately
|
||||
service[syncType === 'full' ? 'fullSync' : 'incrementalSync'](triggeredBy).catch((err) => {
|
||||
console.error('[QBO Sync API] Sync failed:', err);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: `QBO ${syncType} sync started`,
|
||||
triggeredBy,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [invoices, payments, deposits, transactions, reports] = await Promise.all([
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_invoices`),
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_payments`),
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_deposits`),
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_transactions`),
|
||||
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_reports`),
|
||||
]);
|
||||
|
||||
// Check token status
|
||||
let tokenStatus: 'valid' | 'expired' | 'missing' = 'missing';
|
||||
try {
|
||||
const client = new QboClient();
|
||||
const token = await client.loadToken();
|
||||
if (token) {
|
||||
tokenStatus = new Date() < new Date(token.access_token_expires_at) ? 'valid' : 'expired';
|
||||
}
|
||||
} catch {
|
||||
tokenStatus = 'missing';
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tokenStatus,
|
||||
counts: {
|
||||
invoices: parseInt(invoices.rows[0]?.count || '0'),
|
||||
payments: parseInt(payments.rows[0]?.count || '0'),
|
||||
deposits: parseInt(deposits.rows[0]?.count || '0'),
|
||||
transactions: parseInt(transactions.rows[0]?.count || '0'),
|
||||
reports: parseInt(reports.rows[0]?.count || '0'),
|
||||
},
|
||||
lastSync: {
|
||||
invoices: invoices.rows[0]?.last_sync ?? null,
|
||||
payments: payments.rows[0]?.last_sync ?? null,
|
||||
deposits: deposits.rows[0]?.last_sync ?? null,
|
||||
transactions: transactions.rows[0]?.last_sync ?? null,
|
||||
reports: reports.rows[0]?.last_sync ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
34
app/api/reports/ticket-digest/config/route.ts
Normal file
34
app/api/reports/ticket-digest/config/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Ticket Digest Config API
|
||||
* GET /api/reports/ticket-digest/config — Get config + available channels
|
||||
* PUT /api/reports/ticket-digest/config — Update config
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTicketDigestService } from '@/lib/services/ticket-digest-service';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const service = getTicketDigestService();
|
||||
const [config, channels] = await Promise.all([
|
||||
service.getConfig(),
|
||||
service.getAvailableChannels(),
|
||||
]);
|
||||
return NextResponse.json({ config, channels });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const service = getTicketDigestService();
|
||||
const config = await service.updateConfig(body);
|
||||
return NextResponse.json({ config });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
70
app/api/reports/ticket-digest/route.ts
Normal file
70
app/api/reports/ticket-digest/route.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Ticket Digest Report API
|
||||
* POST /api/reports/ticket-digest — Generate and deliver a digest report
|
||||
* Body: { period: 'daily' | 'weekly' | 'monthly', webhookIds?: number[] }
|
||||
* GET /api/reports/ticket-digest — Get report history
|
||||
* GET /api/reports/ticket-digest?preview=daily — Aggregate data only (no LLM, no delivery)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTicketDigestService, DigestPeriod } from '@/lib/services/ticket-digest-service';
|
||||
|
||||
const VALID_PERIODS: DigestPeriod[] = ['daily', 'weekly', 'monthly'];
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const period = body.period as DigestPeriod;
|
||||
|
||||
if (!period || !VALID_PERIODS.includes(period)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid period. Must be one of: ${VALID_PERIODS.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const service = getTicketDigestService();
|
||||
const result = await service.run(period, body.channelIds);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
period,
|
||||
stats: result.stats.overview,
|
||||
noiseCount: result.stats.noise_candidates.length,
|
||||
analysisLength: result.analysis.length,
|
||||
deliveryResults: result.deliveryResults,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[TICKET-DIGEST API] Error:', msg);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const preview = searchParams.get('preview') as DigestPeriod | null;
|
||||
|
||||
const service = getTicketDigestService();
|
||||
|
||||
if (preview && VALID_PERIODS.includes(preview)) {
|
||||
const stats = await service.aggregate(preview);
|
||||
return NextResponse.json({ stats });
|
||||
}
|
||||
|
||||
// Return history + config + available notification channels
|
||||
const [history, config, channels] = await Promise.all([
|
||||
service.getHistory(20),
|
||||
service.getConfig(),
|
||||
service.getAvailableChannels(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ history, config, channels });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[TICKET-DIGEST API] Error:', msg);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,17 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import '@/lib/services/pipeline-steps';
|
||||
import { pipelineEngine } from '@/lib/services/pipeline-engine';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
|
||||
async function getDattoPingTarget(alertUid: string): Promise<string | null> {
|
||||
try {
|
||||
const client = getDattoRMMClient();
|
||||
const target = await client.getPingAlertTarget(alertUid);
|
||||
return target;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/datto-rmm
|
||||
|
|
@ -70,6 +81,83 @@ export async function POST(request: NextRequest) {
|
|||
]
|
||||
);
|
||||
|
||||
// Upsert into datto_rmm_alerts if payload looks like a real alert
|
||||
if (payload && typeof payload === 'object' && payload.alert_uid && !payload.alert_uid.startsWith('[')) {
|
||||
const pingTarget = payload.alert_type === 'PING'
|
||||
? await getDattoPingTarget(payload.alert_uid)
|
||||
: null;
|
||||
const isResolved = String(payload.triggered).toLowerCase() === 'false';
|
||||
const str = (v: unknown) => (v && String(v).trim() !== '' ? String(v) : null);
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO datto_rmm_alerts (
|
||||
alert_uid, device_uid, device_name, device_hostname, device_ip, device_os,
|
||||
device_description, device_id, site_uid, site_name, site_id, platform,
|
||||
priority, alert_category, alert_type, alert_message_en, last_user,
|
||||
triggered, resolved, resolved_on,
|
||||
device_udf1, device_udf2, device_udf3, device_udf4, device_udf5,
|
||||
device_udf6, device_udf7, device_udf8, device_udf9, device_udf10,
|
||||
device_udf11, device_udf12, device_udf13, device_udf14, device_udf15,
|
||||
device_udf16, device_udf17, device_udf18, device_udf19, device_udf20,
|
||||
device_udf21, device_udf22, device_udf23, device_udf24, device_udf25,
|
||||
device_udf26, device_udf27, device_udf28, device_udf29,
|
||||
ping_target,
|
||||
timestamp, synced_at
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,
|
||||
$41,$42,$43,$44,$45,$46,$47,$48,$49,
|
||||
$50,
|
||||
$51,$52
|
||||
)
|
||||
ON CONFLICT (alert_uid) DO UPDATE SET
|
||||
resolved = EXCLUDED.resolved,
|
||||
resolved_on = CASE WHEN EXCLUDED.resolved AND datto_rmm_alerts.resolved_on IS NULL
|
||||
THEN NOW() ELSE datto_rmm_alerts.resolved_on END,
|
||||
triggered = EXCLUDED.triggered,
|
||||
alert_message_en = COALESCE(EXCLUDED.alert_message_en, datto_rmm_alerts.alert_message_en),
|
||||
ping_target = COALESCE(EXCLUDED.ping_target, datto_rmm_alerts.ping_target),
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
payload.alert_uid,
|
||||
str(payload.device_uid),
|
||||
str(payload.device_hostname),
|
||||
str(payload.device_hostname),
|
||||
str(payload.device_ip),
|
||||
str(payload.device_os),
|
||||
str(payload.device_description),
|
||||
str(payload.device_id),
|
||||
str(payload.site_uid),
|
||||
str(payload.site_name),
|
||||
str(payload.site_id),
|
||||
str(payload.platform),
|
||||
str(payload.alert_priority),
|
||||
str(payload.alert_category),
|
||||
str(payload.alert_type),
|
||||
str(payload.alert_message_en),
|
||||
str(payload.last_user),
|
||||
String(payload.triggered),
|
||||
isResolved,
|
||||
isResolved ? receivedAt : null,
|
||||
str(payload.device_udf1), str(payload.device_udf2), str(payload.device_udf3),
|
||||
str(payload.device_udf4), str(payload.device_udf5), str(payload.device_udf6),
|
||||
str(payload.device_udf7), str(payload.device_udf8), str(payload.device_udf9),
|
||||
str(payload.device_udf10), str(payload.device_udf11), str(payload.device_udf12),
|
||||
str(payload.device_udf13), str(payload.device_udf14), str(payload.device_udf15),
|
||||
str(payload.device_udf16), str(payload.device_udf17), str(payload.device_udf18),
|
||||
str(payload.device_udf19), str(payload.device_udf20),
|
||||
str(payload.device_udf21), str(payload.device_udf22), str(payload.device_udf23),
|
||||
str(payload.device_udf24), str(payload.device_udf25), str(payload.device_udf26),
|
||||
str(payload.device_udf27), str(payload.device_udf28), str(payload.device_udf29),
|
||||
pingTarget,
|
||||
receivedAt,
|
||||
receivedAt,
|
||||
]
|
||||
);
|
||||
|
||||
console.log(`[DATTO-RMM-WEBHOOK] Upserted alert ${payload.alert_uid} — resolved=${isResolved}`);
|
||||
}
|
||||
|
||||
// Fire matching pipelines (fire-and-forget)
|
||||
if (payload && typeof payload === 'object') {
|
||||
pipelineEngine.processTrigger('datto_rmm', payload).catch(err =>
|
||||
|
|
|
|||
154
app/api/zabbix/alert-correlation/route.ts
Normal file
154
app/api/zabbix/alert-correlation/route.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* GET /api/zabbix/alert-correlation
|
||||
* Cross-references Zabbix WAN problem events against Datto RMM ping/offline alerts
|
||||
* for the same company within a configurable time window.
|
||||
*
|
||||
* Query params:
|
||||
* days — look-back window in days (default 30)
|
||||
* windowMins — match window ± minutes around Zabbix event (default 120)
|
||||
* companyId — optional: filter to one Autotask company
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const days = Number(searchParams.get('days') ?? 30);
|
||||
const windowMins = Number(searchParams.get('windowMins') ?? 120);
|
||||
const companyId = searchParams.get('companyId');
|
||||
|
||||
try {
|
||||
const since = new Date(Date.now() - days * 86400 * 1000);
|
||||
|
||||
// ── 1. Zabbix events with matched RMM alert count ────────────────────────
|
||||
const zabbixRows = await postgresClient.query<{
|
||||
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: string; // JSON array
|
||||
}>(`
|
||||
SELECT
|
||||
ze.eventid,
|
||||
ze.name,
|
||||
ze.severity,
|
||||
ze.clock,
|
||||
ze.r_clock,
|
||||
ze.duration_seconds,
|
||||
ze.host_name,
|
||||
ze.wan_ip,
|
||||
ze.isp_name,
|
||||
ze.autotask_company_id,
|
||||
ze.autotask_company_name,
|
||||
ze.rmm_site_uid,
|
||||
COUNT(ra.alert_uid)::text AS rmm_alert_count,
|
||||
json_agg(json_build_object(
|
||||
'alert_uid', ra.alert_uid,
|
||||
'site_name', ra.site_name,
|
||||
'alert_class', ra.alert_context->>'@class',
|
||||
'alert_message', ra.alert_message_en,
|
||||
'timestamp', ra.timestamp,
|
||||
'resolved', ra.resolved,
|
||||
'resolved_on', ra.resolved_on,
|
||||
'device_name', ra.device_name
|
||||
) ORDER BY ra.timestamp)
|
||||
FILTER (WHERE ra.alert_uid IS NOT NULL) AS rmm_alerts
|
||||
FROM zabbix_events ze
|
||||
LEFT JOIN datto_rmm_alerts ra
|
||||
ON ra.timestamp BETWEEN ze.clock - ($1 * INTERVAL '1 minute')
|
||||
AND ze.clock + ($1 * INTERVAL '1 minute')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM datto_rmm_sites ds
|
||||
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = ds.uid
|
||||
WHERE ds.uid = ra.site_uid
|
||||
AND rsm.company_id = ze.autotask_company_id
|
||||
)
|
||||
WHERE ze.clock >= $2
|
||||
${companyId ? 'AND ze.autotask_company_id = $3' : ''}
|
||||
GROUP BY
|
||||
ze.eventid, ze.name, ze.severity, ze.clock, ze.r_clock,
|
||||
ze.duration_seconds, ze.host_name, ze.wan_ip, ze.isp_name,
|
||||
ze.autotask_company_id, ze.autotask_company_name, ze.rmm_site_uid
|
||||
ORDER BY ze.clock DESC
|
||||
`, companyId
|
||||
? [windowMins, since, Number(companyId)]
|
||||
: [windowMins, since]);
|
||||
|
||||
// ── 2. RMM ping/offline alerts with NO matching Zabbix event ────────────
|
||||
const rmmOnlyRows = await postgresClient.query<{
|
||||
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;
|
||||
}>(`
|
||||
SELECT
|
||||
ra.alert_uid,
|
||||
ra.site_name,
|
||||
ra.alert_context->>'@class' AS alert_class,
|
||||
ra.alert_message_en AS alert_message,
|
||||
ra.timestamp,
|
||||
ra.resolved,
|
||||
ra.resolved_on,
|
||||
ra.device_name,
|
||||
rsm.company_id AS autotask_company_id,
|
||||
c.company_name AS autotask_company_name
|
||||
FROM datto_rmm_alerts ra
|
||||
JOIN datto_rmm_sites ds ON ds.uid = ra.site_uid
|
||||
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = ds.uid
|
||||
JOIN companies c ON c.id = rsm.company_id
|
||||
WHERE ra.timestamp >= $1
|
||||
AND ra.alert_context->>'@class' IN ('ping_ctx', 'device_offline_ctx', 'network_ctx')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM zabbix_events ze
|
||||
WHERE ze.autotask_company_id = rsm.company_id
|
||||
AND ze.clock BETWEEN ra.timestamp - ($2 * INTERVAL '1 minute')
|
||||
AND ra.timestamp + ($2 * INTERVAL '1 minute')
|
||||
)
|
||||
${companyId ? 'AND rsm.company_id = $3' : ''}
|
||||
ORDER BY ra.timestamp DESC
|
||||
LIMIT 500
|
||||
`, companyId
|
||||
? [since, windowMins, Number(companyId)]
|
||||
: [since, windowMins]);
|
||||
|
||||
// ── 3. Summary stats ─────────────────────────────────────────────────────
|
||||
const zabbixWithRmm = zabbixRows.rows.filter(r => Number(r.rmm_alert_count) > 0).length;
|
||||
const zabbixWithoutRmm = zabbixRows.rows.filter(r => Number(r.rmm_alert_count) === 0).length;
|
||||
|
||||
const lastEventSync = await postgresClient.query<{ last_sync: string | null }>(
|
||||
'SELECT MAX(last_synced_at) AS last_sync FROM zabbix_events'
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
summary: {
|
||||
days,
|
||||
window_mins: windowMins,
|
||||
zabbix_events_total: zabbixRows.rows.length,
|
||||
zabbix_with_rmm_match: zabbixWithRmm,
|
||||
zabbix_without_rmm_match: zabbixWithoutRmm,
|
||||
rmm_only_alerts: rmmOnlyRows.rows.length,
|
||||
last_event_sync: lastEventSync.rows[0]?.last_sync ?? null,
|
||||
},
|
||||
zabbix_events: zabbixRows.rows,
|
||||
rmm_only: rmmOnlyRows.rows,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
122
app/api/zabbix/sync-events/route.ts
Normal file
122
app/api/zabbix/sync-events/route.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* POST /api/zabbix/sync-events
|
||||
* Polls Zabbix for WAN problem events (past N days) and caches them in zabbix_events.
|
||||
* Joins against local zabbix_wan_hosts to enrich with company/site context.
|
||||
* Body: { days?: number } — defaults to 30
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
return NextResponse.json({ error: 'Zabbix not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const days: number = Number(body.days ?? 30);
|
||||
|
||||
const zabbix = new ZabbixClient({
|
||||
apiUrl: process.env.ZABBIX_API_URL,
|
||||
apiToken: process.env.ZABBIX_API_TOKEN,
|
||||
});
|
||||
|
||||
try {
|
||||
// Load local WAN host cache for enrichment (hostid → context)
|
||||
const hostRows = await postgresClient.query<{
|
||||
hostid: string;
|
||||
display_name: string;
|
||||
wan_ip: string;
|
||||
autotask_company_id: number | null;
|
||||
autotask_company_name: string | null;
|
||||
rmm_site_uid: string | null;
|
||||
isp_name: string | null;
|
||||
}>('SELECT hostid, display_name, wan_ip, autotask_company_id, autotask_company_name, rmm_site_uid, isp_name FROM zabbix_wan_hosts');
|
||||
|
||||
const hostMap = new Map(hostRows.rows.map(r => [r.hostid, r]));
|
||||
|
||||
if (hostMap.size === 0) {
|
||||
return NextResponse.json({ error: 'No Zabbix hosts cached — run Sync Hosts first' }, { status: 400 });
|
||||
}
|
||||
|
||||
const from = new Date(Date.now() - days * 86400 * 1000);
|
||||
const to = new Date();
|
||||
|
||||
// Pull WAN-host-only events by scoping to our known hostids
|
||||
const hostIds = [...hostMap.keys()];
|
||||
|
||||
const events = await zabbix.getEvents({ hostIds, from, to, limit: 10000 });
|
||||
|
||||
let upserted = 0;
|
||||
let noHost = 0;
|
||||
|
||||
for (const ev of events) {
|
||||
const hostid = ev.hosts?.[0]?.hostid ?? null;
|
||||
const host = hostid ? hostMap.get(hostid) ?? null : null;
|
||||
|
||||
if (!host) { noHost++; continue; }
|
||||
|
||||
const clockTs = new Date(Number(ev.clock) * 1000);
|
||||
const rClockTs = ev.r_clock && ev.r_clock !== '0'
|
||||
? new Date(Number(ev.r_clock) * 1000)
|
||||
: null;
|
||||
const duration = rClockTs
|
||||
? Math.round((rClockTs.getTime() - clockTs.getTime()) / 1000)
|
||||
: null;
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO zabbix_events (
|
||||
eventid, objectid, name, severity, clock,
|
||||
r_eventid, r_clock, duration_seconds,
|
||||
hostid, host_name, wan_ip,
|
||||
autotask_company_id, autotask_company_name, rmm_site_uid, isp_name,
|
||||
last_synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW())
|
||||
ON CONFLICT (eventid) DO UPDATE SET
|
||||
r_eventid = EXCLUDED.r_eventid,
|
||||
r_clock = EXCLUDED.r_clock,
|
||||
duration_seconds = EXCLUDED.duration_seconds,
|
||||
host_name = EXCLUDED.host_name,
|
||||
wan_ip = EXCLUDED.wan_ip,
|
||||
autotask_company_id = EXCLUDED.autotask_company_id,
|
||||
autotask_company_name = EXCLUDED.autotask_company_name,
|
||||
rmm_site_uid = EXCLUDED.rmm_site_uid,
|
||||
isp_name = EXCLUDED.isp_name,
|
||||
last_synced_at = NOW()`,
|
||||
[
|
||||
ev.eventid,
|
||||
ev.objectid,
|
||||
ev.name,
|
||||
Number(ev.severity),
|
||||
clockTs,
|
||||
ev.r_eventid && ev.r_eventid !== '0' ? ev.r_eventid : null,
|
||||
rClockTs,
|
||||
duration,
|
||||
hostid,
|
||||
host.display_name,
|
||||
host.wan_ip,
|
||||
host.autotask_company_id,
|
||||
host.autotask_company_name,
|
||||
host.rmm_site_uid,
|
||||
host.isp_name,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
const stats = await postgresClient.query<{ total: string; open: string; oldest: string; newest: string }>(`
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE r_eventid IS NULL) AS open,
|
||||
MIN(clock) AS oldest,
|
||||
MAX(clock) AS newest
|
||||
FROM zabbix_events
|
||||
`);
|
||||
|
||||
return NextResponse.json({ upserted, no_host: noHost, days, ...stats.rows[0] });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
122
app/api/zabbix/sync-hosts/route.ts
Normal file
122
app/api/zabbix/sync-hosts/route.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* POST /api/zabbix/sync-hosts
|
||||
* Pulls all hosts from the live Zabbix API and caches them in zabbix_wan_hosts.
|
||||
* Also fetches current open problems and stamps last_problem_at on affected hosts.
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ZabbixClient } from '@/lib/services/zabbix-client';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST() {
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
return NextResponse.json({ error: 'Zabbix not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const zabbix = new ZabbixClient({
|
||||
apiUrl: process.env.ZABBIX_API_URL,
|
||||
apiToken: process.env.ZABBIX_API_TOKEN,
|
||||
});
|
||||
|
||||
try {
|
||||
const hosts = await zabbix.getHosts();
|
||||
|
||||
// Fetch open problems to stamp last_problem_at
|
||||
const problems = await zabbix.getOpenProblems(2);
|
||||
const problemByHostId = new Map<string, { name: string; clock: string }>();
|
||||
for (const p of problems) {
|
||||
for (const h of (p.hosts ?? [])) {
|
||||
if (!problemByHostId.has(h.hostid) || Number(p.clock) > Number(problemByHostId.get(h.hostid)!.clock)) {
|
||||
problemByHostId.set(h.hostid, { name: p.name, clock: p.clock });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let upserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const host of hosts) {
|
||||
// Only process ICMP/WAN hosts — they have an agent interface with an IP
|
||||
const iface = (host.interfaces ?? []).find(i => Number(i.useip) === 1 && i.ip && i.ip !== '127.0.0.1');
|
||||
if (!iface) { skipped++; continue; }
|
||||
|
||||
const getMacro = (name: string) =>
|
||||
(host.macros ?? []).find(m => m.macro === name)?.value ?? null;
|
||||
|
||||
const getTag = (name: string) =>
|
||||
(host.tags ?? []).find(t => t.tag === name)?.value ?? null;
|
||||
|
||||
const rmmSiteUid = getMacro('{$RMM_SITE_UID}');
|
||||
const autotaskCompanyId = getMacro('{$AUTOTASK_COMPANY_ID}');
|
||||
const autotaskCompanyName = getMacro('{$AUTOTASK_COMPANY_NAME}');
|
||||
const ispName = getMacro('{$ISP_NAME}');
|
||||
const asn = getMacro('{$ASN}');
|
||||
|
||||
const isMultiWan = getTag('multi-wan') === 'true';
|
||||
const source = getTag('source') ?? 'datto-rmm';
|
||||
|
||||
const problem = problemByHostId.get(host.hostid);
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO zabbix_wan_hosts (
|
||||
hostid, host_name, display_name, wan_ip, status,
|
||||
rmm_site_uid, autotask_company_id, autotask_company_name,
|
||||
isp_name, asn, is_multi_wan, source, tags,
|
||||
last_problem_at, last_problem_name,
|
||||
last_synced_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),NOW())
|
||||
ON CONFLICT (hostid) DO UPDATE SET
|
||||
host_name = EXCLUDED.host_name,
|
||||
display_name = EXCLUDED.display_name,
|
||||
wan_ip = EXCLUDED.wan_ip,
|
||||
status = EXCLUDED.status,
|
||||
rmm_site_uid = EXCLUDED.rmm_site_uid,
|
||||
autotask_company_id = EXCLUDED.autotask_company_id,
|
||||
autotask_company_name = EXCLUDED.autotask_company_name,
|
||||
isp_name = EXCLUDED.isp_name,
|
||||
asn = EXCLUDED.asn,
|
||||
is_multi_wan = EXCLUDED.is_multi_wan,
|
||||
source = EXCLUDED.source,
|
||||
tags = EXCLUDED.tags,
|
||||
last_problem_at = EXCLUDED.last_problem_at,
|
||||
last_problem_name = EXCLUDED.last_problem_name,
|
||||
last_synced_at = NOW(),
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
host.hostid,
|
||||
host.host,
|
||||
host.name ?? host.host,
|
||||
iface.ip,
|
||||
Number(host.status ?? 0),
|
||||
rmmSiteUid,
|
||||
autotaskCompanyId ? Number(autotaskCompanyId) : null,
|
||||
autotaskCompanyName,
|
||||
ispName,
|
||||
asn,
|
||||
isMultiWan,
|
||||
source,
|
||||
JSON.stringify(host.tags ?? []),
|
||||
problem ? new Date(Number(problem.clock) * 1000).toISOString() : null,
|
||||
problem?.name ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
// Remove hosts no longer in Zabbix
|
||||
const liveIds = hosts.map(h => h.hostid);
|
||||
if (liveIds.length > 0) {
|
||||
const deleted = await postgresClient.query(
|
||||
'DELETE FROM zabbix_wan_hosts WHERE hostid <> ALL($1) RETURNING hostid',
|
||||
[liveIds]
|
||||
);
|
||||
return NextResponse.json({ upserted, skipped, removed: deleted.rowCount });
|
||||
}
|
||||
|
||||
return NextResponse.json({ upserted, skipped, removed: 0 });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
166
app/api/zabbix/wan-gap-analysis/route.ts
Normal file
166
app/api/zabbix/wan-gap-analysis/route.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* GET /api/zabbix/wan-gap-analysis
|
||||
* Cross-references Datto RMM sites, Zabbix WAN hosts, and IT Glue WAN circuits
|
||||
* to surface monitoring gaps and event correlation.
|
||||
*
|
||||
* Returns:
|
||||
* - summary: counts of covered/gap/no-itg sites
|
||||
* - rmm_gaps: RMM sites with no Zabbix host
|
||||
* - itg_gaps: IT Glue WAN circuits with no matched Zabbix host (and have a static IP)
|
||||
* - multi_circuit_sites: companies with >1 IT Glue WAN circuit, showing Zabbix coverage per circuit
|
||||
* - current_problems: Zabbix hosts with active problems + matching recent RMM alerts
|
||||
* - last_synced: timestamps of the cache tables
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// ── 1. RMM sites missing from Zabbix ─────────────────────────────────────
|
||||
const rmmGaps = await postgresClient.query(`
|
||||
SELECT
|
||||
s.uid AS rmm_site_uid,
|
||||
s.name AS rmm_site_name,
|
||||
s.autotask_company_name AS company_name,
|
||||
rsm.company_id,
|
||||
s.number_of_devices,
|
||||
s.number_of_online_devices
|
||||
FROM datto_rmm_sites s
|
||||
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = s.uid
|
||||
LEFT JOIN zabbix_wan_hosts z ON z.rmm_site_uid = s.uid
|
||||
WHERE z.hostid IS NULL
|
||||
AND s.number_of_online_devices > 0
|
||||
ORDER BY s.autotask_company_name, s.name
|
||||
`);
|
||||
|
||||
// ── 2. IT Glue WAN circuits missing from Zabbix (have IPs, not decommissioned) ──
|
||||
const itgGaps = await postgresClient.query(`
|
||||
SELECT
|
||||
ic.id AS itg_asset_id,
|
||||
ic.org_name,
|
||||
ic.autotask_company_id,
|
||||
ic.provider,
|
||||
ic.link_type,
|
||||
ic.static_ips,
|
||||
ic.location_name,
|
||||
ic.location_city,
|
||||
ic.upload_mbps,
|
||||
ic.download_mbps
|
||||
FROM itg_wan_circuits ic
|
||||
WHERE ic.zabbix_hostid IS NULL
|
||||
AND ic.is_decommissioned = FALSE
|
||||
AND array_length(ic.static_ips, 1) > 0
|
||||
ORDER BY ic.org_name, ic.provider
|
||||
`);
|
||||
|
||||
// ── 3. Multi-circuit companies (>1 IT Glue WAN circuit) ──────────────────
|
||||
const multiCircuit = await postgresClient.query(`
|
||||
SELECT
|
||||
ic.org_name,
|
||||
ic.autotask_company_id,
|
||||
COUNT(*) AS total_circuits,
|
||||
COUNT(*) FILTER (WHERE ic.zabbix_hostid IS NOT NULL) AS monitored_circuits,
|
||||
COUNT(*) FILTER (WHERE ic.zabbix_hostid IS NULL
|
||||
AND NOT ic.is_decommissioned
|
||||
AND array_length(ic.static_ips,1) > 0) AS gap_circuits,
|
||||
json_agg(json_build_object(
|
||||
'id', ic.id,
|
||||
'provider', ic.provider,
|
||||
'link_type', ic.link_type,
|
||||
'static_ips', ic.static_ips,
|
||||
'location_name', ic.location_name,
|
||||
'zabbix_hostid', ic.zabbix_hostid,
|
||||
'is_decommissioned', ic.is_decommissioned
|
||||
) ORDER BY ic.provider) AS circuits
|
||||
FROM itg_wan_circuits ic
|
||||
WHERE NOT ic.is_decommissioned
|
||||
GROUP BY ic.org_name, ic.autotask_company_id
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY gap_circuits DESC, ic.org_name
|
||||
`);
|
||||
|
||||
// ── 4. Current Zabbix problems + correlated RMM alerts ───────────────────
|
||||
const problems = await postgresClient.query(`
|
||||
SELECT
|
||||
z.hostid,
|
||||
z.display_name,
|
||||
z.wan_ip,
|
||||
z.isp_name,
|
||||
z.autotask_company_name,
|
||||
z.autotask_company_id,
|
||||
z.rmm_site_uid,
|
||||
z.last_problem_at,
|
||||
z.last_problem_name,
|
||||
-- Count open RMM alerts for the same company in the last 24h
|
||||
(
|
||||
SELECT COUNT(*) FROM datto_rmm_alerts a
|
||||
JOIN datto_rmm_sites ds ON ds.uid = a.site_uid
|
||||
WHERE ds.autotask_company_id = z.autotask_company_id
|
||||
AND a.resolved = FALSE
|
||||
AND a.timestamp > NOW() - INTERVAL '24 hours'
|
||||
) AS open_rmm_alerts_24h,
|
||||
-- Most recent RMM network alert for this company
|
||||
(
|
||||
SELECT a.alert_message_en FROM datto_rmm_alerts a
|
||||
JOIN datto_rmm_sites ds ON ds.uid = a.site_uid
|
||||
WHERE ds.autotask_company_id = z.autotask_company_id
|
||||
AND (a.alert_category ILIKE '%network%' OR a.alert_category ILIKE '%wan%'
|
||||
OR a.alert_type ILIKE '%ping%' OR a.alert_type ILIKE '%offline%')
|
||||
AND a.timestamp > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY a.timestamp DESC LIMIT 1
|
||||
) AS latest_rmm_network_alert,
|
||||
-- Open ticket count for this company today
|
||||
(
|
||||
SELECT COUNT(*) FROM tickets t
|
||||
WHERE t.company_id = z.autotask_company_id
|
||||
AND t.source = 8
|
||||
AND t.create_date > NOW() - INTERVAL '24 hours'
|
||||
AND t.is_deleted IS NOT TRUE
|
||||
) AS monitor_tickets_24h
|
||||
FROM zabbix_wan_hosts z
|
||||
WHERE z.last_problem_at > NOW() - INTERVAL '24 hours'
|
||||
OR z.status = 1
|
||||
ORDER BY z.last_problem_at DESC NULLS LAST
|
||||
`);
|
||||
|
||||
// ── 5. Summary counts ─────────────────────────────────────────────────────
|
||||
const summary = await postgresClient.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM datto_rmm_sites s JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = s.uid)
|
||||
AS total_rmm_sites,
|
||||
(SELECT COUNT(*) FROM zabbix_wan_hosts)
|
||||
AS total_zabbix_hosts,
|
||||
(SELECT COUNT(*) FROM zabbix_wan_hosts WHERE status = 0)
|
||||
AS zabbix_enabled,
|
||||
(SELECT COUNT(*) FROM datto_rmm_sites s
|
||||
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = s.uid
|
||||
LEFT JOIN zabbix_wan_hosts z ON z.rmm_site_uid = s.uid
|
||||
WHERE z.hostid IS NULL AND s.number_of_online_devices > 0)
|
||||
AS rmm_sites_no_zabbix,
|
||||
(SELECT COUNT(*) FROM itg_wan_circuits WHERE NOT is_decommissioned)
|
||||
AS total_itg_circuits,
|
||||
(SELECT COUNT(*) FROM itg_wan_circuits
|
||||
WHERE zabbix_hostid IS NOT NULL AND NOT is_decommissioned)
|
||||
AS itg_circuits_monitored,
|
||||
(SELECT COUNT(*) FROM itg_wan_circuits
|
||||
WHERE zabbix_hostid IS NULL AND NOT is_decommissioned
|
||||
AND array_length(static_ips,1) > 0)
|
||||
AS itg_circuits_gap,
|
||||
(SELECT MAX(last_synced_at) FROM zabbix_wan_hosts)
|
||||
AS zabbix_last_synced,
|
||||
(SELECT MAX(last_synced_at) FROM itg_wan_circuits)
|
||||
AS itg_last_synced
|
||||
`);
|
||||
|
||||
return NextResponse.json({
|
||||
summary: summary.rows[0],
|
||||
rmm_gaps: rmmGaps.rows,
|
||||
itg_gaps: itgGaps.rows,
|
||||
multi_circuit: multiCircuit.rows,
|
||||
problems: problems.rows,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
188
app/api/zabbix/webhook/route.ts
Normal file
188
app/api/zabbix/webhook/route.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* POST /api/zabbix/webhook
|
||||
* Receives Zabbix alert/recovery notifications and writes them to zabbix_events.
|
||||
* Enriches each event with company/site context from the local zabbix_wan_hosts cache.
|
||||
*
|
||||
* Expected JSON payload (sent by the Zabbix webhook media type script):
|
||||
* {
|
||||
* event_id: string -- {EVENT.ID}
|
||||
* event_name: string -- {EVENT.NAME}
|
||||
* event_value: string -- "1" = PROBLEM, "0" = RESOLVED
|
||||
* event_severity: string -- numeric severity "0"–"5"
|
||||
* event_clock: string -- Unix timestamp string
|
||||
* trigger_id: string -- {TRIGGER.ID}
|
||||
* host_id: string -- {HOST.ID}
|
||||
* host_name: string -- {HOST.HOST} (technical name)
|
||||
* r_event_id?: string -- {EVENT.RECOVERY.ID} (only on recovery)
|
||||
* r_clock?: string -- {EVENT.RECOVERY.DATE} as unix ts (only on recovery)
|
||||
* }
|
||||
*
|
||||
* Auth: Bearer token in Authorization header, matched against ZABBIX_WEBHOOK_SECRET env var.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Optional shared secret — skip check if not configured
|
||||
const secret = process.env.ZABBIX_WEBHOOK_SECRET;
|
||||
if (secret) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth;
|
||||
if (token !== secret) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
let body: Record<string, string>;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
console.log('[ZABBIX-WEBHOOK] Incoming request — auth:', request.headers.get('authorization') ? 'present' : 'none', '— body:', raw.substring(0, 500));
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
console.log('[ZABBIX-WEBHOOK] Failed to parse JSON');
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const {
|
||||
event_id,
|
||||
event_name,
|
||||
event_value,
|
||||
event_severity,
|
||||
event_clock,
|
||||
trigger_id,
|
||||
host_id,
|
||||
r_event_id,
|
||||
r_clock,
|
||||
} = body;
|
||||
|
||||
if (!event_id || !host_id || !event_clock) {
|
||||
return NextResponse.json({ error: 'Missing required fields: event_id, host_id, event_clock' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Enrich with local host context
|
||||
const hostRow = await postgresClient.query<{
|
||||
display_name: string;
|
||||
wan_ip: string | null;
|
||||
autotask_company_id: number | null;
|
||||
autotask_company_name: string | null;
|
||||
rmm_site_uid: string | null;
|
||||
isp_name: string | null;
|
||||
}>(
|
||||
'SELECT display_name, wan_ip, autotask_company_id, autotask_company_name, rmm_site_uid, isp_name FROM zabbix_wan_hosts WHERE hostid = $1',
|
||||
[host_id]
|
||||
);
|
||||
|
||||
const host = hostRow.rows[0] ?? null;
|
||||
|
||||
const toInt = (v: string | undefined) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
|
||||
const toTs = (v: string | undefined) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null; };
|
||||
const isMacro = (v: string | undefined) => !v || v.startsWith('{');
|
||||
|
||||
const isResolved = event_value === '0';
|
||||
const clockTs = toTs(event_clock) ?? new Date();
|
||||
// {EVENT.RECOVERY.CLOCK} often doesn't resolve in webhook params — fall back to NOW() for recoveries
|
||||
const rClockTs = toTs(r_clock) ?? (isResolved ? new Date() : null);
|
||||
const duration = rClockTs ? Math.round((rClockTs.getTime() - clockTs.getTime()) / 1000) : null;
|
||||
// {EVENT.RECOVERY.ID} also may not resolve — use event_id as marker so the row is flagged resolved
|
||||
const rEventId = !isMacro(r_event_id) && r_event_id !== '0'
|
||||
? r_event_id
|
||||
: (isResolved ? event_id : null);
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO zabbix_events (
|
||||
eventid, objectid, name, severity, clock,
|
||||
r_eventid, r_clock, duration_seconds,
|
||||
hostid, host_name, wan_ip,
|
||||
autotask_company_id, autotask_company_name, rmm_site_uid, isp_name,
|
||||
last_synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW())
|
||||
ON CONFLICT (eventid) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
r_eventid = COALESCE(EXCLUDED.r_eventid, zabbix_events.r_eventid),
|
||||
r_clock = COALESCE(EXCLUDED.r_clock, zabbix_events.r_clock),
|
||||
duration_seconds = COALESCE(EXCLUDED.duration_seconds, zabbix_events.duration_seconds),
|
||||
host_name = EXCLUDED.host_name,
|
||||
wan_ip = EXCLUDED.wan_ip,
|
||||
autotask_company_id = EXCLUDED.autotask_company_id,
|
||||
autotask_company_name = EXCLUDED.autotask_company_name,
|
||||
rmm_site_uid = EXCLUDED.rmm_site_uid,
|
||||
isp_name = EXCLUDED.isp_name,
|
||||
last_synced_at = NOW()`,
|
||||
[
|
||||
event_id,
|
||||
trigger_id ?? null,
|
||||
event_name ?? null,
|
||||
toInt(event_severity),
|
||||
clockTs,
|
||||
rEventId,
|
||||
rClockTs,
|
||||
duration,
|
||||
host_id,
|
||||
host?.display_name ?? body.host_name ?? null,
|
||||
host?.wan_ip ?? null,
|
||||
host?.autotask_company_id ?? null,
|
||||
host?.autotask_company_name ?? null,
|
||||
host?.rmm_site_uid ?? null,
|
||||
host?.isp_name ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, event_id, resolved: isResolved });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[ZABBIX-WEBHOOK]', msg);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/zabbix/webhook
|
||||
* Returns the Zabbix media type script and parameter config ready to paste.
|
||||
*/
|
||||
export async function GET() {
|
||||
const baseUrl = process.env.WEBHOOK_BASE_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? 'https://your-pulse-url';
|
||||
const secret = process.env.ZABBIX_WEBHOOK_SECRET ?? '';
|
||||
|
||||
const script = `// Pulse WAN Correlation Webhook
|
||||
var params = JSON.parse(value);
|
||||
var req = new HttpRequest();
|
||||
req.addHeader('Content-Type: application/json');
|
||||
${secret ? "req.addHeader('Authorization: Bearer ' + params.webhook_secret);" : '// No auth configured — set ZABBIX_WEBHOOK_SECRET env var to enable'}
|
||||
|
||||
var payload = JSON.stringify({
|
||||
event_id: params.event_id,
|
||||
event_name: params.event_name,
|
||||
event_value: params.event_value,
|
||||
event_severity: params.event_severity,
|
||||
event_clock: params.event_clock,
|
||||
trigger_id: params.trigger_id,
|
||||
host_id: params.host_id,
|
||||
host_name: params.host_name,
|
||||
r_event_id: params.r_event_id,
|
||||
r_clock: params.r_clock
|
||||
});
|
||||
|
||||
var response = req.post(params.webhook_url, payload);
|
||||
if (req.getStatus() !== 200) {
|
||||
throw 'Pulse webhook failed: HTTP ' + req.getStatus() + ' — ' + response;
|
||||
}
|
||||
return 'OK';`;
|
||||
|
||||
const parameters = [
|
||||
{ name: 'webhook_url', value: `${baseUrl}/api/zabbix/webhook` },
|
||||
{ name: 'webhook_secret', value: secret || '(set ZABBIX_WEBHOOK_SECRET env var)' },
|
||||
{ name: 'event_id', value: '{EVENT.ID}' },
|
||||
{ name: 'event_name', value: '{EVENT.NAME}' },
|
||||
{ name: 'event_value', value: '{EVENT.VALUE}' },
|
||||
{ name: 'event_severity', value: '{EVENT.SEVERITY.NUM}' },
|
||||
{ name: 'event_clock', value: '{EVENT.CLOCK}' },
|
||||
{ name: 'trigger_id', value: '{TRIGGER.ID}' },
|
||||
{ name: 'host_id', value: '{HOST.ID}' },
|
||||
{ name: 'host_name', value: '{HOST.HOST}' },
|
||||
{ name: 'r_event_id', value: '{EVENT.RECOVERY.ID}' },
|
||||
{ name: 'r_clock', value: '{EVENT.RECOVERY.CLOCK}' },
|
||||
];
|
||||
|
||||
return NextResponse.json({ script, parameters, webhook_url: `${baseUrl}/api/zabbix/webhook` });
|
||||
}
|
||||
67
app/legal/eula/page.tsx
Normal file
67
app/legal/eula/page.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
export default function EndUserLicenseAgreement() {
|
||||
return (
|
||||
<main style={{ maxWidth: 800, margin: '40px auto', padding: '0 24px', fontFamily: 'sans-serif', lineHeight: 1.7 }}>
|
||||
<h1>End-User License Agreement</h1>
|
||||
<p><strong>Last updated:</strong> March 17, 2026</p>
|
||||
|
||||
<p>
|
||||
This End-User License Agreement (“Agreement”) governs your use of the Pulse
|
||||
internal operations platform (“Application”) developed and operated by{' '}
|
||||
<strong>Wulf Consulting, Inc.</strong> (“Wulf Consulting”).
|
||||
</p>
|
||||
|
||||
<h2>1. Internal Use Only</h2>
|
||||
<p>
|
||||
The Application is licensed exclusively for internal use by authorized Wulf Consulting
|
||||
employees and contractors. Access by unauthorized individuals is strictly prohibited.
|
||||
</p>
|
||||
|
||||
<h2>2. License Grant</h2>
|
||||
<p>
|
||||
Wulf Consulting grants authorized users a non-exclusive, non-transferable, revocable
|
||||
license to access and use the Application solely for internal business operations.
|
||||
</p>
|
||||
|
||||
<h2>3. Third-Party Integrations</h2>
|
||||
<p>
|
||||
The Application integrates with QuickBooks Online via the Intuit API. Use of QuickBooks
|
||||
Online data within the Application is subject to Intuit's Terms of Service. Users
|
||||
must not use the Application to access QuickBooks data beyond what is required for
|
||||
legitimate internal business purposes.
|
||||
</p>
|
||||
|
||||
<h2>4. Restrictions</h2>
|
||||
<ul>
|
||||
<li>You may not redistribute, sublicense, or resell access to the Application.</li>
|
||||
<li>You may not use the Application to process data for third parties outside Wulf Consulting.</li>
|
||||
<li>You may not reverse-engineer or attempt to extract source code from the Application.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Data Handling</h2>
|
||||
<p>
|
||||
All financial data accessed through the Application is handled in accordance with our
|
||||
Privacy Policy. Data is stored on Wulf Consulting's private infrastructure and is
|
||||
not shared externally.
|
||||
</p>
|
||||
|
||||
<h2>6. Termination</h2>
|
||||
<p>
|
||||
This license is effective until terminated. Wulf Consulting may terminate access at any
|
||||
time. Upon termination, you must cease all use of the Application.
|
||||
</p>
|
||||
|
||||
<h2>7. Disclaimer of Warranties</h2>
|
||||
<p>
|
||||
The Application is provided “as is” for internal operational use. Wulf
|
||||
Consulting makes no warranties regarding uptime, accuracy of synced data, or fitness for
|
||||
any particular purpose beyond internal operations.
|
||||
</p>
|
||||
|
||||
<h2>8. Contact</h2>
|
||||
<p>
|
||||
For questions about this Agreement, contact{' '}
|
||||
<a href="mailto:admin@wulfconsulting.com">admin@wulfconsulting.com</a>.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
49
app/legal/privacy/page.tsx
Normal file
49
app/legal/privacy/page.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<main style={{ maxWidth: 800, margin: '40px auto', padding: '0 24px', fontFamily: 'sans-serif', lineHeight: 1.7 }}>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p><strong>Last updated:</strong> March 17, 2026</p>
|
||||
|
||||
<p>
|
||||
Pulse is an internal operations platform operated by <strong>Wulf Consulting, Inc.</strong>
|
||||
(“Wulf Consulting”, “we”, “us”). This application is for
|
||||
internal business use only and is not available to the general public.
|
||||
</p>
|
||||
|
||||
<h2>Information We Access</h2>
|
||||
<p>
|
||||
Pulse connects to QuickBooks Online via the Intuit OAuth 2.0 API to read financial data
|
||||
including invoices, payments, deposits, transactions, and financial reports. This data is
|
||||
accessed solely for internal reporting and business operations purposes.
|
||||
</p>
|
||||
|
||||
<h2>How We Use Your Data</h2>
|
||||
<ul>
|
||||
<li>Financial data is stored in a private, self-hosted PostgreSQL database.</li>
|
||||
<li>Data is used exclusively for internal dashboards and reporting.</li>
|
||||
<li>No financial data is shared with third parties.</li>
|
||||
<li>No data is sold or used for advertising.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data Storage and Security</h2>
|
||||
<p>
|
||||
All data is stored on servers controlled by Wulf Consulting. Access is restricted to
|
||||
authorized Wulf Consulting staff only. OAuth tokens are stored securely and are never
|
||||
exposed externally.
|
||||
</p>
|
||||
|
||||
<h2>Data Retention</h2>
|
||||
<p>
|
||||
Synced financial data is retained for operational reporting purposes. OAuth tokens are
|
||||
refreshed automatically and can be revoked at any time via the QuickBooks Online app
|
||||
authorization settings.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
For questions about this policy, contact <strong>Wulf Consulting, Inc.</strong> at{' '}
|
||||
<a href="mailto:admin@wulfconsulting.com">admin@wulfconsulting.com</a>.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue