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