'use client'; import React, { useState, useEffect, useCallback, useMemo } from 'react'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import dayjs, { type Dayjs } from 'dayjs'; import { toast } from 'sonner'; import { ChevronLeft, ChevronRight, Check, ChevronsUpDown, X } from 'lucide-react'; import type { ApexOptions } from 'apexcharts'; import { Button } from '@/components/ui/button'; import { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; // ── Dynamic imports (browser-only) ──────────────────────────────────────────── const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); const WorldMap = dynamic(() => import('./WorldMapInner'), { ssr: false, loading: () => (
), }) as React.ComponentType<{ data: import('./WorldMapInner').CountryStats[]; selectedCountry?: string | null }>; // ── Types (mirrored from analytics-db — can't import server-only code) ──────── type Interval = '1h' | '12h' | '24h' | '7d' | '30d'; type DisplayInterval = Interval | 'custom'; const INTERVAL_SECONDS_CLIENT: Record = { '1h': 3600, '12h': 43200, '24h': 86400, '7d': 7 * 86400, '30d': 30 * 86400, }; interface AnalyticsSummary { totalRequests: number; uniqueIps: number; blockedRequests: number; blockedPercent: number; bytesServed: number; loggingDisabled: boolean; } interface TimelineBucket { ts: number; total: number; blocked: number; } interface CountryStats { countryCode: string; total: number; blocked: number; } interface ProtoStats { proto: string; count: number; percent: number; } interface UAStats { userAgent: string; count: number; percent: number; } interface BlockedEvent { id: number; ts: number; clientIp: string; countryCode: string | null; method: string; uri: string; status: number; host: string; } interface BlockedPage { events: BlockedEvent[]; total: number; page: number; pages: number; } interface TopWafRule { ruleId: number; count: number; message: string | null; hosts: { host: string; count: number }[]; } interface WafStats { total: number; topRules: TopWafRule[]; byCountry: { countryCode: string; count: number }[]; } // ── Helpers ─────────────────────────────────────────────────────────────────── function countryFlag(code: string): string { if (!code || code.length !== 2) return '🌐'; return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); } function parseUA(ua: string): string { if (!ua) return 'Unknown'; if (/Googlebot/i.test(ua)) return 'Googlebot'; if (/bingbot/i.test(ua)) return 'Bingbot'; if (/DuckDuckBot/i.test(ua)) return 'DuckDuckBot'; if (/curl/i.test(ua)) return 'curl'; if (/python-requests|Python\//i.test(ua)) return 'Python'; if (/Go-http-client/i.test(ua)) return 'Go'; if (/wget/i.test(ua)) return 'wget'; if (/Edg\//i.test(ua)) return 'Edge'; if (/OPR\//i.test(ua)) return 'Opera'; if (/SamsungBrowser/i.test(ua)) return 'Samsung Browser'; if (/Chrome\//i.test(ua)) return 'Chrome'; if (/Firefox\//i.test(ua)) return 'Firefox'; if (/Safari\//i.test(ua)) return 'Safari'; return ua.substring(0, 32); } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; } function formatTs(ts: number, rangeSeconds: number): string { const d = new Date(ts * 1000); if (rangeSeconds <= 86400) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (rangeSeconds <= 7 * 86400) return d.toLocaleDateString([], { weekday: 'short' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } const DARK_CHART: ApexOptions = { chart: { background: 'transparent', toolbar: { show: false }, animations: { enabled: false } }, theme: { mode: 'dark' }, grid: { borderColor: 'rgba(255,255,255,0.06)' }, tooltip: { theme: 'dark' }, }; // ── Local DateTimePicker ─────────────────────────────────────────────────────── function DateTimePicker({ value, onChange, placeholder, }: { value: Dayjs | null; onChange: (v: Dayjs | null) => void; placeholder?: string; }) { const [open, setOpen] = useState(false); const [timeStr, setTimeStr] = useState(value ? value.format('HH:mm') : '00:00'); // Keep timeStr in sync when value changes externally useEffect(() => { if (value) setTimeStr(value.format('HH:mm')); }, [value]); const selectedDate = value ? value.toDate() : undefined; function handleDaySelect(day: Date | undefined) { if (!day) return; const [hh, mm] = timeStr.split(':').map(Number); const next = dayjs(day).hour(hh || 0).minute(mm || 0).second(0); onChange(next); } function handleTimeChange(e: React.ChangeEvent) { setTimeStr(e.target.value); if (value) { const [hh, mm] = e.target.value.split(':').map(Number); onChange(value.hour(hh || 0).minute(mm || 0).second(0)); } } const label = value ? value.format('DD/MM/YYYY HH:mm') : (placeholder ?? 'Pick date & time'); return (
Time:
); } // ── Stat card ───────────────────────────────────────────────────────────────── function StatCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) { return (

{label}

{value}

{sub &&

{sub}

}
); } // ── Hosts multi-select combobox ─────────────────────────────────────────────── function HostsCombobox({ allHosts, selectedHosts, onChange, }: { allHosts: string[]; selectedHosts: string[]; onChange: (v: string[]) => void; }) { const [open, setOpen] = useState(false); function toggle(host: string) { if (selectedHosts.includes(host)) { onChange(selectedHosts.filter(h => h !== host)); } else { onChange([...selectedHosts, host]); } } const label = selectedHosts.length === 0 ? 'All hosts' : selectedHosts.length <= 2 ? selectedHosts.join(', ') : `${selectedHosts.length} hosts`; return (
·
No hosts found. {allHosts.map(host => ( toggle(host)} className="text-xs"> {host} ))}
{selectedHosts.length > 0 && (
{selectedHosts.length <= 2 ? selectedHosts.map(h => ( {h} )) : ( {selectedHosts.length} hosts ) }
)}
); } // ── Main component ──────────────────────────────────────────────────────────── export default function AnalyticsClient() { const [interval, setIntervalVal] = useState('1h'); const [selectedHosts, setSelectedHosts] = useState([]); const [allHosts, setAllHosts] = useState([]); // Custom range as Dayjs objects const [customFrom, setCustomFrom] = useState(null); const [customTo, setCustomTo] = useState(null); const [summary, setSummary] = useState(null); const [timeline, setTimeline] = useState([]); const [countries, setCountries] = useState([]); const [protocols, setProtocols] = useState([]); const [userAgents, setUserAgents] = useState([]); const [blocked, setBlocked] = useState(null); const [wafStats, setWafStats] = useState(null); const [loading, setLoading] = useState(true); const [selectedCountry, setSelectedCountry] = useState(null); /** How many seconds the current selection spans — used for chart axis labels */ const rangeSeconds = useMemo(() => { if (interval === 'custom' && customFrom && customTo) { const diff = customTo.unix() - customFrom.unix(); return diff > 0 ? diff : 3600; } return INTERVAL_SECONDS_CLIENT[interval as Interval] ?? 3600; }, [interval, customFrom, customTo]); /** Build the query string for all analytics endpoints */ const buildParams = useCallback((extra = '') => { const h = selectedHosts.length > 0 ? `hosts=${selectedHosts.map(encodeURIComponent).join(',')}` : ''; const sep = h ? `&${h}` : ''; if (interval === 'custom' && customFrom && customTo) { return `?from=${customFrom.unix()}&to=${customTo.unix()}${sep}${extra}`; } return `?interval=${interval}${sep}${extra}`; }, [interval, selectedHosts, customFrom, customTo]); // Fetch all configured+active hosts once useEffect(() => { fetch('/api/analytics/hosts').then(r => r.json()).then(setAllHosts).catch(() => {}); }, []); // Fetch all analytics data when range/host selection changes useEffect(() => { if (interval === 'custom') { if (!customFrom || !customTo || customFrom.unix() >= customTo.unix()) return; } setLoading(true); const params = buildParams(); Promise.all([ fetch(`/api/analytics/summary${params}`).then(r => r.json()), fetch(`/api/analytics/timeline${params}`).then(r => r.json()), fetch(`/api/analytics/countries${params}`).then(r => r.json()), fetch(`/api/analytics/protocols${params}`).then(r => r.json()), fetch(`/api/analytics/user-agents${params}`).then(r => r.json()), fetch(`/api/analytics/blocked${params}&page=1`).then(r => r.json()), fetch(`/api/analytics/waf-stats${params}`).then(r => r.json()), ]).then(([s, t, c, p, u, b, w]) => { setSummary(s); setTimeline(t); setCountries(c); setProtocols(p); setUserAgents(u); setBlocked(b); setWafStats(w); }).catch(() => { toast.error('Failed to load analytics data'); }).finally(() => setLoading(false)); }, [buildParams, interval, customFrom, customTo]); const fetchBlockedPage = useCallback((page: number) => { fetch(`/api/analytics/blocked${buildParams(`&page=${page}`)}`).then(r => r.json()).then(setBlocked).catch(() => {}); }, [buildParams]); // ── Chart configs ───────────────────────────────────────────────────────── const timelineLabels = timeline.map(b => formatTs(b.ts, rangeSeconds)); const timelineOptions: ApexOptions = { ...DARK_CHART, chart: { ...DARK_CHART.chart, type: 'area', stacked: true, id: 'timeline' }, colors: ['#3b82f6', '#ef4444'], fill: { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.45, opacityTo: 0.05 } }, stroke: { curve: 'smooth', width: 2 }, dataLabels: { enabled: false }, xaxis: { categories: timelineLabels, labels: { rotate: 0, style: { colors: '#94a3b8', fontSize: '11px' } }, axisBorder: { show: false }, axisTicks: { show: false } }, yaxis: { labels: { style: { colors: '#94a3b8' } } }, legend: { labels: { colors: '#94a3b8' } }, tooltip: { theme: 'dark', shared: true, intersect: false }, }; const timelineSeries = [ { name: 'Allowed', data: timeline.map(b => b.total - b.blocked) }, { name: 'Blocked', data: timeline.map(b => b.blocked) }, ]; const donutOptions: ApexOptions = { ...DARK_CHART, chart: { ...DARK_CHART.chart, type: 'donut', id: 'protocols' }, colors: ['#3b82f6', '#8b5cf6', '#06b6d4', '#f59e0b'], labels: protocols.map(p => p.proto), legend: { position: 'bottom', labels: { colors: '#94a3b8' } }, dataLabels: { style: { colors: ['#fff'] } }, plotOptions: { pie: { donut: { size: '65%' } } }, }; const donutSeries = protocols.map(p => p.count); const uaNames = userAgents.map(u => parseUA(u.userAgent)); const barOptions: ApexOptions = { ...DARK_CHART, chart: { ...DARK_CHART.chart, type: 'bar', id: 'ua' }, colors: ['#7f5bff'], plotOptions: { bar: { horizontal: true, borderRadius: 4 } }, dataLabels: { enabled: false }, xaxis: { categories: uaNames, labels: { style: { colors: '#94a3b8', fontSize: '12px' } } }, yaxis: { labels: { style: { colors: '#94a3b8', fontSize: '12px' } } }, }; const barSeries = [{ name: 'Requests', data: userAgents.map(u => u.count) }]; const wafRuleLabels = (wafStats?.topRules ?? []).map(r => `#${r.ruleId}`); const wafBarOptions: ApexOptions = { ...DARK_CHART, chart: { ...DARK_CHART.chart, type: 'bar', id: 'waf-rules' }, colors: ['#f59e0b'], plotOptions: { bar: { horizontal: true, borderRadius: 4 } }, dataLabels: { enabled: false }, xaxis: { categories: wafRuleLabels, labels: { style: { colors: '#94a3b8', fontSize: '12px' } } }, yaxis: { labels: { style: { colors: '#94a3b8', fontSize: '12px' } } }, }; const wafBarSeries = [{ name: 'Hits', data: (wafStats?.topRules ?? []).map(r => r.count) }]; const wafByCountry = new Map((wafStats?.byCountry ?? []).map(r => [r.countryCode, r.count])); const INTERVALS: DisplayInterval[] = ['1h', '12h', '24h', '7d', '30d', 'custom']; // ── Render ──────────────────────────────────────────────────────────────── return (
{/* Header */}

Traffic Intelligence

Analytics

{/* Interval toggle group */}
{INTERVALS.map(iv => ( ))}
{interval === 'custom' && (
)}
{/* Logging disabled alert */} {summary?.loggingDisabled && (
Caddy access logging is not enabled — no traffic data is being collected.{' '} Enable logging in Settings.
)} {/* Loading overlay */} {loading && (
)} {!loading && summary && ( <> {/* Stats row */}
0 ? `${wafStats!.total.toLocaleString()} from WAF` : undefined} color={summary.blockedRequests > 0 ? '#ef4444' : undefined} /> 10 ? '#f59e0b' : undefined} /> 0 ? `${wafStats.topRules.length} rules triggered` : 'No WAF events'} color={(wafStats?.total ?? 0) > 0 ? '#f59e0b' : undefined} />
{/* Timeline */}

Requests Over Time

{timeline.length === 0 ? (
No data for this period
) : (
)}
{/* World map + Countries */}

Traffic by Country

Top Countries

{countries.length === 0 ? (
No geo data available
) : ( Country Requests WAF Blocked {countries.slice(0, 10).map(c => { const wafCount = wafByCountry.get(c.countryCode) ?? 0; return ( setSelectedCountry(s => s === c.countryCode ? null : c.countryCode)} className={cn( 'cursor-pointer', selectedCountry === c.countryCode ? 'bg-sky-300/[0.08]' : 'hover:bg-sky-300/[0.05]', )} >
{countryFlag(c.countryCode)} {c.countryCode}
{c.total.toLocaleString()} 0 ? 'text-yellow-400' : 'text-muted-foreground')}> {wafCount > 0 ? wafCount.toLocaleString() : '—'} 0 ? 'text-red-400' : 'text-muted-foreground')}> {c.blocked.toLocaleString()}
); })}
)}
{/* Protocols + User Agents */}

HTTP Protocols

{protocols.length === 0 ? (
No data
) : ( <>
{protocols.map(p => ( {p.proto} {p.count.toLocaleString()} {p.percent}% ))}
)}

Top User Agents

{userAgents.length === 0 ? (
No data
) : (
)}
{/* Recent Blocked Requests */}

Recent Blocked Requests

{!blocked || blocked.events.length === 0 ? (
No blocked requests in this period
) : ( <>
{['Time', 'IP', 'Country', 'Host', 'Method', 'URI', 'Status'].map(h => ( {h} ))} {blocked.events.map(ev => ( {new Date(ev.ts * 1000).toLocaleString()} {ev.clientIp} {ev.countryCode ? `${countryFlag(ev.countryCode)} ${ev.countryCode}` : '—'} {ev.host || '—'} {ev.method} {ev.uri} {ev.status} ))}
{blocked.pages > 1 && (
Page {blocked.page} of {blocked.pages}
)} )}
{/* WAF Top Rules */} {wafStats && wafStats.total > 0 && (

Top WAF Rules Triggered

{['Rule', 'Description', 'Hits', 'Triggered by'].map(h => ( {h} ))} {wafStats.topRules.map(rule => ( #{rule.ruleId} {rule.message ? (

{rule.message}

{rule.message}
) : ( )}
{rule.count.toLocaleString()}
{rule.hosts.map(h => ( {h.host} ×{h.count} ))}
))}
)} )}
); }