615 lines
19 KiB
TypeScript
615 lines
19 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, 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<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 cardStyles = useCardStyles();
|
|
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 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={`${cardStyles.card} ${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>
|
|
);
|
|
}
|