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:
53
frontend/src/api/history.ts
Normal file
53
frontend/src/api/history.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* API functions for the ban history endpoints.
|
||||
*/
|
||||
|
||||
import { get } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type {
|
||||
HistoryListResponse,
|
||||
HistoryQuery,
|
||||
IpDetailResponse,
|
||||
} from "../types/history";
|
||||
|
||||
/**
|
||||
* Fetch a paginated list of historical bans with optional filters.
|
||||
*/
|
||||
export async function fetchHistory(
|
||||
query: HistoryQuery = {},
|
||||
): Promise<HistoryListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.range) params.set("range", query.range);
|
||||
if (query.jail) params.set("jail", query.jail);
|
||||
if (query.ip) params.set("ip", query.ip);
|
||||
if (query.page !== undefined) params.set("page", String(query.page));
|
||||
if (query.page_size !== undefined)
|
||||
params.set("page_size", String(query.page_size));
|
||||
|
||||
const qs = params.toString();
|
||||
const url = qs
|
||||
? `${ENDPOINTS.history}?${qs}`
|
||||
: ENDPOINTS.history;
|
||||
return get<HistoryListResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full ban history for a single IP address.
|
||||
*
|
||||
* @returns null when the server returns 404 (no history for this IP).
|
||||
*/
|
||||
export async function fetchIpHistory(ip: string): Promise<IpDetailResponse | null> {
|
||||
try {
|
||||
return await get<IpDetailResponse>(ENDPOINTS.historyIp(ip));
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"status" in err &&
|
||||
(err as { status: number }).status === 404
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
111
frontend/src/hooks/useHistory.ts
Normal file
111
frontend/src/hooks/useHistory.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* `useHistory` hook — fetches and manages ban history data.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchHistory, fetchIpHistory } from "../api/history";
|
||||
import type {
|
||||
HistoryBanItem,
|
||||
HistoryQuery,
|
||||
IpDetailResponse,
|
||||
} from "../types/history";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useHistory — paginated list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseHistoryResult {
|
||||
items: HistoryBanItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setPage: (page: number) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
|
||||
const [items, setItems] = useState<HistoryBanItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(query.page ?? 1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchHistory({ ...query, page })
|
||||
.then((resp) => {
|
||||
setItems(resp.items);
|
||||
setTotal(resp.total);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [query, page]);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return { items, total, page, loading, error, setPage, refresh: load };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useIpHistory — per-IP detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseIpHistoryResult {
|
||||
detail: IpDetailResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useIpHistory(ip: string): UseIpHistoryResult {
|
||||
const [detail, setDetail] = useState<IpDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchIpHistory(ip)
|
||||
.then((resp) => {
|
||||
setDetail(resp);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [ip]);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return { detail, loading, error, refresh: load };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
59
frontend/src/types/history.ts
Normal file
59
frontend/src/types/history.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* TypeScript types for the ban history API.
|
||||
*/
|
||||
|
||||
/** Optional time-range filter for history queries. */
|
||||
export type TimeRange = "24h" | "7d" | "30d" | "365d";
|
||||
|
||||
/** A single row in the history ban-list table. */
|
||||
export interface HistoryBanItem {
|
||||
ip: string;
|
||||
jail: string;
|
||||
banned_at: string;
|
||||
ban_count: number;
|
||||
failures: number;
|
||||
matches: string[];
|
||||
country_code: string | null;
|
||||
country_name: string | null;
|
||||
asn: string | null;
|
||||
org: string | null;
|
||||
}
|
||||
|
||||
/** Paginated response from GET /api/history */
|
||||
export interface HistoryListResponse {
|
||||
items: HistoryBanItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** A single ban event in a per-IP timeline. */
|
||||
export interface IpTimelineEvent {
|
||||
jail: string;
|
||||
banned_at: string;
|
||||
ban_count: number;
|
||||
failures: number;
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
/** Full historical record for a single IP address. */
|
||||
export interface IpDetailResponse {
|
||||
ip: string;
|
||||
total_bans: number;
|
||||
total_failures: number;
|
||||
last_ban_at: string | null;
|
||||
country_code: string | null;
|
||||
country_name: string | null;
|
||||
asn: string | null;
|
||||
org: string | null;
|
||||
timeline: IpTimelineEvent[];
|
||||
}
|
||||
|
||||
/** Query parameters supported by GET /api/history */
|
||||
export interface HistoryQuery {
|
||||
range?: TimeRange;
|
||||
jail?: string;
|
||||
ip?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user