Stage 9: ban history — backend service, router, frontend history page

- history.py models: HistoryBanItem, HistoryListResponse, IpTimelineEvent, IpDetailResponse
- history_service.py: list_history() with dynamic WHERE clauses (range/jail/ip
  prefix/all-time), get_ip_detail() with timeline aggregation
- history.py router: GET /api/history + GET /api/history/{ip} (404 for unknown)
- Fixed latent bug in ban_service._parse_data_json: json.loads('null') -> None
  -> AttributeError; now checks isinstance(parsed, dict) before assigning obj
- 317 tests pass (27 new), ruff + mypy clean (46 files)
- types/history.ts, api/history.ts, hooks/useHistory.ts created
- HistoryPage.tsx: filter bar (time range/jail/IP), DataGrid table,
  high-ban-count row highlighting, per-IP IpDetailView with timeline,
  pagination
- Frontend tsc + ESLint clean (0 errors/warnings)
- Tasks.md Stage 9 marked done
This commit is contained in:
2026-03-01 15:09:22 +01:00
parent 54313fd3e0
commit b8f3a1c562
12 changed files with 2050 additions and 50 deletions

View File

@@ -1,23 +1,615 @@
/**
* Ban history placeholder page — full implementation in Stage 9.
* 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 { Text, makeStyles, tokens } from "@fluentui/react-components";
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 {
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: { padding: tokens.spacingVerticalXXL },
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,
background: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
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",
},
});
export function HistoryPage(): React.JSX.Element {
// ---------------------------------------------------------------------------
// 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>>[];
// ---------------------------------------------------------------------------
// IpDetailView — per-IP detail view
// ---------------------------------------------------------------------------
interface IpDetailViewProps {
ip: string;
onBack: () => void;
}
function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element {
const styles = useStyles();
const { detail, loading, error, refresh } = useIpHistory(ip);
if (loading) {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}
>
<Spinner label={`Loading history for ${ip}`} />
</div>
);
}
if (error) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (!detail) {
return (
<MessageBar intent="warning">
<MessageBarBody>No history found for {ip}.</MessageBarBody>
</MessageBar>
);
}
return (
<div className={styles.root}>
<Text as="h1" size={700} weight="semibold">
History
</Text>
<Text as="p" size={300}>
Historical ban query view will be implemented in Stage 9.
<div style={{ display: "flex", flexDirection: "column", gap: tokens.spacingVerticalL }}>
{/* Back button + heading */}
<div className={styles.header}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
<Button
icon={<ArrowLeftRegular />}
appearance="subtle"
onClick={onBack}
>
Back to list
</Button>
<Text as="h2" size={600} weight="semibold" className={styles.monoText}>
{ip}
</Text>
</div>
<Button
icon={<ArrowCounterclockwiseRegular />}
appearance="subtle"
onClick={(): void => {
refresh();
}}
>
Refresh
</Button>
</div>
{/* Summary grid */}
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Total Bans</span>
<span className={styles.detailValue}>{String(detail.total_bans)}</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Total Failures</span>
<span className={styles.detailValue}>{String(detail.total_failures)}</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Last Banned</span>
<span className={styles.detailValue}>
{detail.last_ban_at
? new Date(detail.last_ban_at).toLocaleString()
: "—"}
</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Country</span>
<span className={styles.detailValue}>
{detail.country_name ?? detail.country_code ?? "—"}
</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>ASN</span>
<span className={styles.detailValue}>{detail.asn ?? "—"}</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Organisation</span>
<span className={styles.detailValue}>{detail.org ?? "—"}</span>
</div>
</div>
{/* Timeline table */}
<Text weight="semibold" size={400}>
Ban Timeline ({String(detail.timeline.length)} events)
</Text>
<div className={styles.tableWrapper}>
<Table size="small" aria-label="Ban timeline">
<TableHeader>
<TableRow>
<TableHeaderCell>Banned At</TableHeaderCell>
<TableHeaderCell>Jail</TableHeaderCell>
<TableHeaderCell>Failures</TableHeaderCell>
<TableHeaderCell>Times Banned</TableHeaderCell>
<TableHeaderCell>Matched Lines</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{detail.timeline.map((event) => (
<TableRow key={`${event.jail}-${event.banned_at}`}>
<TableCell>
<TableCellLayout>
{new Date(event.banned_at).toLocaleString()}
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{event.jail}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{String(event.failures)}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{String(event.ban_count)}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
{event.matches.length === 0 ? (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
</Text>
) : (
<Text
size={100}
style={{
fontFamily: "Consolas, 'Courier New', monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{event.matches.join("\n")}
</Text>
)}
</TableCellLayout>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// HistoryPage — main component
// ---------------------------------------------------------------------------
export function HistoryPage(): React.JSX.Element {
const styles = useStyles();
// Filter state
const [range, setRange] = useState<TimeRange | undefined>(undefined);
const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState("");
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
page_size: PAGE_SIZE,
});
// Per-IP detail navigation
const [selectedIp, setSelectedIp] = useState<string | null>(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 (
<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}>
<div className={styles.filterLabel}>
<Text size={200}>Time range</Text>
<Select
aria-label="Time range"
value={range ?? ""}
onChange={(_ev, data): void => {
setRange(data.value === "" ? undefined : (data.value as TimeRange));
}}
size="small"
>
<option value="">All time</option>
{TIME_RANGE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
</div>
<div className={styles.filterLabel}>
<Text size={200}>Jail</Text>
<Input
placeholder="e.g. sshd"
value={jailFilter}
onChange={(_ev, data): void => {
setJailFilter(data.value);
}}
size="small"
/>
</div>
<div className={styles.filterLabel}>
<Text size={200}>IP Address</Text>
<Input
placeholder="e.g. 192.168"
value={ipFilter}
onChange={(_ev, data): void => {
setIpFilter(data.value);
}}
size="small"
onKeyDown={(e): void => {
if (e.key === "Enter") applyFilters();
}}
/>
</div>
<Button appearance="primary" size="small" onClick={applyFilters}>
Apply
</Button>
<Button
appearance="subtle"
size="small"
onClick={(): void => {
setRange(undefined);
setJailFilter("");
setIpFilter("");
setAppliedQuery({ page_size: PAGE_SIZE });
}}
>
Clear
</Button>
</div>
{/* ---------------------------------------------------------------- */}
{/* Error / loading state */}
{/* ---------------------------------------------------------------- */}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{loading && !error && (
<div
style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}
>
<Spinner label="Loading history…" />
</div>
)}
{/* ---------------------------------------------------------------- */}
{/* Summary */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && (
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
{String(total)} record{total !== 1 ? "s" : ""} found ·
Page {String(page)} 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={page <= 1}
onClick={(): void => {
setPage(page - 1);
}}
/>
<Text size={200}>
Page {String(page)} / {String(totalPages)}
</Text>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
size="small"
disabled={page >= totalPages}
onClick={(): void => {
setPage(page + 1);
}}
/>
</div>
)}
</div>
);
}