'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 */}
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}
))}
))}
)}
>
)}
);
}