Files
caddy-proxy-manager/app/(dashboard)/analytics/AnalyticsClient.tsx
fuomag9 b5625e5a96 feat: migrate from npm to bun and fix analytics map height
Switch package manager and runtime from Node.js/npm to Bun across
Docker, CI, and scripts. The SQLite driver remains better-sqlite3
due to Next.js Turbopack being unable to resolve bun:sqlite during
build-time page pre-rendering.

Also fix the world map not rendering in the analytics page — the
overflowX wrapper added for mobile broke the flex height chain,
collapsing the map to 0px.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 01:48:21 +01:00

746 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import {
Alert,
Autocomplete,
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
CircularProgress,
Divider,
Grid,
ListItemText,
Pagination,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography,
} from '@mui/material';
import { LocalizationProvider, DateTimePicker } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs, { type Dayjs } from 'dayjs';
import type { ApexOptions } from 'apexcharts';
// ── Dynamic imports (browser-only) ────────────────────────────────────────────
const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false });
const WorldMap = dynamic(() => import('./WorldMapInner'), {
ssr: false,
loading: () => (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 240 }}>
<CircularProgress size={24} />
</Box>
),
}) 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<Interval, number> = {
'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' },
};
// ── Stat card ─────────────────────────────────────────────────────────────────
function StatCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) {
return (
<Card elevation={0} sx={{ height: '100%', border: '1px solid rgba(148,163,184,0.12)' }}>
<CardContent>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 1 }}>
{label}
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: '-0.03em', mt: 0.5, color: color ?? 'text.primary' }}>
{value}
</Typography>
{sub && <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{sub}</Typography>}
</CardContent>
</Card>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function AnalyticsClient() {
const [interval, setIntervalVal] = useState<DisplayInterval>('1h');
const [selectedHosts, setSelectedHosts] = useState<string[]>([]);
const [allHosts, setAllHosts] = useState<string[]>([]);
// Custom range as Dayjs objects
const [customFrom, setCustomFrom] = useState<Dayjs | null>(null);
const [customTo, setCustomTo] = useState<Dayjs | null>(null);
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [timeline, setTimeline] = useState<TimelineBucket[]>([]);
const [countries, setCountries] = useState<CountryStats[]>([]);
const [protocols, setProtocols] = useState<ProtoStats[]>([]);
const [userAgents, setUserAgents] = useState<UAStats[]>([]);
const [blocked, setBlocked] = useState<BlockedPage | null>(null);
const [wafStats, setWafStats] = useState<WafStats | null>(null);
const [loading, setLoading] = useState(true);
const [selectedCountry, setSelectedCountry] = useState<string | null>(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(() => {}).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]));
// ── Render ────────────────────────────────────────────────────────────────
return (
<Stack spacing={4} sx={{ maxWidth: '100%', overflow: 'hidden' }}>
{/* Header */}
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ sm: 'center' }} justifyContent="space-between" spacing={2}>
<Box>
<Typography variant="overline" sx={{ color: 'rgba(148,163,184,0.6)', letterSpacing: 4 }}>
Traffic Intelligence
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700, letterSpacing: '-0.02em' }}>
Analytics
</Typography>
</Box>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap" useFlexGap>
<ToggleButtonGroup
value={interval}
exclusive
size="small"
onChange={(_e, v) => {
if (!v) return;
if (v === 'custom' && !customFrom) {
setCustomFrom(dayjs().subtract(24, 'hour'));
setCustomTo(dayjs());
}
setIntervalVal(v);
}}
>
<ToggleButton value="1h">1h</ToggleButton>
<ToggleButton value="12h">12h</ToggleButton>
<ToggleButton value="24h">24h</ToggleButton>
<ToggleButton value="7d">7d</ToggleButton>
<ToggleButton value="30d">30d</ToggleButton>
<ToggleButton value="custom">Custom</ToggleButton>
</ToggleButtonGroup>
{interval === 'custom' && (
<Stack direction="row" spacing={1} alignItems="center">
<DateTimePicker
value={customFrom}
maxDateTime={customTo ?? undefined}
onChange={setCustomFrom}
slotProps={{
textField: {
size: 'small',
sx: { width: 200 },
},
}}
format="DD/MM/YYYY HH:mm"
ampm={false}
/>
<Typography variant="caption" color="text.disabled" sx={{ flexShrink: 0 }}></Typography>
<DateTimePicker
value={customTo}
minDateTime={customFrom ?? undefined}
onChange={setCustomTo}
slotProps={{
textField: {
size: 'small',
sx: { width: 200 },
},
}}
format="DD/MM/YYYY HH:mm"
ampm={false}
/>
</Stack>
)}
<Autocomplete
multiple
size="small"
options={allHosts}
value={selectedHosts}
onChange={(_e, v) => setSelectedHosts(v)}
disableCloseOnSelect
limitTags={2}
sx={{ width: { xs: '100%', sm: 260 }, flexShrink: 0 }}
ListboxProps={{
// Prevent scroll from the dropdown list leaking to the page
style: { overscrollBehavior: 'contain' },
}}
PaperComponent={({ children, ...paperProps }) => (
<Paper {...paperProps}>
{/* Select all / none — onMouseDown preventDefault keeps the popup open */}
<Box
onMouseDown={e => e.preventDefault()}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, px: 1, py: 0.5 }}
>
<Button
size="small"
variant="text"
sx={{ fontSize: 12, py: 0.25, minWidth: 0 }}
onClick={() => setSelectedHosts(allHosts)}
>
Select all
</Button>
<Typography variant="caption" color="text.disabled">·</Typography>
<Button
size="small"
variant="text"
sx={{ fontSize: 12, py: 0.25, minWidth: 0 }}
onClick={() => setSelectedHosts([])}
>
Clear
</Button>
</Box>
<Divider />
{children}
</Paper>
)}
renderOption={(props, option, { selected }) => (
<li {...props} key={option}>
<Checkbox size="small" checked={selected} sx={{ mr: 0.5, p: 0.5 }} />
<ListItemText primary={option} primaryTypographyProps={{ variant: 'body2', noWrap: true }} />
</li>
)}
renderTags={(value, getTagProps) => {
if (value.length <= 2) {
return value.map((option, index) => (
<Chip
{...getTagProps({ index })}
key={option}
label={option}
size="small"
sx={{ maxWidth: 100, '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' } }}
/>
));
}
// Collapse to a single count chip so the input never grows tall
return [
<Chip
key="count"
label={`${value.length} hosts`}
size="small"
onDelete={() => setSelectedHosts([])}
/>,
];
}}
renderInput={(params) => (
<TextField
{...params}
placeholder={selectedHosts.length === 0 ? 'All hosts' : undefined}
/>
)}
/>
</Stack>
</LocalizationProvider>
</Stack>
{/* Logging disabled alert */}
{summary?.loggingDisabled && (
<Alert severity="warning">
Caddy access logging is not enabled no traffic data is being collected.{' '}
<Link href="/settings" style={{ color: 'inherit' }}>Enable logging in Settings</Link>.
</Alert>
)}
{/* Loading overlay */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
)}
{!loading && summary && (
<>
{/* Stats row */}
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}>
<StatCard label="Total Requests" value={summary.totalRequests.toLocaleString()} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}>
<StatCard label="Unique IPs" value={summary.uniqueIps.toLocaleString()} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}>
<StatCard
label="Blocked Requests"
value={summary.blockedRequests.toLocaleString()}
sub={(wafStats?.total ?? 0) > 0 ? `${wafStats!.total.toLocaleString()} from WAF` : undefined}
color={summary.blockedRequests > 0 ? '#ef4444' : undefined}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}>
<StatCard
label="Block Rate"
value={`${summary.blockedPercent}%`}
sub={`${formatBytes(summary.bytesServed)} served`}
color={summary.blockedPercent > 10 ? '#f59e0b' : undefined}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}>
<StatCard
label="WAF Events"
value={(wafStats?.total ?? 0).toLocaleString()}
sub={wafStats && wafStats.topRules.length > 0 ? `${wafStats.topRules.length} rules triggered` : 'No WAF events'}
color={(wafStats?.total ?? 0) > 0 ? '#f59e0b' : undefined}
/>
</Grid>
</Grid>
{/* Timeline */}
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Requests Over Time
</Typography>
{timeline.length === 0 ? (
<Box sx={{ py: 6, textAlign: 'center', color: 'text.secondary' }}>No data for this period</Box>
) : (
<Box sx={{ overflowX: "auto", width: "100%" }}>
<ReactApexChart
type="area"
series={timelineSeries}
options={timelineOptions}
height={220}
/>
</Box>
)}
</CardContent>
</Card>
{/* World map + Countries */}
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 7 }}>
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)', height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardContent sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
Traffic by Country
</Typography>
<Box sx={{ flex: 1, minHeight: 280 }}>
<WorldMap data={countries} selectedCountry={selectedCountry} />
</Box>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 5 }}>
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)', height: '100%' }}>
<CardContent sx={{ p: '16px !important' }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1.5 }}>
Top Countries
</Typography>
{countries.length === 0 ? (
<Box sx={{ py: 4, textAlign: 'center', color: 'text.secondary' }}>No geo data available</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>Country</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>Requests</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>WAF</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>Blocked</TableCell>
</TableRow>
</TableHead>
<TableBody>
{countries.slice(0, 10).map(c => (
<TableRow
key={c.countryCode}
onClick={() => setSelectedCountry(s => s === c.countryCode ? null : c.countryCode)}
sx={{
cursor: 'pointer',
'& td': { borderColor: 'rgba(255,255,255,0.04)' },
bgcolor: selectedCountry === c.countryCode ? 'rgba(125,211,252,0.08)' : 'transparent',
'&:hover': { bgcolor: 'rgba(125,211,252,0.05)' },
}}
>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<span>{countryFlag(c.countryCode)}</span>
<Typography variant="body2">{c.countryCode}</Typography>
</Stack>
</TableCell>
<TableCell align="right">
<Typography variant="body2">{c.total.toLocaleString()}</Typography>
</TableCell>
<TableCell align="right">
{(() => { const wafCount = wafByCountry.get(c.countryCode) ?? 0; return (
<Typography variant="body2" color={wafCount > 0 ? 'warning.light' : 'text.disabled'}>
{wafCount > 0 ? wafCount.toLocaleString() : '—'}
</Typography>
); })()}
</TableCell>
<TableCell align="right">
<Typography variant="body2" color={c.blocked > 0 ? 'error.light' : 'text.secondary'}>
{c.blocked.toLocaleString()}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Protocols + User Agents */}
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 5 }}>
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)', height: '100%' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
HTTP Protocols
</Typography>
{protocols.length === 0 ? (
<Box sx={{ py: 6, textAlign: 'center', color: 'text.secondary' }}>No data</Box>
) : (
<>
<Box sx={{ overflowX: "auto", width: "100%" }}>
<ReactApexChart type="donut" series={donutSeries} options={donutOptions} height={220} />
</Box>
<Table size="small" sx={{ mt: 1 }}>
<TableBody>
{protocols.map(p => (
<TableRow key={p.proto} sx={{ '& td': { borderColor: 'rgba(255,255,255,0.04)' } }}>
<TableCell><Typography variant="body2">{p.proto}</Typography></TableCell>
<TableCell align="right"><Typography variant="body2">{p.count.toLocaleString()}</Typography></TableCell>
<TableCell align="right"><Typography variant="body2" color="text.secondary">{p.percent}%</Typography></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 7 }}>
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)', height: '100%' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Top User Agents
</Typography>
{userAgents.length === 0 ? (
<Box sx={{ py: 6, textAlign: 'center', color: 'text.secondary' }}>No data</Box>
) : (
<Box sx={{ overflowX: "auto", width: "100%" }}>
<ReactApexChart type="bar" series={barSeries} options={barOptions} height={260} />
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Recent Blocked Requests */}
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Recent Blocked Requests
</Typography>
{!blocked || blocked.events.length === 0 ? (
<Paper elevation={0} sx={{ py: 5, textAlign: 'center', color: 'text.secondary', bgcolor: 'rgba(12,18,30,0.5)' }}>
No blocked requests in this period
</Paper>
) : (
<>
<Table size="small">
<TableHead>
<TableRow>
{['Time', 'IP', 'Country', 'Host', 'Method', 'URI', 'Status'].map(h => (
<TableCell key={h} sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)', whiteSpace: 'nowrap' }}>{h}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{blocked.events.map(ev => (
<TableRow key={ev.id} sx={{ '& td': { borderColor: 'rgba(255,255,255,0.04)' } }}>
<TableCell sx={{ whiteSpace: 'nowrap' }}>
<Typography variant="body2" color="text.secondary">
{new Date(ev.ts * 1000).toLocaleString()}
</Typography>
</TableCell>
<TableCell><Typography variant="body2" fontFamily="monospace">{ev.clientIp}</Typography></TableCell>
<TableCell>
<Typography variant="body2">
{ev.countryCode ? `${countryFlag(ev.countryCode)} ${ev.countryCode}` : '—'}
</Typography>
</TableCell>
<TableCell sx={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2">{ev.host || '—'}</Typography>
</TableCell>
<TableCell><Typography variant="body2" fontFamily="monospace">{ev.method}</Typography></TableCell>
<TableCell sx={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2" fontFamily="monospace" title={ev.uri}>{ev.uri}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="error.light" fontFamily="monospace">{ev.status}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{blocked.pages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Pagination
count={blocked.pages}
page={blocked.page}
onChange={(_e, p) => fetchBlockedPage(p)}
color="primary"
size="small"
/>
</Box>
)}
</>
)}
</CardContent>
</Card>
{/* WAF Top Rules */}
{wafStats && wafStats.total > 0 && (
<Card elevation={0} sx={{ border: '1px solid rgba(148,163,184,0.12)' }}>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Top WAF Rules Triggered
</Typography>
<Box sx={{ overflowX: "auto", width: "100%" }}>
<ReactApexChart type="bar" series={wafBarSeries} options={wafBarOptions} height={Math.max(120, wafStats.topRules.length * 32)} />
</Box>
<Table size="small" sx={{ mt: 2 }}>
<TableHead>
<TableRow>
{['Rule', 'Description', 'Hits', 'Triggered by'].map(h => (
<TableCell key={h} sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)', whiteSpace: 'nowrap' }}>{h}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{wafStats.topRules.map(rule => (
<TableRow key={rule.ruleId} sx={{ '& td': { borderColor: 'rgba(255,255,255,0.04)' } }}>
<TableCell>
<Typography variant="body2" fontFamily="monospace" color="warning.light">#{rule.ruleId}</Typography>
</TableCell>
<TableCell sx={{ maxWidth: 320 }}>
{rule.message ? (
<Tooltip title={rule.message} placement="top">
<Typography variant="body2" color="text.secondary" noWrap sx={{ maxWidth: 300 }}>{rule.message}</Typography>
</Tooltip>
) : (
<Typography variant="body2" color="text.disabled"></Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={600}>{rule.count.toLocaleString()}</Typography>
</TableCell>
<TableCell>
<Stack direction="row" flexWrap="wrap" gap={0.5}>
{rule.hosts.map(h => (
<Chip key={h.host} label={`${h.host} ×${h.count}`} size="small" />
))}
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</>
)}
</Stack>
);
}