diff --git a/app/(dashboard)/waf-events/WafEventsClient.tsx b/app/(dashboard)/waf-events/WafEventsClient.tsx index 5348a3df..93e04086 100644 --- a/app/(dashboard)/waf-events/WafEventsClient.tsx +++ b/app/(dashboard)/waf-events/WafEventsClient.tsx @@ -2,8 +2,19 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { Chip, Stack, TextField, Tooltip, Typography } from "@mui/material"; +import { + Box, + Chip, + Divider, + Drawer, + IconButton, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; +import CloseIcon from "@mui/icons-material/Close"; import { DataTable } from "@/src/components/ui/DataTable"; import type { WafEvent } from "@/src/lib/models/waf-events"; @@ -29,11 +40,107 @@ function SeverityChip({ severity }: { severity: string | null }) { return ; } +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +function WafEventDrawer({ event, onClose }: { event: WafEvent | null; onClose: () => void }) { + // Parse rawData safely — render as text only, never as HTML + let parsedRaw: unknown = null; + if (event?.rawData) { + try { parsedRaw = JSON.parse(event.rawData); } catch { parsedRaw = event.rawData; } + } + + return ( + + {event && ( + + {/* Header */} + + + + WAF Event + + + + + + + {/* Fields */} + + {new Date(event.ts * 1000).toLocaleString()} + + + + {event.host || "—"} + + + + + {event.clientIp} + {event.countryCode && } + + + + + + {event.method} {event.uri} + + + + + {event.ruleId ?? "—"} + + + + {event.ruleMessage ?? "—"} + + + + + {/* Raw audit log — rendered as plain text, never as HTML */} + + {parsedRaw !== null ? ( + + {JSON.stringify(parsedRaw, null, 2)} + + ) : ( + + )} + + + )} + + ); +} + export default function WafEventsClient({ events, pagination, initialSearch }: Props) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const [searchTerm, setSearchTerm] = useState(initialSearch); + const [selected, setSelected] = useState(null); useEffect(() => { setSearchTerm(initialSearch); }, [initialSearch]); const debounceRef = useRef | null>(null); @@ -175,7 +282,10 @@ export default function WafEventsClient({ events, pagination, initialSearch }: P keyField="id" emptyMessage="No WAF events found. Enable the WAF in Settings and send some traffic to see events here." pagination={pagination} + onRowClick={setSelected} /> + + setSelected(null)} /> ); } diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx index 7f5dcd81..ab60d48d 100644 --- a/src/components/ui/DataTable.tsx +++ b/src/components/ui/DataTable.tsx @@ -29,6 +29,7 @@ type DataTableProps = { keyField: keyof T; emptyMessage?: string; loading?: boolean; + onRowClick?: (row: T) => void; pagination?: { total: number; page: number; @@ -69,6 +70,7 @@ export function DataTable({ keyField, emptyMessage = "No data available", loading = false, + onRowClick, pagination, }: DataTableProps) { return ( @@ -97,7 +99,11 @@ export function DataTable({ ) : ( data.map((row) => ( - + onRowClick(row) : undefined} + sx={onRowClick ? { cursor: "pointer", "&:hover": { bgcolor: "action.hover" } } : undefined} + > {columns.map((col) => ( {col.render ? col.render(row) : (row as Record)[col.id] as ReactNode} diff --git a/src/lib/models/waf-events.ts b/src/lib/models/waf-events.ts index 61d457e8..fd34793f 100644 --- a/src/lib/models/waf-events.ts +++ b/src/lib/models/waf-events.ts @@ -13,6 +13,7 @@ export type WafEvent = { ruleId: number | null; ruleMessage: string | null; severity: string | null; + rawData: string | null; }; function buildSearch(search?: string) { @@ -53,5 +54,6 @@ export async function listWafEvents(limit = 50, offset = 0, search?: string): Pr ruleId: r.ruleId ?? null, ruleMessage: r.ruleMessage ?? null, severity: r.severity ?? null, + rawData: r.rawData ?? null, })); }