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:
466
frontend/src/components/jail/BannedIpsSection.tsx
Normal file
466
frontend/src/components/jail/BannedIpsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user