Files
BanGUI/frontend/src/pages/HistoryPage.tsx
2026-05-04 13:13:01 +02:00

383 lines
12 KiB
TypeScript

/**
* 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<typeof useStyles>,
): TableColumnDefinition<HistoryBanItem>[] =>
[
createTableColumn<HistoryBanItem>({
columnId: "banned_at",
renderHeaderCell: () => "Banned At",
renderCell: (item) => (
<Text size={200}>{new Date(item.banned_at).toLocaleString()}</Text>
),
}),
createTableColumn<HistoryBanItem>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: (item) => (
<span
className={styles.ipCell}
role="button"
tabIndex={0}
onClick={(): void => {
onClickIp(item.ip);
}}
onKeyDown={(e): void => {
if (e.key === "Enter" || e.key === " ") onClickIp(item.ip);
}}
>
{item.ip}
</span>
),
}),
createTableColumn<HistoryBanItem>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
}),
createTableColumn<HistoryBanItem>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: (item) => (
<Text size={200}>{item.country_name ?? item.country_code ?? "—"}</Text>
),
}),
createTableColumn<HistoryBanItem>({
columnId: "failures",
renderHeaderCell: () => "Failures",
renderCell: (item) => <Text size={200}>{String(item.failures)}</Text>,
}),
createTableColumn<HistoryBanItem>({
columnId: "ban_count",
renderHeaderCell: () => "Times Banned",
renderCell: (item) => (
<Badge
appearance="filled"
color={item.ban_count >= HIGH_BAN_THRESHOLD ? "danger" : "subtle"}
size="medium"
>
{String(item.ban_count)}
</Badge>
),
}),
] as ReturnType<typeof createTableColumn<HistoryBanItem>>[];
// ---------------------------------------------------------------------------
// HistoryPage — main component
// ---------------------------------------------------------------------------
export function HistoryPage(): React.JSX.Element {
const styles = useStyles();
// Filter state
const [range, setRange] = useState<TimeRange>("7d");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState("");
const [page, setPage] = useState(1);
// Per-IP detail navigation
const [selectedIp, setSelectedIp] = useState<string | null>(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 (
<div className={styles.root}>
<SectionErrorBoundary sectionName="IP Detail View">
<IpDetailView
ip={selectedIp}
onBack={(): void => {
setSelectedIp(null);
}}
/>
</SectionErrorBoundary>
</div>
);
}
return (
<div className={styles.root} data-testid="history-page">
{/* ---------------------------------------------------------------- */}
{/* Header */}
{/* ---------------------------------------------------------------- */}
<div className={styles.header}>
<Text as="h1" size={700} weight="semibold">
History
</Text>
<Toolbar size="small">
<ToolbarButton
icon={<ArrowCounterclockwiseRegular />}
onClick={(): void => {
refresh();
}}
disabled={loading}
title="Refresh"
/>
</Toolbar>
</div>
{/* ---------------------------------------------------------------- */}
{/* Filter bar */}
{/* ---------------------------------------------------------------- */}
<div className={styles.filterRow}>
<DashboardFilterBar
timeRange={range}
onTimeRangeChange={(value) => {
setRange(value);
}}
originFilter={originFilter}
onOriginFilterChange={(value) => {
setOriginFilter(value);
}}
jail={jailFilter}
onJailChange={(value) => {
setJailFilter(value);
}}
ip={ipFilter}
onIpChange={(value) => {
setIpFilter(value);
}}
/>
</div>
{/* ---------------------------------------------------------------- */}
{/* Summary */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && (
<Text size={300} className={styles.summaryText}>
{String(total)} record{total !== 1 ? "s" : ""} found ·
Page {String(currentPage)} of {String(totalPages)} ·
Rows highlighted in yellow have {String(HIGH_BAN_THRESHOLD)}+ repeat bans
</Text>
)}
{/* ---------------------------------------------------------------- */}
{/* DataGrid table */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && (
<SectionErrorBoundary sectionName="History Table">
<div className={styles.tableWrapper} data-testid="history-table">
<DataGrid
items={items}
columns={columns}
getRowId={(item: HistoryBanItem) => `${item.ip}-${item.banned_at}`}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<HistoryBanItem>>
{({ item }) => (
<DataGridRow<HistoryBanItem>
key={`${item.ip}-${item.banned_at}`}
className={
item.ban_count >= HIGH_BAN_THRESHOLD
? styles.highBanRow
: undefined
}
>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
</SectionErrorBoundary>
)}
{/* ---------------------------------------------------------------- */}
{/* Pagination */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && totalPages > 1 && (
<div className={styles.pagination}>
<Button
icon={<ChevronLeftRegular />}
appearance="subtle"
size="small"
disabled={currentPage <= 1}
onClick={(): void => {
setCurrentPage(currentPage - 1);
}}
aria-label="Previous page"
/>
<Text size={200}>
Page {String(currentPage)} / {String(totalPages)}
</Text>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
size="small"
disabled={currentPage >= totalPages}
onClick={(): void => {
setCurrentPage(currentPage + 1);
}}
aria-label="Next page"
/>
</div>
)}
</div>
);
}