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)
467 lines
13 KiB
TypeScript
467 lines
13 KiB
TypeScript
/**
|
||
* `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>
|
||
);
|
||
}
|