feat: QuickBooks Online integration

- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts)
- Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts)
- Add QBO types (lib/types/qbo.ts)
- Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect
- Add /admin/qbo status and sync management page
- Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment)
- Add QBO nav link under Admin
- Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all
- Add CashFlow report type alongside P&L and BalanceSheet
- Add NoReportData check to skip empty report months
- Add intuit_tid capture in error messages
- Add redirect: follow for cluster routing
- Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables

Also includes earlier work:
- Ping flap suppression pipeline step
- Ticket digest reports with LLM analysis
- Zabbix WAN monitor and gap analysis
- Kiosk is_deleted filter fixes
- Datto RMM ping target enrichment
- Entity sync soft-delete detection
This commit is contained in:
lorentz 2026-03-17 07:39:55 -04:00
parent c518eefdb2
commit b98c67482a
40 changed files with 6223 additions and 15 deletions

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

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

View file

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

View file

@ -41,6 +41,12 @@ import {
ChevronDown,
ChevronRight,
Network,
ShieldAlert,
Layers,
BookOpen,
Activity,
Clipboard,
ClipboardCheck,
} from 'lucide-react';
import { toast } from 'sonner';
import { HostManager } from '@/components/zabbix/host-manager';
@ -102,7 +108,37 @@ function ActionBadge({ action }: { action: string }) {
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Gap Analysis types
// ─────────────────────────────────────────────────────────────────────────────
interface GapSummary {
total_rmm_sites: string;
total_zabbix_hosts: string;
zabbix_enabled: string;
rmm_sites_no_zabbix: string;
total_itg_circuits: string;
itg_circuits_monitored: string;
itg_circuits_gap: string;
zabbix_last_synced: string | null;
itg_last_synced: string | null;
}
interface RmmGap { rmm_site_uid: string; rmm_site_name: string; company_name: string; company_id: number; number_of_devices: number; number_of_online_devices: number; }
interface ItgGap { itg_asset_id: number; org_name: string; autotask_company_id: number | null; provider: string; link_type: string; static_ips: string[]; location_name: string; location_city: string; upload_mbps: number; download_mbps: number; }
interface MultiCircuit { org_name: string; autotask_company_id: number | null; total_circuits: string; monitored_circuits: string; gap_circuits: string; circuits: Array<{ id: number; provider: string; link_type: string; static_ips: string[]; location_name: string; zabbix_hostid: string | null; is_decommissioned: boolean; }>; }
interface ProblemRow { hostid: string; display_name: string; wan_ip: string; isp_name: string; autotask_company_name: string; autotask_company_id: number; rmm_site_uid: string; last_problem_at: string; last_problem_name: string; open_rmm_alerts_24h: string; latest_rmm_network_alert: string | null; monitor_tickets_24h: string; }
// ─────────────────────────────────────────────────────────────────────────────
// Correlation types
// ─────────────────────────────────────────────────────────────────────────────
interface CorrSummary { days: number; window_mins: number; zabbix_events_total: number; zabbix_with_rmm_match: number; zabbix_without_rmm_match: number; rmm_only_alerts: number; last_event_sync: string | null; }
interface RmmAlertRef { alert_uid: string; site_name: string; alert_class: string; alert_message: string | null; timestamp: string; resolved: boolean; resolved_on: string | null; device_name: string | null; }
interface ZabbixEventRow { eventid: string; name: string; severity: number; clock: string; r_clock: string | null; duration_seconds: number | null; host_name: string; wan_ip: string; isp_name: string | null; autotask_company_id: number; autotask_company_name: string; rmm_site_uid: string | null; rmm_alert_count: string; rmm_alerts: RmmAlertRef[] | null; }
interface RmmOnlyRow { alert_uid: string; site_name: string; alert_class: string; alert_message: string | null; timestamp: string; resolved: boolean; resolved_on: string | null; device_name: string | null; autotask_company_id: number | null; autotask_company_name: string | null; }
type PageTab = 'sync' | 'gaps' | 'correlation';
export default function ZabbixWanPage() {
const [activeTab, setActiveTab] = useState<PageTab>('sync');
const [mode, setMode] = useState<SyncMode>('all');
const [companyId, setCompanyId] = useState<string>('');
const [siteUid, setSiteUid] = useState<string>('');
@ -122,6 +158,117 @@ export default function ZabbixWanPage() {
const abortRef = useRef<AbortController | null>(null);
const tableBottomRef = useRef<HTMLDivElement>(null);
// Gap analysis state
const [gapLoading, setGapLoading] = useState(false);
const [syncing, setSyncing] = useState<'zabbix' | 'itg' | null>(null);
const [gapSummary, setGapSummary] = useState<GapSummary | null>(null);
const [rmmGaps, setRmmGaps] = useState<RmmGap[]>([]);
const [itgGaps, setItgGaps] = useState<ItgGap[]>([]);
const [multiCircuit, setMultiCircuit] = useState<MultiCircuit[]>([]);
const [problems, setProblems] = useState<ProblemRow[]>([]);
const [gapError, setGapError] = useState<string | null>(null);
const [expandedMulti, setExpandedMulti] = useState<Set<string>>(new Set());
const loadGapAnalysis = async () => {
setGapLoading(true); setGapError(null);
try {
const r = await fetch('/api/zabbix/wan-gap-analysis');
const d = await r.json();
if (!r.ok) throw new Error(d.error);
setGapSummary(d.summary);
setRmmGaps(d.rmm_gaps ?? []);
setItgGaps(d.itg_gaps ?? []);
setMultiCircuit(d.multi_circuit ?? []);
setProblems(d.problems ?? []);
} catch (e) { setGapError(String(e)); }
finally { setGapLoading(false); }
};
const syncZabbixHosts = async () => {
setSyncing('zabbix');
try {
const r = await fetch('/api/zabbix/sync-hosts', { method: 'POST' });
const d = await r.json();
if (!r.ok) throw new Error(d.error);
toast.success(`Zabbix hosts synced — ${d.upserted} upserted, ${d.removed} removed`);
await loadGapAnalysis();
} catch (e) { toast.error('Zabbix sync failed: ' + String(e)); }
finally { setSyncing(null); }
};
const syncItgCircuits = async () => {
setSyncing('itg');
try {
const r = await fetch('/api/itglue/sync-wan', { method: 'POST' });
const d = await r.json();
if (!r.ok) throw new Error(d.error);
toast.success(`IT Glue WAN synced — ${d.upserted} circuits, ${d.matched_zabbix} matched to Zabbix`);
await loadGapAnalysis();
} catch (e) { toast.error('IT Glue sync failed: ' + String(e)); }
finally { setSyncing(null); }
};
const fmtSynced = (ts: string | null) => ts ? new Date(ts).toLocaleString() : 'Never';
const fmtTs = (ts: string | null) => ts ? new Date(ts).toLocaleString() : '—';
const fmtDuration = (s: number | null, hasRecovery?: boolean) => {
if (s === null || s === undefined) return hasRecovery ? '< 1m' : 'Open';
if (s === 0) return hasRecovery ? '< 1m' : 'Open';
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.round(s/60)}m`;
return `${Math.floor(s/3600)}h ${Math.round((s%3600)/60)}m`;
};
const severityLabel = (s: number) => ['','Info','Warning','Average','High','Disaster'][s] ?? String(s);
const severityClass = (s: number) => s >= 4 ? 'text-red-600 font-semibold' : s === 3 ? 'text-orange-500' : 'text-yellow-500';
// Correlation state
const [corrDays, setCorrDays] = useState(30);
const [corrWindow, setCorrWindow] = useState(120);
const [corrLoading, setCorrLoading] = useState(false);
const [corrSummary, setCorrSummary] = useState<CorrSummary | null>(null);
const [zabbixEvents, setZabbixEvents] = useState<ZabbixEventRow[]>([]);
const [rmmOnly, setRmmOnly] = useState<RmmOnlyRow[]>([]);
const [corrError, setCorrError] = useState<string | null>(null);
const [expandedEvent, setExpandedEvent] = useState<Set<string>>(new Set());
const [corrFilter, setCorrFilter] = useState<'all' | 'matched' | 'unmatched'>('all');
const [webhookConfig, setWebhookConfig] = useState<{ script: string; parameters: Array<{name:string;value:string}>; webhook_url: string } | null>(null);
const [copied, setCopied] = useState<'script'|'url'|null>(null);
const [webhookCollapsed, setWebhookCollapsed] = useState(true);
const [rmmOnlyCollapsed, setRmmOnlyCollapsed] = useState(true);
const loadWebhookConfig = async () => {
try {
const r = await fetch('/api/zabbix/webhook');
const d = await r.json();
setWebhookConfig(d);
} catch { /* ignore */ }
};
const copyText = async (text: string, key: 'script'|'url') => {
await navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
const loadCorrelation = async () => {
setCorrLoading(true); setCorrError(null);
try {
const r = await fetch(`/api/zabbix/alert-correlation?days=${corrDays}&windowMins=${corrWindow}`);
const d = await r.json();
if (!r.ok) throw new Error(d.error);
setCorrSummary(d.summary);
setZabbixEvents(d.zabbix_events ?? []);
setRmmOnly(d.rmm_only ?? []);
} catch (e) { setCorrError(String(e)); }
finally { setCorrLoading(false); }
};
const filteredEvents = zabbixEvents.filter(e =>
corrFilter === 'all' ? true :
corrFilter === 'matched' ? Number(e.rmm_alert_count) > 0 :
Number(e.rmm_alert_count) === 0
);
// Manual host creation state
const [manualOpen, setManualOpen] = useState(false);
const [manualIp, setManualIp] = useState('');
@ -289,11 +436,6 @@ export default function ZabbixWanPage() {
<div className="container mx-auto py-8 max-w-6xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/admin/sync">
<Button variant="ghost" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" /> Back
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Globe className="w-6 h-6" /> Zabbix WAN Monitor Setup
@ -304,6 +446,29 @@ export default function ZabbixWanPage() {
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b">
{([['sync', Globe, 'WAN Sync'], ['gaps', ShieldAlert, 'Gap Analysis'], ['correlation', Activity, 'Alert Correlation']] as const).map(([tab, Icon, label]) => (
<button
key={tab}
onClick={() => {
setActiveTab(tab as PageTab);
if (tab === 'gaps' && !gapSummary) loadGapAnalysis();
if (tab === 'correlation' && !webhookConfig) loadWebhookConfig();
}}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="w-4 h-4" /> {label}
</button>
))}
</div>
{/* ── WAN Sync tab ─────────────────────────────────────────────────── */}
{activeTab === 'sync' && (<>
{/* Config card */}
<Card>
<CardHeader className="pb-4">
@ -732,6 +897,531 @@ export default function ZabbixWanPage() {
Configure your options above and click {dryRun ? 'Preview' : 'Run'} to start.
</div>
)}
</>)}
{/* ── Alert Correlation tab ──────────────────────────────────────── */}
{activeTab === 'correlation' && (
<div className="space-y-6">
{/* Webhook setup */}
{webhookConfig && (
<Card>
<CardHeader className="pb-3 cursor-pointer select-none" onClick={() => setWebhookCollapsed(v => !v)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2"><Clipboard className="w-4 h-4" /> Zabbix Webhook Setup</CardTitle>
{webhookCollapsed ? <ChevronRight className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</div>
{webhookCollapsed && <CardDescription>Click to expand setup instructions</CardDescription>}
{!webhookCollapsed && <CardDescription>Configure a Webhook media type in Zabbix Administration Media types, then create an action that sends to all WAN hosts</CardDescription>}
</CardHeader>
{!webhookCollapsed && <CardContent className="space-y-4">
<div className="flex items-center gap-3">
<code className="flex-1 bg-muted rounded px-3 py-2 text-sm font-mono truncate">{webhookConfig.webhook_url}</code>
<Button variant="outline" size="sm" onClick={() => copyText(webhookConfig.webhook_url, 'url')} className="gap-1.5 shrink-0">
{copied === 'url' ? <ClipboardCheck className="w-3.5 h-3.5 text-green-500" /> : <Clipboard className="w-3.5 h-3.5" />}
{copied === 'url' ? 'Copied' : 'Copy URL'}
</Button>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Media type script</p>
<Button variant="outline" size="sm" onClick={() => copyText(webhookConfig.script, 'script')} className="gap-1.5">
{copied === 'script' ? <ClipboardCheck className="w-3.5 h-3.5 text-green-500" /> : <Clipboard className="w-3.5 h-3.5" />}
{copied === 'script' ? 'Copied' : 'Copy Script'}
</Button>
</div>
<pre className="bg-muted rounded p-3 text-xs font-mono overflow-x-auto max-h-40 leading-relaxed">{webhookConfig.script}</pre>
</div>
<div className="space-y-1.5">
<p className="text-sm font-medium">Parameters to add</p>
<div className="border rounded divide-y text-xs">
{webhookConfig.parameters.map(p => (
<div key={p.name} className="flex items-center px-3 py-1.5 gap-4">
<span className="font-mono w-36 shrink-0 text-muted-foreground">{p.name}</span>
<span className="font-mono">{p.value}</span>
</div>
))}
</div>
</div>
</CardContent>}
</Card>
)}
{/* Controls */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2"><Activity className="w-4 h-4" /> Alert Correlation</CardTitle>
<CardDescription>Compare Zabbix WAN events against Datto RMM ping/offline alerts for the same company and time window</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={loadCorrelation} disabled={corrLoading} className="gap-2">
{corrLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />} Refresh
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end gap-6">
<div className="space-y-1.5">
<Label className="text-sm">Look-back period</Label>
<div className="flex items-center gap-2">
<Input type="number" min={1} max={90} value={corrDays} onChange={e => setCorrDays(Math.max(1,Number(e.target.value)))} className="w-20" />
<span className="text-sm text-muted-foreground">days</span>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm">Match window</Label>
<div className="flex items-center gap-2">
<Input type="number" min={5} max={480} value={corrWindow} onChange={e => setCorrWindow(Math.max(5,Number(e.target.value)))} className="w-20" />
<span className="text-sm text-muted-foreground">min ±</span>
</div>
</div>
</div>
{corrSummary && (
<>
<p className="text-xs text-muted-foreground">Last event sync: {fmtSynced(corrSummary.last_event_sync)}</p>
<div className="grid grid-cols-4 gap-3">
<div className="border rounded-lg p-3 text-center"><p className="text-2xl font-bold">{corrSummary.zabbix_events_total}</p><p className="text-xs text-muted-foreground mt-0.5">Zabbix events</p></div>
<div className="border rounded-lg p-3 text-center bg-green-500/5">
<p className="text-2xl font-bold text-green-600">{corrSummary.zabbix_with_rmm_match}</p>
<p className="text-xs text-muted-foreground mt-0.5">Zabbix + RMM correlated</p>
</div>
<div className="border rounded-lg p-3 text-center bg-amber-500/5">
<p className="text-2xl font-bold text-amber-600">{corrSummary.zabbix_without_rmm_match}</p>
<p className="text-xs text-muted-foreground mt-0.5">Zabbix only (no RMM match)</p>
</div>
<div className="border rounded-lg p-3 text-center bg-blue-500/5">
<p className="text-2xl font-bold text-blue-600">{corrSummary.rmm_only_alerts}</p>
<p className="text-xs text-muted-foreground mt-0.5">RMM only (no Zabbix event)</p>
</div>
</div>
</>
)}
</CardContent>
</Card>
{corrError && (
<div className="flex items-center gap-2 text-sm text-destructive border border-destructive/30 bg-destructive/5 rounded-md p-3">
<AlertTriangle className="w-4 h-4" /> {corrError}
</div>
)}
{/* Zabbix events table */}
{(filteredEvents.length > 0 || corrSummary) && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2"><Globe className="w-4 h-4" /> Zabbix WAN Events</CardTitle>
<div className="flex gap-1">
{(['all','matched','unmatched'] as const).map(f => (
<button key={f} onClick={() => setCorrFilter(f)}
className={`px-3 py-1 rounded text-xs font-medium border transition-colors ${
corrFilter === f ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-accent'
}`}>
{f === 'all' ? `All (${zabbixEvents.length})` : f === 'matched' ? `RMM match (${corrSummary?.zabbix_with_rmm_match ?? 0})` : `No RMM (${corrSummary?.zabbix_without_rmm_match ?? 0})`}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead>Host / Client</TableHead>
<TableHead>WAN IP</TableHead>
<TableHead>Problem</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-center">RMM Alerts</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.map(ev => {
const matched = Number(ev.rmm_alert_count) > 0;
const expanded = expandedEvent.has(ev.eventid);
const alerts: RmmAlertRef[] = ev.rmm_alerts ?? [];
return (<>
<TableRow
key={ev.eventid}
className={`cursor-pointer ${matched ? 'hover:bg-green-500/5' : 'hover:bg-amber-500/5 bg-amber-500/[0.03]'}`}
onClick={() => setExpandedEvent(prev => { const n = new Set(prev); expanded ? n.delete(ev.eventid) : n.add(ev.eventid); return n; })}
>
<TableCell>
<p className="font-medium text-sm">{ev.host_name}</p>
<p className="text-xs text-muted-foreground">{ev.autotask_company_name}</p>
</TableCell>
<TableCell className="font-mono text-sm">{ev.wan_ip}</TableCell>
<TableCell className="text-sm max-w-[200px] truncate" title={ev.name}>{ev.name}</TableCell>
<TableCell><span className={`text-sm ${severityClass(ev.severity)}`}>{severityLabel(ev.severity)}</span></TableCell>
<TableCell className="text-sm">{fmtTs(ev.clock)}</TableCell>
<TableCell className="text-sm tabular-nums">{fmtDuration(ev.duration_seconds, !!ev.r_clock)}</TableCell>
<TableCell className="text-center">
{matched
? <Badge variant="default" className="bg-green-600">{ev.rmm_alert_count}</Badge>
: <Badge variant="outline" className="text-amber-600 border-amber-400">0</Badge>}
</TableCell>
</TableRow>
{expanded && alerts.length > 0 && alerts.map(a => (
<TableRow key={a.alert_uid} className="bg-green-500/5">
<TableCell className="pl-8 text-xs text-muted-foreground" colSpan={2}>
{a.device_name && <span className="font-medium text-foreground">{a.device_name}</span>} · {a.site_name}
</TableCell>
<TableCell className="text-xs" colSpan={2}>
<Badge variant="outline" className="text-xs mr-2">{a.alert_class}</Badge>
<span className="text-muted-foreground truncate max-w-[200px]">{a.alert_message ?? '—'}</span>
</TableCell>
<TableCell className="text-xs">{fmtTs(a.timestamp)}</TableCell>
<TableCell className="text-xs">{a.resolved ? 'Resolved' : 'Open'}</TableCell>
<TableCell />
</TableRow>
))}
{expanded && alerts.length === 0 && (
<TableRow className="bg-amber-500/5">
<TableCell colSpan={7} className="pl-8 text-xs text-muted-foreground italic">No RMM alerts found within ±{corrWindow}min for this company</TableCell>
</TableRow>
)}
</>);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* RMM-only alerts */}
{rmmOnly.length > 0 && (
<Card>
<CardHeader className="pb-3 cursor-pointer select-none" onClick={() => setRmmOnlyCollapsed(v => !v)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Server className="w-4 h-4 text-blue-500" /> RMM-Only Alerts <Badge variant="outline">{rmmOnly.length}</Badge>
</CardTitle>
{rmmOnlyCollapsed ? <ChevronRight className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</div>
<CardDescription>RMM ping/offline alerts with no matching Zabbix event potential Zabbix coverage gap</CardDescription>
</CardHeader>
{!rmmOnlyCollapsed && <CardContent className="p-0">
<div className="max-h-[400px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead>Company</TableHead>
<TableHead>Site</TableHead>
<TableHead>Device</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
<TableHead>Time</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rmmOnly.map(a => (
<TableRow key={a.alert_uid}>
<TableCell className="text-sm font-medium">{a.autotask_company_name ?? '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">{a.site_name}</TableCell>
<TableCell className="text-sm">{a.device_name ?? '—'}</TableCell>
<TableCell><Badge variant="outline" className="text-xs">{a.alert_class}</Badge></TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[220px] truncate" title={a.alert_message ?? ''}>{a.alert_message ?? '—'}</TableCell>
<TableCell className="text-sm">{fmtTs(a.timestamp)}</TableCell>
<TableCell><Badge variant={a.resolved ? 'outline' : 'destructive'} className="text-xs">{a.resolved ? 'Resolved' : 'Open'}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>}
</Card>
)}
{!corrLoading && !corrSummary && !corrError && (
<div className="text-center py-16 text-muted-foreground text-sm">
Click <strong>Refresh</strong> to load correlation data.
</div>
)}
</div>
)}
{/* ── Gap Analysis tab ─────────────────────────────────────────────── */}
{activeTab === 'gaps' && (
<div className="space-y-6">
{/* Sync controls */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2"><Layers className="w-4 h-4" /> Data Sources</CardTitle>
<CardDescription>Sync Zabbix hosts and IT Glue WAN circuits into local cache, then run gap analysis</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={loadGapAnalysis} disabled={gapLoading} className="gap-2">
{gapLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />} Refresh
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{/* Zabbix */}
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm flex items-center gap-2"><Globe className="w-4 h-4" /> Zabbix Hosts</p>
<p className="text-xs text-muted-foreground mt-0.5">Last synced: {fmtSynced(gapSummary?.zabbix_last_synced ?? null)}</p>
</div>
<Button size="sm" onClick={syncZabbixHosts} disabled={!!syncing} className="gap-2">
{syncing === 'zabbix' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Sync
</Button>
</div>
{gapSummary && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Total hosts</p><p className="font-semibold text-base">{gapSummary.total_zabbix_hosts}</p></div>
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Enabled</p><p className="font-semibold text-base">{gapSummary.zabbix_enabled}</p></div>
</div>
)}
</div>
{/* IT Glue */}
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm flex items-center gap-2"><BookOpen className="w-4 h-4" /> IT Glue WAN Circuits</p>
<p className="text-xs text-muted-foreground mt-0.5">Last synced: {fmtSynced(gapSummary?.itg_last_synced ?? null)}</p>
</div>
<Button size="sm" onClick={syncItgCircuits} disabled={!!syncing} className="gap-2">
{syncing === 'itg' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Sync
</Button>
</div>
{gapSummary && (
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Total circuits</p><p className="font-semibold text-base">{gapSummary.total_itg_circuits}</p></div>
<div className="bg-green-500/10 rounded p-2"><p className="text-muted-foreground">Monitored</p><p className="font-semibold text-base text-green-600">{gapSummary.itg_circuits_monitored}</p></div>
<div className="bg-amber-500/10 rounded p-2"><p className="text-muted-foreground">Gaps</p><p className="font-semibold text-base text-amber-600">{gapSummary.itg_circuits_gap}</p></div>
</div>
)}
</div>
</div>
{gapSummary && (
<div className="mt-4 grid grid-cols-3 gap-4 text-sm border-t pt-4">
<div className="text-center">
<p className="text-2xl font-bold">{gapSummary.total_rmm_sites}</p>
<p className="text-xs text-muted-foreground">RMM sites mapped</p>
</div>
<div className="text-center">
<p className={`text-2xl font-bold ${Number(gapSummary.rmm_sites_no_zabbix) > 0 ? 'text-amber-500' : 'text-green-600'}`}>
{gapSummary.rmm_sites_no_zabbix}
</p>
<p className="text-xs text-muted-foreground">RMM sites without Zabbix host</p>
</div>
<div className="text-center">
<p className={`text-2xl font-bold ${Number(gapSummary.itg_circuits_gap) > 0 ? 'text-amber-500' : 'text-green-600'}`}>
{gapSummary.itg_circuits_gap}
</p>
<p className="text-xs text-muted-foreground">IT Glue circuits unmonitored</p>
</div>
</div>
)}
</CardContent>
</Card>
{gapError && (
<div className="flex items-center gap-2 text-sm text-destructive border border-destructive/30 bg-destructive/5 rounded-md p-3">
<AlertTriangle className="w-4 h-4" /> {gapError}
</div>
)}
{/* Current Zabbix problems + RMM correlation */}
{problems.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Activity className="w-4 h-4 text-red-500" /> Active / Recent Problems <Badge variant="destructive">{problems.length}</Badge></CardTitle>
<CardDescription>Zabbix WAN problems in the last 24h correlated with RMM alerts and monitor tickets</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Host / Client</TableHead>
<TableHead>WAN IP</TableHead>
<TableHead>ISP</TableHead>
<TableHead>Problem</TableHead>
<TableHead className="text-center">RMM Alerts 24h</TableHead>
<TableHead className="text-center">Monitor Tickets 24h</TableHead>
<TableHead>Latest RMM Network Alert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{problems.map(p => (
<TableRow key={p.hostid} className="bg-red-500/5">
<TableCell>
<p className="font-medium text-sm">{p.display_name}</p>
<p className="text-xs text-muted-foreground">{p.autotask_company_name}</p>
</TableCell>
<TableCell className="font-mono text-sm">{p.wan_ip}</TableCell>
<TableCell className="text-sm">{p.isp_name ?? '—'}</TableCell>
<TableCell className="text-sm max-w-[200px] truncate" title={p.last_problem_name}>{p.last_problem_name ?? '—'}</TableCell>
<TableCell className="text-center">
<Badge variant={Number(p.open_rmm_alerts_24h) > 0 ? 'destructive' : 'outline'}>{p.open_rmm_alerts_24h}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={Number(p.monitor_tickets_24h) > 0 ? 'secondary' : 'outline'}>{p.monitor_tickets_24h}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[220px] truncate" title={p.latest_rmm_network_alert ?? ''}>
{p.latest_rmm_network_alert ?? <span className="italic">None</span>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Multi-circuit companies */}
{multiCircuit.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><GitFork className="w-4 h-4" /> Multi-Circuit Companies</CardTitle>
<CardDescription>Companies with more than one WAN circuit in IT Glue check each is covered in Zabbix</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead className="text-center">Circuits</TableHead>
<TableHead className="text-center">Monitored</TableHead>
<TableHead className="text-center">Gaps</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{multiCircuit.map(mc => {
const key = mc.org_name;
const expanded = expandedMulti.has(key);
return (<>
<TableRow
key={key}
className={`cursor-pointer hover:bg-muted/30 ${Number(mc.gap_circuits) > 0 ? 'bg-amber-500/5' : ''}`}
onClick={() => setExpandedMulti(prev => { const n = new Set(prev); expanded ? n.delete(key) : n.add(key); return n; })}
>
<TableCell className="font-medium text-sm">{mc.org_name}</TableCell>
<TableCell className="text-center">{mc.total_circuits}</TableCell>
<TableCell className="text-center"><Badge variant={Number(mc.monitored_circuits) > 0 ? 'default' : 'outline'} className="bg-green-600">{mc.monitored_circuits}</Badge></TableCell>
<TableCell className="text-center">{Number(mc.gap_circuits) > 0 ? <Badge variant="destructive">{mc.gap_circuits}</Badge> : <Badge variant="outline">0</Badge>}</TableCell>
<TableCell>{expanded ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}</TableCell>
</TableRow>
{expanded && mc.circuits.map(c => (
<TableRow key={c.id} className="bg-muted/20">
<TableCell colSpan={2} className="pl-8 text-sm">
<span className="font-medium">{c.provider}</span>
{c.location_name && <span className="text-muted-foreground"> {c.location_name}</span>}
{c.is_decommissioned && <Badge variant="outline" className="ml-2 text-xs">Decommissioned</Badge>}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{c.link_type}</TableCell>
<TableCell className="font-mono text-xs">{c.static_ips?.join(', ') || '—'}</TableCell>
<TableCell colSpan={2}>
{c.zabbix_hostid
? <Badge variant="default" className="bg-green-600 gap-1"><CheckCircle2 className="w-3 h-3" /> Monitored</Badge>
: c.is_decommissioned
? <Badge variant="outline" className="gap-1"><MinusCircle className="w-3 h-3" /> Decommissioned</Badge>
: <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" /> No Zabbix Host</Badge>
}
</TableCell>
</TableRow>
))}
</>);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* IT Glue gaps */}
{itgGaps.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><BookOpen className="w-4 h-4 text-amber-500" /> IT Glue Circuits Without Zabbix Monitoring <Badge variant="outline">{itgGaps.length}</Badge></CardTitle>
<CardDescription>Active circuits with documented static IPs in IT Glue that have no matching Zabbix host</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Organization</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Type</TableHead>
<TableHead>Static IPs</TableHead>
<TableHead>Location</TableHead>
<TableHead>Speed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itgGaps.map(g => (
<TableRow key={g.itg_asset_id}>
<TableCell className="font-medium text-sm">{g.org_name}</TableCell>
<TableCell className="text-sm">{g.provider ?? '—'}</TableCell>
<TableCell className="text-sm">{g.link_type ?? '—'}</TableCell>
<TableCell className="font-mono text-xs">{g.static_ips?.join(', ') || '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">{[g.location_name, g.location_city].filter(Boolean).join(', ') || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{g.download_mbps ? `${g.download_mbps}` : ''}{g.upload_mbps ? `${g.upload_mbps} Mbps` : ''}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* RMM sites without Zabbix */}
{rmmGaps.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Server className="w-4 h-4 text-amber-500" /> RMM Sites Without Zabbix Host <Badge variant="outline">{rmmGaps.length}</Badge></CardTitle>
<CardDescription>Online RMM sites that have no corresponding Zabbix WAN monitor run WAN Sync to add them</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Site</TableHead>
<TableHead>Company</TableHead>
<TableHead className="text-center">Devices</TableHead>
<TableHead className="text-center">Online</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rmmGaps.map(g => (
<TableRow key={g.rmm_site_uid}>
<TableCell className="font-medium text-sm">{g.rmm_site_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{g.company_name}</TableCell>
<TableCell className="text-center tabular-nums">{g.number_of_devices}</TableCell>
<TableCell className="text-center tabular-nums text-green-600">{g.number_of_online_devices}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{!gapLoading && gapSummary && rmmGaps.length === 0 && itgGaps.length === 0 && problems.length === 0 && (
<div className="text-center py-16 text-muted-foreground text-sm">
<CheckCircle2 className="w-8 h-8 mx-auto mb-3 text-green-500" />
All monitored sites and IT Glue circuits are covered in Zabbix.
</div>
)}
{!gapLoading && !gapSummary && !gapError && (
<div className="text-center py-16 text-muted-foreground text-sm">
Sync Zabbix hosts and IT Glue circuits above to run gap analysis.
</div>
)}
</div>
)}
</div>
);
}

View 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(/&nbsp;/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 });
}
}

View file

@ -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

View file

@ -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
View 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 });
}
}

View 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
View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View file

@ -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 =>

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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
View 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 (&ldquo;Agreement&rdquo;) governs your use of the Pulse
internal operations platform (&ldquo;Application&rdquo;) developed and operated by{' '}
<strong>Wulf Consulting, Inc.</strong> (&ldquo;Wulf Consulting&rdquo;).
</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&apos;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&apos;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 &ldquo;as is&rdquo; 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>
);
}

View 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>
(&ldquo;Wulf Consulting&rdquo;, &ldquo;we&rdquo;, &ldquo;us&rdquo;). 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>
);
}