/** * HistoryPage — forensic exploration of all historical fail2ban ban records. * * Shows a paginated, filterable table of every ban ever recorded in the * fail2ban database. Clicking an IP address opens a per-IP timeline view. * Rows with repeatedly-banned IPs are highlighted in amber. */ import { useCallback, useState } from "react"; import { Badge, Button, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Input, MessageBar, MessageBarBody, Select, Spinner, Table, TableBody, TableCell, TableCellLayout, TableColumnDefinition, TableHeader, TableHeaderCell, TableRow, Text, Toolbar, ToolbarButton, createTableColumn, makeStyles, tokens, } from "@fluentui/react-components"; import { useCardStyles } from "../theme/commonStyles"; import { ArrowCounterclockwiseRegular, ArrowLeftRegular, ChevronLeftRegular, ChevronRightRegular, } from "@fluentui/react-icons"; import { useHistory, useIpHistory } from "../hooks/useHistory"; import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Ban counts at or above this threshold are highlighted. */ const HIGH_BAN_THRESHOLD = 5; const PAGE_SIZE = 50; const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [ { label: "Last 24 hours", value: "24h" }, { label: "Last 7 days", value: "7d" }, { label: "Last 30 days", value: "30d" }, { label: "Last 365 days", value: "365d" }, ]; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const useStyles = makeStyles({ root: { display: "flex", flexDirection: "column", gap: tokens.spacingVerticalL, padding: tokens.spacingVerticalXXL, paddingLeft: tokens.spacingHorizontalXXL, paddingRight: tokens.spacingHorizontalXXL, }, header: { display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: tokens.spacingHorizontalM, }, filterRow: { display: "flex", alignItems: "flex-end", gap: tokens.spacingHorizontalM, flexWrap: "wrap", }, filterLabel: { display: "flex", flexDirection: "column", gap: tokens.spacingVerticalXS, }, tableWrapper: { overflow: "auto", borderRadius: tokens.borderRadiusMedium, border: `1px solid ${tokens.colorNeutralStroke1}`, }, ipCell: { fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem", color: tokens.colorBrandForeground1, cursor: "pointer", textDecoration: "underline", }, highBanRow: { backgroundColor: tokens.colorPaletteYellowBackground1, }, pagination: { display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, justifyContent: "flex-end", }, detailGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: tokens.spacingVerticalM, padding: tokens.spacingVerticalM, marginBottom: tokens.spacingVerticalM, }, detailField: { display: "flex", flexDirection: "column", gap: tokens.spacingVerticalXS, }, detailLabel: { color: tokens.colorNeutralForeground3, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em", }, detailValue: { fontSize: "0.9rem", fontWeight: "600", }, monoText: { fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem", }, }); // --------------------------------------------------------------------------- // Column definitions for the main history table // --------------------------------------------------------------------------- const HISTORY_COLUMNS = ( onClickIp: (ip: string) => void, styles: ReturnType, ): TableColumnDefinition[] => [ createTableColumn({ columnId: "banned_at", renderHeaderCell: () => "Banned At", renderCell: (item) => ( {new Date(item.banned_at).toLocaleString()} ), }), createTableColumn({ columnId: "ip", renderHeaderCell: () => "IP Address", renderCell: (item) => ( { onClickIp(item.ip); }} onKeyDown={(e): void => { if (e.key === "Enter" || e.key === " ") onClickIp(item.ip); }} > {item.ip} ), }), createTableColumn({ columnId: "jail", renderHeaderCell: () => "Jail", renderCell: (item) => {item.jail}, }), createTableColumn({ columnId: "country", renderHeaderCell: () => "Country", renderCell: (item) => ( {item.country_name ?? item.country_code ?? "—"} ), }), createTableColumn({ columnId: "failures", renderHeaderCell: () => "Failures", renderCell: (item) => {String(item.failures)}, }), createTableColumn({ columnId: "ban_count", renderHeaderCell: () => "Times Banned", renderCell: (item) => ( = HIGH_BAN_THRESHOLD ? "danger" : "subtle"} size="medium" > {String(item.ban_count)} ), }), ] as ReturnType>[]; // --------------------------------------------------------------------------- // IpDetailView — per-IP detail view // --------------------------------------------------------------------------- interface IpDetailViewProps { ip: string; onBack: () => void; } function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element { const styles = useStyles(); const cardStyles = useCardStyles(); const { detail, loading, error, refresh } = useIpHistory(ip); if (loading) { return (
); } if (error) { return ( {error} ); } if (!detail) { return ( No history found for {ip}. ); } return (
{/* Back button + heading */}
{ip}
{/* Summary grid */}
Total Bans {String(detail.total_bans)}
Total Failures {String(detail.total_failures)}
Last Banned {detail.last_ban_at ? new Date(detail.last_ban_at).toLocaleString() : "—"}
Country {detail.country_name ?? detail.country_code ?? "—"}
ASN {detail.asn ?? "—"}
Organisation {detail.org ?? "—"}
{/* Timeline table */} Ban Timeline ({String(detail.timeline.length)} events)
Banned At Jail Failures Times Banned Matched Lines {detail.timeline.map((event) => ( {new Date(event.banned_at).toLocaleString()} {event.jail} {String(event.failures)} {String(event.ban_count)} {event.matches.length === 0 ? ( ) : ( {event.matches.join("\n")} )} ))}
); } // --------------------------------------------------------------------------- // HistoryPage — main component // --------------------------------------------------------------------------- export function HistoryPage(): React.JSX.Element { const styles = useStyles(); // Filter state const [range, setRange] = useState(undefined); const [jailFilter, setJailFilter] = useState(""); const [ipFilter, setIpFilter] = useState(""); const [appliedQuery, setAppliedQuery] = useState({ page_size: PAGE_SIZE, }); // Per-IP detail navigation const [selectedIp, setSelectedIp] = useState(null); const { items, total, page, loading, error, setPage, refresh } = useHistory(appliedQuery); const applyFilters = useCallback((): void => { setAppliedQuery({ range: range, jail: jailFilter.trim() || undefined, ip: ipFilter.trim() || undefined, page_size: PAGE_SIZE, }); }, [range, jailFilter, ipFilter]); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); /** History table columns with IP click handler. */ const columns = HISTORY_COLUMNS( (ip: string): void => { setSelectedIp(ip); }, styles, ); // If an IP is selected, show the detail view. if (selectedIp !== null) { return (
{ setSelectedIp(null); }} />
); } return (
{/* ---------------------------------------------------------------- */} {/* Header */} {/* ---------------------------------------------------------------- */}
History } onClick={(): void => { refresh(); }} disabled={loading} title="Refresh" />
{/* ---------------------------------------------------------------- */} {/* Filter bar */} {/* ---------------------------------------------------------------- */}
Time range
Jail { setJailFilter(data.value); }} size="small" />
IP Address { setIpFilter(data.value); }} size="small" onKeyDown={(e): void => { if (e.key === "Enter") applyFilters(); }} />
{/* ---------------------------------------------------------------- */} {/* Error / loading state */} {/* ---------------------------------------------------------------- */} {error && ( {error} )} {loading && !error && (
)} {/* ---------------------------------------------------------------- */} {/* Summary */} {/* ---------------------------------------------------------------- */} {!loading && !error && ( {String(total)} record{total !== 1 ? "s" : ""} found · Page {String(page)} of {String(totalPages)} · Rows highlighted in yellow have {String(HIGH_BAN_THRESHOLD)}+ repeat bans )} {/* ---------------------------------------------------------------- */} {/* DataGrid table */} {/* ---------------------------------------------------------------- */} {!loading && !error && (
`${item.ip}-${item.banned_at}`} focusMode="composite" > {({ renderHeaderCell }) => ( {renderHeaderCell()} )} > {({ item }) => ( key={`${item.ip}-${item.banned_at}`} className={ item.ban_count >= HIGH_BAN_THRESHOLD ? styles.highBanRow : undefined } > {({ renderCell }) => ( {renderCell(item)} )} )}
)} {/* ---------------------------------------------------------------- */} {/* Pagination */} {/* ---------------------------------------------------------------- */} {!loading && !error && totalPages > 1 && (
)}
); }