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));