feat: analytics WAF improvements — bar chart, host chips, country column

- Add getTopWafRulesWithHosts() and getWafEventCountries() model queries
- WAF stats API now returns topRules with per-host breakdown and byCountry
- Analytics: replace WAF rules table with bar chart + host chip details
- Analytics: add WAF column (amber) to Top Countries table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-04 22:42:10 +01:00
parent 7ceeb84fc2
commit c20ba54b4c
3 changed files with 86 additions and 11 deletions

View File

@@ -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() {
<TableRow>
<TableCell sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>Country</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>Requests</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>WAF</TableCell>
<TableCell align="right" sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)' }}>Blocked</TableCell>
</TableRow>
</TableHead>
@@ -535,6 +551,13 @@ export default function AnalyticsClient() {
<TableCell align="right">
<Typography variant="body2">{c.total.toLocaleString()}</Typography>
</TableCell>
<TableCell align="right">
{(() => { const wafCount = wafByCountry.get(c.countryCode) ?? 0; return (
<Typography variant="body2" color={wafCount > 0 ? 'warning.light' : 'text.disabled'}>
{wafCount > 0 ? wafCount.toLocaleString() : '—'}
</Typography>
); })()}
</TableCell>
<TableCell align="right">
<Typography variant="body2" color={c.blocked > 0 ? 'error.light' : 'text.secondary'}>
{c.blocked.toLocaleString()}
@@ -665,10 +688,11 @@ export default function AnalyticsClient() {
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Top WAF Rules Triggered
</Typography>
<Table size="small">
<ReactApexChart type="bar" series={wafBarSeries} options={wafBarOptions} height={Math.max(120, wafStats.topRules.length * 32)} />
<Table size="small" sx={{ mt: 2 }}>
<TableHead>
<TableRow>
{['Rule ID', 'Message', 'Hits'].map(h => (
{['Rule', 'Description', 'Hits', 'Triggered by'].map(h => (
<TableCell key={h} sx={{ color: 'text.secondary', borderColor: 'rgba(255,255,255,0.06)', whiteSpace: 'nowrap' }}>{h}</TableCell>
))}
</TableRow>
@@ -677,14 +701,27 @@ export default function AnalyticsClient() {
{wafStats.topRules.map(rule => (
<TableRow key={rule.ruleId} sx={{ '& td': { borderColor: 'rgba(255,255,255,0.04)' } }}>
<TableCell>
<Typography variant="body2" fontFamily="monospace" color="warning.light">{rule.ruleId}</Typography>
<Typography variant="body2" fontFamily="monospace" color="warning.light">#{rule.ruleId}</Typography>
</TableCell>
<TableCell sx={{ maxWidth: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2" color="text.secondary" title={rule.message ?? undefined}>{rule.message ?? '—'}</Typography>
<TableCell sx={{ maxWidth: 320 }}>
{rule.message ? (
<Tooltip title={rule.message} placement="top">
<Typography variant="body2" color="text.secondary" noWrap sx={{ maxWidth: 300 }}>{rule.message}</Typography>
</Tooltip>
) : (
<Typography variant="body2" color="text.disabled"></Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={600}>{rule.count.toLocaleString()}</Typography>
</TableCell>
<TableCell>
<Stack direction="row" flexWrap="wrap" gap={0.5}>
{rule.hosts.map(h => (
<Chip key={h.host} label={`${h.host} ×${h.count}`} size="small" />
))}
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>

View File

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

View File

@@ -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<TopWafRuleWithHosts[]> {
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<Record<number, string | null>> {
if (ruleIds.length === 0) return {};
const rows = await db