/** * 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. * * The history table is wrapped with SectionErrorBoundary for resilience. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Badge, Button, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Text, Toolbar, ToolbarButton, createTableColumn, makeStyles, tokens, } from "@fluentui/react-components"; import type { TableColumnDefinition } from "@fluentui/react-components"; import { ArrowCounterclockwiseRegular, ChevronLeftRegular, ChevronRightRegular, } from "@fluentui/react-icons"; import { DashboardFilterBar } from "../components/DashboardFilterBar"; import { SectionErrorBoundary } from "../components/SectionErrorBoundary"; import { useHistory } from "../hooks/useHistory"; import { IpDetailView } from "./history/IpDetailView"; import { HISTORY_PAGE_SIZE } from "../utils/constants"; import type { HistoryBanItem, TimeRange } from "../types/history"; import type { BanOriginFilter } from "../types/ban"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Ban counts at or above this threshold are highlighted. */ const HIGH_BAN_THRESHOLD = 5; // --------------------------------------------------------------------------- // 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", }, 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", }, summaryText: { color: tokens.colorNeutralForeground3, }, }); // --------------------------------------------------------------------------- // 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>[]; // --------------------------------------------------------------------------- // HistoryPage — main component // --------------------------------------------------------------------------- export function HistoryPage(): React.JSX.Element { const styles = useStyles(); // Filter state const [range, setRange] = useState("7d"); const [originFilter, setOriginFilter] = useState("all"); const [jailFilter, setJailFilter] = useState(""); const [ipFilter, setIpFilter] = useState(""); const [page, setPage] = useState(1); // Per-IP detail navigation const [selectedIp, setSelectedIp] = useState(null); const { items, total, page: currentPage, loading, error, setPage: setCurrentPage, refresh } = useHistory( page, HISTORY_PAGE_SIZE, range, originFilter !== "all" ? originFilter : undefined, jailFilter.trim() || undefined, ipFilter.trim() || undefined, "archive", ); const handleIpClick = useCallback((ip: string): void => { setSelectedIp(ip); }, []); const columns = useMemo( () => HISTORY_COLUMNS(handleIpClick, styles), [handleIpClick, styles], ); // Reset to page 1 when filters change useEffect((): void => { setPage(1); }, [range, originFilter, jailFilter, ipFilter]); const totalPages = Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE)); // 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 */} {/* ---------------------------------------------------------------- */}
{ setRange(value); }} originFilter={originFilter} onOriginFilterChange={(value) => { setOriginFilter(value); }} jail={jailFilter} onJailChange={(value) => { setJailFilter(value); }} ip={ipFilter} onIpChange={(value) => { setIpFilter(value); }} />
{/* ---------------------------------------------------------------- */} {/* Summary */} {/* ---------------------------------------------------------------- */} {!loading && !error && ( {String(total)} record{total !== 1 ? "s" : ""} found · Page {String(currentPage)} 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 && (
)}
); }