diff --git a/app/(dashboard)/analytics/AnalyticsClient.tsx b/app/(dashboard)/analytics/AnalyticsClient.tsx index 57611efa..6d2e6149 100644 --- a/app/(dashboard)/analytics/AnalyticsClient.tsx +++ b/app/(dashboard)/analytics/AnalyticsClient.tsx @@ -27,6 +27,7 @@ import { TextField, ToggleButton, ToggleButtonGroup, + Tooltip, Typography, } from '@mui/material'; import { LocalizationProvider, DateTimePicker } from '@mui/x-date-pickers'; @@ -77,8 +78,8 @@ interface BlockedEvent { } interface BlockedPage { events: BlockedEvent[]; total: number; page: number; pages: number; } -interface TopWafRule { ruleId: number; count: number; message: string | null; } -interface WafStats { total: number; topRules: TopWafRule[]; } +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 ─────────────────────────────────────────────────────────────────── @@ -264,6 +265,20 @@ export default function AnalyticsClient() { }; 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 ( @@ -511,6 +526,7 @@ export default function AnalyticsClient() { Country Requests + WAF Blocked @@ -535,6 +551,13 @@ export default function AnalyticsClient() { {c.total.toLocaleString()} + + {(() => { const wafCount = wafByCountry.get(c.countryCode) ?? 0; return ( + 0 ? 'warning.light' : 'text.disabled'}> + {wafCount > 0 ? wafCount.toLocaleString() : '—'} + + ); })()} + 0 ? 'error.light' : 'text.secondary'}> {c.blocked.toLocaleString()} @@ -665,10 +688,11 @@ export default function AnalyticsClient() { Top WAF Rules Triggered - + +
- {['Rule ID', 'Message', 'Hits'].map(h => ( + {['Rule', 'Description', 'Hits', 'Triggered by'].map(h => ( {h} ))} @@ -677,14 +701,27 @@ export default function AnalyticsClient() { {wafStats.topRules.map(rule => ( - {rule.ruleId} + #{rule.ruleId} - - {rule.message ?? '—'} + + {rule.message ? ( + + {rule.message} + + ) : ( + + )} {rule.count.toLocaleString()} + + + {rule.hosts.map(h => ( + + ))} + + ))} diff --git a/app/api/analytics/waf-stats/route.ts b/app/api/analytics/waf-stats/route.ts index 8669a744..93de18a1 100644 --- a/app/api/analytics/waf-stats/route.ts +++ b/app/api/analytics/waf-stats/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireUser } from '@/src/lib/auth'; import { INTERVAL_SECONDS } from '@/src/lib/analytics-db'; -import { countWafEventsInRange, getTopWafRules } from '@/src/lib/models/waf-events'; +import { countWafEventsInRange, getTopWafRulesWithHosts, getWafEventCountries } from '@/src/lib/models/waf-events'; function resolveRange(params: URLSearchParams): { from: number; to: number } { const fromParam = params.get('from'); @@ -18,9 +18,10 @@ function resolveRange(params: URLSearchParams): { from: number; to: number } { export async function GET(req: NextRequest) { await requireUser(); const { from, to } = resolveRange(req.nextUrl.searchParams); - const [total, topRules] = await Promise.all([ + const [total, topRules, byCountry] = await Promise.all([ countWafEventsInRange(from, to), - getTopWafRules(from, to, 10), + getTopWafRulesWithHosts(from, to, 10), + getWafEventCountries(from, to), ]); - return NextResponse.json({ total, topRules }); + return NextResponse.json({ total, topRules, byCountry }); } diff --git a/src/lib/models/waf-events.ts b/src/lib/models/waf-events.ts index 04df07de..9b8a4664 100644 --- a/src/lib/models/waf-events.ts +++ b/src/lib/models/waf-events.ts @@ -61,6 +61,43 @@ export async function getTopWafRules(from: number, to: number, limit = 10): Prom .map((r) => ({ ruleId: r.ruleId, count: r.count, message: r.message ?? null })); } +export type TopWafRuleWithHosts = { + ruleId: number; + count: number; + message: string | null; + hosts: { host: string; count: number }[]; +}; + +export async function getTopWafRulesWithHosts(from: number, to: number, limit = 10): Promise { + const topRules = await getTopWafRules(from, to, limit); + if (topRules.length === 0) return []; + + const ruleIds = topRules.map(r => r.ruleId); + const hostRows = await db + .select({ ruleId: wafEvents.ruleId, host: wafEvents.host, count: count() }) + .from(wafEvents) + .where(and(gte(wafEvents.ts, from), lte(wafEvents.ts, to), inArray(wafEvents.ruleId, ruleIds))) + .groupBy(wafEvents.ruleId, wafEvents.host) + .orderBy(desc(count())); + + return topRules.map(rule => ({ + ...rule, + hosts: hostRows + .filter(r => r.ruleId === rule.ruleId) + .map(r => ({ host: r.host, count: r.count })), + })); +} + +export async function getWafEventCountries(from: number, to: number): Promise<{ countryCode: string; count: number }[]> { + const rows = await db + .select({ countryCode: wafEvents.countryCode, count: count() }) + .from(wafEvents) + .where(and(gte(wafEvents.ts, from), lte(wafEvents.ts, to))) + .groupBy(wafEvents.countryCode) + .orderBy(desc(count())); + return rows.map(r => ({ countryCode: r.countryCode ?? 'XX', count: r.count })); +} + export async function getWafRuleMessages(ruleIds: number[]): Promise> { if (ruleIds.length === 0) return {}; const rows = await db