feat: Task 4 — paginated banned-IPs section on jail detail page

Backend:
- Add JailBannedIpsResponse Pydantic model (ban.py)
- Add get_jail_banned_ips() service: server-side pagination, optional
  IP substring search, geo enrichment on page slice only (jail_service.py)
- Add GET /api/jails/{name}/banned endpoint with page/page_size/search
  query params, 400/404/502 error handling (routers/jails.py)
- 23 new tests: 13 service tests + 10 router tests (all passing)

Frontend:
- Add JailBannedIpsResponse TS interface (types/jail.ts)
- Add jailBanned endpoint helper (api/endpoints.ts)
- Add fetchJailBannedIps() API function (api/jails.ts)
- Add BannedIpsSection component: Fluent UI DataGrid, debounced search
  (300 ms), prev/next pagination, page-size dropdown, per-row unban
  button, loading spinner, empty state, error MessageBar (BannedIpsSection.tsx)
- Mount BannedIpsSection in JailDetailPage between stats and patterns
- 12 new Vitest tests for BannedIpsSection (all passing)
This commit is contained in:
2026-03-14 16:28:43 +01:00
parent 0966f347c4
commit baf45c6c62
12 changed files with 1333 additions and 3 deletions

View File

@@ -0,0 +1,466 @@
/**
* `BannedIpsSection` component.
*
* Displays a paginated table of IPs currently banned in a specific fail2ban
* jail. Supports server-side search filtering (debounced), page navigation,
* page-size selection, and per-row unban actions.
*
* Only the current page is geo-enriched by the backend, so the component
* remains fast even when a jail contains thousands of banned IPs.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
Badge,
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
Dropdown,
Field,
Input,
MessageBar,
MessageBarBody,
Option,
Spinner,
Text,
Tooltip,
makeStyles,
tokens,
type TableColumnDefinition,
createTableColumn,
} from "@fluentui/react-components";
import {
ArrowClockwiseRegular,
ChevronLeftRegular,
ChevronRightRegular,
DismissRegular,
SearchRegular,
} from "@fluentui/react-icons";
import { fetchJailBannedIps, unbanIp } from "../../api/jails";
import type { ActiveBan } from "../../types/jail";
import { ApiError } from "../../api/client";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Debounce delay in milliseconds for the search input. */
const SEARCH_DEBOUNCE_MS = 300;
/** Available page-size options. */
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
},
header: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalM,
paddingBottom: tokens.spacingVerticalS,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
},
headerLeft: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
},
toolbar: {
display: "flex",
alignItems: "flex-end",
gap: tokens.spacingHorizontalS,
flexWrap: "wrap",
},
searchField: {
minWidth: "200px",
flexGrow: 1,
},
tableWrapper: {
overflowX: "auto",
},
centred: {
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: tokens.spacingVerticalXXL,
},
pagination: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: tokens.spacingHorizontalS,
paddingTop: tokens.spacingVerticalS,
flexWrap: "wrap",
},
pageSizeWrapper: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalXS,
},
mono: {
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Format an ISO 8601 timestamp for compact display.
*
* @param iso - ISO 8601 string or `null`.
* @returns A locale time string, or `"—"` when `null`.
*/
function fmtTime(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/** A row item augmented with an `onUnban` callback for the row action. */
interface BanRow {
ban: ActiveBan;
onUnban: (ip: string) => void;
}
const columns: TableColumnDefinition<BanRow>[] = [
createTableColumn<BanRow>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: ({ ban }) => (
<Text
style={{
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
}}
>
{ban.ip}
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: ({ ban }) =>
ban.country ? (
<Text size={200}>{ban.country}</Text>
) : (
<Text size={200} style={{ color: tokens.colorNeutralForeground4 }}>
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "banned_at",
renderHeaderCell: () => "Banned At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.banned_at)}</Text>,
}),
createTableColumn<BanRow>({
columnId: "expires_at",
renderHeaderCell: () => "Expires At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.expires_at)}</Text>,
}),
createTableColumn<BanRow>({
columnId: "actions",
renderHeaderCell: () => "",
renderCell: ({ ban, onUnban }) => (
<Tooltip content={`Unban ${ban.ip}`} relationship="label">
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => {
onUnban(ban.ip);
}}
aria-label={`Unban ${ban.ip}`}
/>
</Tooltip>
),
}),
];
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
/** Props for {@link BannedIpsSection}. */
export interface BannedIpsSectionProps {
/** The jail name whose banned IPs are displayed. */
jailName: string;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Paginated section showing currently banned IPs for a single jail.
*
* @param props - {@link BannedIpsSectionProps}
*/
export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX.Element {
const styles = useStyles();
const [items, setItems] = useState<ActiveBan[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<number>(25);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [opError, setOpError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Debounce the search input so we don't spam the backend on every keystroke.
useEffect(() => {
if (debounceRef.current !== null) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout((): void => {
setDebouncedSearch(search);
setPage(1);
}, SEARCH_DEBOUNCE_MS);
return (): void => {
if (debounceRef.current !== null) clearTimeout(debounceRef.current);
};
}, [search]);
const load = useCallback(() => {
setLoading(true);
setError(null);
fetchJailBannedIps(jailName, page, pageSize, debouncedSearch || undefined)
.then((resp) => {
setItems(resp.items);
setTotal(resp.total);
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setError(msg);
})
.finally(() => {
setLoading(false);
});
}, [jailName, page, pageSize, debouncedSearch]);
useEffect(() => {
load();
}, [load]);
const handleUnban = (ip: string): void => {
setOpError(null);
unbanIp(ip, jailName)
.then(() => {
load();
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setOpError(msg);
});
};
const rows: BanRow[] = items.map((ban) => ({
ban,
onUnban: handleUnban,
}));
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
return (
<div className={styles.root}>
{/* Section header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<Text as="h2" size={500} weight="semibold">
Currently Banned IPs
</Text>
<Badge appearance="tint">{String(total)}</Badge>
</div>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={load}
aria-label="Refresh banned IPs"
/>
</div>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.searchField}>
<Field label="Search by IP">
<Input
aria-label="Search by IP"
contentBefore={<SearchRegular />}
placeholder="e.g. 192.168"
value={search}
onChange={(_, d) => {
setSearch(d.value);
}}
/>
</Field>
</div>
</div>
{/* Error bars */}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
</MessageBar>
)}
{/* Table */}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading banned IPs…" />
</div>
) : items.length === 0 ? (
<div className={styles.centred}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
No IPs currently banned in this jail.
</Text>
</div>
) : (
<div className={styles.tableWrapper}>
<DataGrid
items={rows}
columns={columns}
getRowId={(row: BanRow) => row.ban.ip}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<BanRow>>
{({ item, rowId }) => (
<DataGridRow<BanRow> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
)}
{/* Pagination */}
{total > 0 && (
<div className={styles.pagination}>
<div className={styles.pageSizeWrapper}>
<Text size={200}>Rows per page:</Text>
<Dropdown
aria-label="Rows per page"
value={String(pageSize)}
selectedOptions={[String(pageSize)]}
onOptionSelect={(_, d) => {
const newSize = Number(d.optionValue);
if (!Number.isNaN(newSize)) {
setPageSize(newSize);
setPage(1);
}
}}
style={{ minWidth: "80px" }}
>
{PAGE_SIZE_OPTIONS.map((n) => (
<Option key={n} value={String(n)}>
{String(n)}
</Option>
))}
</Dropdown>
</div>
<Text size={200}>
{String((page - 1) * pageSize + 1)}
{String(Math.min(page * pageSize, total))} of {String(total)}
</Text>
<Button
size="small"
appearance="subtle"
icon={<ChevronLeftRegular />}
disabled={page <= 1}
onClick={() => {
setPage((p) => Math.max(1, p - 1));
}}
aria-label="Previous page"
/>
<Button
size="small"
appearance="subtle"
icon={<ChevronRightRegular />}
disabled={page >= totalPages}
onClick={() => {
setPage((p) => p + 1);
}}
aria-label="Next page"
/>
</div>
)}
</div>
);
}