diff --git a/app/(dashboard)/analytics/AnalyticsClient.tsx b/app/(dashboard)/analytics/AnalyticsClient.tsx index ebd535dd..d75fb523 100644 --- a/app/(dashboard)/analytics/AnalyticsClient.tsx +++ b/app/(dashboard)/analytics/AnalyticsClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import { @@ -39,7 +39,7 @@ const WorldMap = dynamic(() => import('./WorldMapInner'), { ), -}); +}) as React.ComponentType<{ data: import('./WorldMapInner').CountryStats[]; selectedCountry?: string | null }>; // ── Types (mirrored from analytics-db — can't import server-only code) ──────── @@ -145,6 +145,7 @@ export default function AnalyticsClient() { const [userAgents, setUserAgents] = useState([]); const [blocked, setBlocked] = useState(null); const [loading, setLoading] = useState(true); + const [selectedCountry, setSelectedCountry] = useState(null); // Fetch hosts once useEffect(() => { @@ -330,7 +331,7 @@ export default function AnalyticsClient() { Traffic by Country - + @@ -354,7 +355,16 @@ export default function AnalyticsClient() { {countries.slice(0, 10).map(c => ( - + 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)' }, + }} + > {countryFlag(c.countryCode)} diff --git a/app/(dashboard)/analytics/WorldMapInner.tsx b/app/(dashboard)/analytics/WorldMapInner.tsx index 7d8a2069..6ccf2e49 100644 --- a/app/(dashboard)/analytics/WorldMapInner.tsx +++ b/app/(dashboard)/analytics/WorldMapInner.tsx @@ -168,7 +168,7 @@ interface HoverInfo { blocked: number; } -export default function WorldMapInner({ data }: { data: CountryStats[] }) { +export default function WorldMapInner({ data, selectedCountry }: { data: CountryStats[]; selectedCountry?: string | null }) { const [baseGeojson, setBaseGeojson] = useState(null); const [hoverInfo, setHoverInfo] = useState(null); @@ -213,12 +213,10 @@ export default function WorldMapInner({ data }: { data: CountryStats[] }) { }); }, []); - const highlightFilter = useMemo( - () => hoverInfo?.alpha2 - ? ['==', ['get', 'alpha2'], hoverInfo.alpha2] - : ['boolean', false], - [hoverInfo?.alpha2], - ); + const highlightFilter = useMemo(() => { + const target = hoverInfo?.alpha2 ?? selectedCountry ?? null; + return target ? ['==', ['get', 'alpha2'], target] : ['boolean', false]; + }, [hoverInfo?.alpha2, selectedCountry]); if (!geojson) { return ( diff --git a/src/lib/log-parser.ts b/src/lib/log-parser.ts index 58538f38..81de29f9 100644 --- a/src/lib/log-parser.ts +++ b/src/lib/log-parser.ts @@ -62,6 +62,12 @@ function lookupCountry(ip: string): string | null { interface CaddyLogEntry { ts?: number; msg?: string; + plugin?: string; + // fields on "request blocked" entries (top-level) + client_ip?: string; + method?: string; + uri?: string; + // fields on "handled request" entries status?: number; size?: number; request?: { @@ -75,7 +81,23 @@ interface CaddyLogEntry { }; } -function parseLine(line: string): typeof trafficEvents.$inferInsert | null { +// Build a set of signatures from caddy-blocker's "request blocked" entries so we +// can mark the corresponding "handled request" rows correctly instead of using +// status === 403 (which would also catch legitimate upstream 403s). +function collectBlockedSignatures(lines: string[]): Set { + const blocked = new Set(); + for (const line of lines) { + let entry: CaddyLogEntry; + try { entry = JSON.parse(line.trim()); } catch { continue; } + if (entry.msg !== 'request blocked' || entry.plugin !== 'caddy-blocker') continue; + const ts = Math.floor(entry.ts ?? 0); + const key = `${ts}|${entry.client_ip ?? ''}|${entry.method ?? ''}|${entry.uri ?? ''}`; + blocked.add(key); + } + return blocked; +} + +function parseLine(line: string, blocked: Set): typeof trafficEvents.$inferInsert | null { let entry: CaddyLogEntry; try { entry = JSON.parse(line); @@ -88,41 +110,45 @@ function parseLine(line: string): typeof trafficEvents.$inferInsert | null { const req = entry.request ?? {}; const clientIp = req.client_ip || req.remote_ip || ''; + const ts = Math.floor(entry.ts ?? Date.now() / 1000); + const method = req.method ?? ''; + const uri = req.uri ?? ''; const status = entry.status ?? 0; + const key = `${ts}|${clientIp}|${method}|${uri}`; + return { - ts: Math.floor(entry.ts ?? Date.now() / 1000), + ts, clientIp, countryCode: clientIp ? lookupCountry(clientIp) : null, host: req.host ?? '', - method: req.method ?? '', - uri: req.uri ?? '', + method, + uri, status, proto: req.proto ?? '', bytesSent: entry.size ?? 0, userAgent: req.headers?.['User-Agent']?.[0] ?? '', - isBlocked: status === 403, + isBlocked: blocked.has(key), }; } -async function readLines(startOffset: number): Promise<{ rows: typeof trafficEvents.$inferInsert[]; newOffset: number }> { +async function readLines(startOffset: number): Promise<{ lines: string[]; newOffset: number }> { return new Promise((resolve, reject) => { - const rows: typeof trafficEvents.$inferInsert[] = []; + const lines: string[] = []; let bytesRead = 0; const stream = createReadStream(LOG_FILE, { start: startOffset, encoding: 'utf8' }); stream.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'ENOENT') resolve({ rows: [], newOffset: startOffset }); + if (err.code === 'ENOENT') resolve({ lines: [], newOffset: startOffset }); else reject(err); }); const rl = createInterface({ input: stream, crlfDelay: Infinity }); rl.on('line', (line) => { bytesRead += Buffer.byteLength(line, 'utf8') + 1; // +1 for newline - const row = parseLine(line.trim()); - if (row) rows.push(row); + if (line.trim()) lines.push(line.trim()); }); - rl.on('close', () => resolve({ rows, newOffset: startOffset + bytesRead })); + rl.on('close', () => resolve({ lines, newOffset: startOffset + bytesRead })); rl.on('error', reject); }); } @@ -165,11 +191,13 @@ export async function parseNewLogEntries(): Promise { // Detect log rotation: file shrank const startOffset = currentSize < storedSize ? 0 : storedOffset; - const { rows, newOffset } = await readLines(startOffset); + const { lines, newOffset } = await readLines(startOffset); - if (rows.length > 0) { + if (lines.length > 0) { + const blocked = collectBlockedSignatures(lines); + const rows = lines.map(l => parseLine(l, blocked)).filter(r => r !== null); insertBatch(rows); - console.log(`[log-parser] inserted ${rows.length} traffic events`); + console.log(`[log-parser] inserted ${rows.length} traffic events (${blocked.size} blocked)`); } setState('access_log_offset', String(newOffset));