feat: add analytics dashboard with traffic monitoring

- Parse Caddy access logs every 30s into traffic_events SQLite table
- GeoIP country lookup via maxmind (GeoLite2-Country.mmdb)
- 90-day retention with automatic purge
- Analytics page with interval (24h/7d/30d) and per-host filtering:
  - Stats cards: total requests, unique IPs, blocked count, block rate
  - Requests-over-time area chart (ApexCharts)
  - SVG world choropleth map (d3-geo + topojson-client, React 19 compatible)
  - Top countries table with flag emojis
  - HTTP protocol donut chart
  - Top user agents horizontal bar chart
  - Recent blocked requests table with pagination
- Traffic (24h) summary card on Overview page linking to analytics
- 7 authenticated API routes under /api/analytics/
- Share caddy-logs volume with web container (read-only)
- group_add caddy GID to web container for log file read access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-02-26 20:43:23 +01:00
parent 674e06e3c9
commit 8be69d2774
23 changed files with 2584 additions and 16 deletions

View File

@@ -17,13 +17,20 @@ type RecentEvent = {
created_at: string;
};
type TrafficSummary = {
totalRequests: number;
blockedPercent: number;
} | null;
export default function OverviewClient({
userName,
stats,
trafficSummary,
recentEvents
}: {
userName: string;
stats: StatCard[];
trafficSummary: TrafficSummary;
recentEvents: RecentEvent[];
}) {
return (
@@ -90,6 +97,51 @@ export default function OverviewClient({
</Card>
</Grid>
))}
{/* Traffic (24h) card */}
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card elevation={0} sx={{ height: "100%", border: "1px solid rgba(148, 163, 184, 0.14)" }}>
<CardActionArea
component={Link}
href="/analytics"
sx={{
height: "100%",
p: 0,
"&:hover": {
background: "linear-gradient(135deg, rgba(127, 91, 255, 0.16), rgba(34, 211, 238, 0.08))"
}
}}
>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box sx={{ color: "rgba(127, 91, 255, 0.8)", display: "flex", alignItems: "center" }}>
<svg xmlns="http://www.w3.org/2000/svg" width="35" height="35" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 9.2h3V19H5zM10.6 5h2.8v14h-2.8zm5.6 8H19v6h-2.8z"/>
</svg>
</Box>
{trafficSummary ? (
<>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: "-0.03em" }}>
{trafficSummary.totalRequests.toLocaleString()}
</Typography>
<Typography color="text.secondary" sx={{ fontWeight: 500 }}>
Traffic (24h)
{trafficSummary.totalRequests > 0 && (
<Box component="span" sx={{ ml: 1, color: trafficSummary.blockedPercent > 0 ? "error.light" : "text.secondary", fontSize: "0.8em" }}>
· {trafficSummary.blockedPercent}% blocked
</Box>
)}
</Typography>
</>
) : (
<>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: "-0.03em" }}></Typography>
<Typography color="text.secondary" sx={{ fontWeight: 500 }}>Traffic (24h)</Typography>
</>
)}
</CardContent>
</CardActionArea>
</Card>
</Grid>
</Grid>
<Stack spacing={2}>