Moved all static layout properties (display, gap, margin, padding, colour) from inline style props to makeStyles classes in: - MapBansTable.tsx: Pagination row flexbox layout - JailDetailPage.tsx: Link styling for textDecoration - HistoryPage.tsx: Summary text styling - IpDetailView.tsx: Loading container and text formatting Kept inline styles only for genuinely dynamic values: - WorldMap.tsx: Tooltip position (follows mouse) - TopCountriesPieChart.tsx: Legend color (from recharts data) - TopCountriesBarChart.tsx: Chart height (derives from data length) This change improves performance by leveraging Griffel's atomic CSS cache and ensures consistency with the established Fluent UI pattern. Updated Docs/Web-Development.md with explicit rule: inline styles only for runtime-dynamic values, all static properties go in makeStyles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
374 lines
11 KiB
TypeScript
374 lines
11 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.
|
|
*/
|
|
|
|
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 { 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}>
|
|
<IpDetailView
|
|
ip={selectedIp}
|
|
onBack={(): void => {
|
|
setSelectedIp(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.root}>
|
|
{/* ---------------------------------------------------------------- */}
|
|
{/* 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 && (
|
|
<div className={styles.tableWrapper}>
|
|
<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>
|
|
)}
|
|
|
|
{/* ---------------------------------------------------------------- */}
|
|
{/* Pagination */}
|
|
{/* ---------------------------------------------------------------- */}
|
|
{!loading && !error && totalPages > 1 && (
|
|
<div className={styles.pagination}>
|
|
<Button
|
|
icon={<ChevronLeftRegular />}
|
|
appearance="subtle"
|
|
size="small"
|
|
disabled={currentPage <= 1}
|
|
onClick={(): void => {
|
|
setCurrentPage(currentPage - 1);
|
|
}}
|
|
/>
|
|
<Text size={200}>
|
|
Page {String(currentPage)} / {String(totalPages)}
|
|
</Text>
|
|
<Button
|
|
icon={<ChevronRightRegular />}
|
|
appearance="subtle"
|
|
size="small"
|
|
disabled={currentPage >= totalPages}
|
|
onClick={(): void => {
|
|
setCurrentPage(currentPage + 1);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|