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:
@@ -38,6 +38,7 @@ export const ENDPOINTS = {
|
||||
// -------------------------------------------------------------------------
|
||||
jails: "/jails",
|
||||
jail: (name: string): string => `/jails/${encodeURIComponent(name)}`,
|
||||
jailBanned: (name: string): string => `/jails/${encodeURIComponent(name)}/banned`,
|
||||
jailStart: (name: string): string => `/jails/${encodeURIComponent(name)}/start`,
|
||||
jailStop: (name: string): string => `/jails/${encodeURIComponent(name)}/stop`,
|
||||
jailIdle: (name: string): string => `/jails/${encodeURIComponent(name)}/idle`,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ENDPOINTS } from "./endpoints";
|
||||
import type {
|
||||
ActiveBanListResponse,
|
||||
IpLookupResponse,
|
||||
JailBannedIpsResponse,
|
||||
JailCommandResponse,
|
||||
JailDetailResponse,
|
||||
JailListResponse,
|
||||
@@ -224,3 +225,37 @@ export async function unbanAllBans(): Promise<UnbanAllResponse> {
|
||||
export async function lookupIp(ip: string): Promise<IpLookupResponse> {
|
||||
return get<IpLookupResponse>(ENDPOINTS.geoLookup(ip));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail-specific paginated bans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch the currently banned IPs for a specific jail, paginated.
|
||||
*
|
||||
* Only the requested page is geo-enriched on the backend, so this call
|
||||
* remains fast even when a jail has thousands of banned IPs.
|
||||
*
|
||||
* @param jailName - Jail name (e.g. `"sshd"`).
|
||||
* @param page - 1-based page number (default 1).
|
||||
* @param pageSize - Items per page; max 100 (default 25).
|
||||
* @param search - Optional case-insensitive IP substring filter.
|
||||
* @returns A {@link JailBannedIpsResponse} with paginated ban entries.
|
||||
* @throws {ApiError} On non-2xx responses (404 if jail unknown, 502 if fail2ban down).
|
||||
*/
|
||||
export async function fetchJailBannedIps(
|
||||
jailName: string,
|
||||
page = 1,
|
||||
pageSize = 25,
|
||||
search?: string,
|
||||
): Promise<JailBannedIpsResponse> {
|
||||
const params: Record<string, string> = {
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
};
|
||||
if (search !== undefined && search !== "") {
|
||||
params.search = search;
|
||||
}
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get<JailBannedIpsResponse>(`${ENDPOINTS.jailBanned(jailName)}?${query}`);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
251
frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx
Normal file
251
frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Tests for the `BannedIpsSection` component.
|
||||
*
|
||||
* Verifies:
|
||||
* - Renders the section header and total count badge.
|
||||
* - Shows a spinner while loading.
|
||||
* - Renders a table with IP rows on success.
|
||||
* - Shows an empty-state message when there are no banned IPs.
|
||||
* - Displays an error message bar when the API call fails.
|
||||
* - Search input re-fetches with the search parameter after debounce.
|
||||
* - Unban button calls `unbanIp` and refreshes the list.
|
||||
* - Pagination buttons are shown and change the page.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { BannedIpsSection } from "../BannedIpsSection";
|
||||
import type { JailBannedIpsResponse } from "../../../types/jail";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { mockFetchJailBannedIps, mockUnbanIp } = vi.hoisted(() => ({
|
||||
mockFetchJailBannedIps: vi.fn<
|
||||
(
|
||||
jailName: string,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
search?: string,
|
||||
) => Promise<JailBannedIpsResponse>
|
||||
>(),
|
||||
mockUnbanIp: vi.fn<
|
||||
(ip: string, jail?: string) => Promise<{ message: string; jail: string }>
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../api/jails", () => ({
|
||||
fetchJailBannedIps: mockFetchJailBannedIps,
|
||||
unbanIp: mockUnbanIp,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeBan(ip: string) {
|
||||
return {
|
||||
ip,
|
||||
jail: "sshd",
|
||||
banned_at: "2025-01-01T10:00:00+00:00",
|
||||
expires_at: "2025-01-01T10:10:00+00:00",
|
||||
ban_count: 1,
|
||||
country: "US",
|
||||
};
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
ips: string[] = ["1.2.3.4", "5.6.7.8"],
|
||||
total = 2,
|
||||
): JailBannedIpsResponse {
|
||||
return {
|
||||
items: ips.map(makeBan),
|
||||
total,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_RESPONSE: JailBannedIpsResponse = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderSection(jailName = "sshd") {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<BannedIpsSection jailName={jailName} />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("BannedIpsSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
|
||||
});
|
||||
|
||||
it("renders section header with 'Currently Banned IPs' title", async () => {
|
||||
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Currently Banned IPs")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the total count badge", async () => {
|
||||
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"], 2));
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a spinner while loading", () => {
|
||||
// Never resolves during this test so we see the spinner.
|
||||
mockFetchJailBannedIps.mockReturnValue(new Promise(() => void 0));
|
||||
renderSection();
|
||||
expect(screen.getByText("Loading banned IPs…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders IP rows when banned IPs exist", async () => {
|
||||
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"]));
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("1.2.3.4")).toBeTruthy();
|
||||
expect(screen.getByText("5.6.7.8")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty-state message when no IPs are banned", async () => {
|
||||
mockFetchJailBannedIps.mockResolvedValue(EMPTY_RESPONSE);
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("No IPs currently banned in this jail."),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error message bar on API failure", async () => {
|
||||
mockFetchJailBannedIps.mockRejectedValue(new Error("socket dead"));
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/socket dead/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls fetchJailBannedIps with the jail name", async () => {
|
||||
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
|
||||
renderSection("nginx");
|
||||
await waitFor(() => {
|
||||
expect(mockFetchJailBannedIps).toHaveBeenCalledWith(
|
||||
"nginx",
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("search input re-fetches after debounce with the search term", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
|
||||
renderSection();
|
||||
// Flush pending async work from the initial render (no timer advancement needed).
|
||||
await act(async () => {});
|
||||
|
||||
mockFetchJailBannedIps.mockClear();
|
||||
mockFetchJailBannedIps.mockResolvedValue(
|
||||
makeResponse(["1.2.3.4"], 1),
|
||||
);
|
||||
|
||||
// fireEvent is synchronous — avoids hanging with fake timers.
|
||||
const input = screen.getByPlaceholderText("e.g. 192.168");
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "1.2.3" } });
|
||||
});
|
||||
|
||||
// Advance just past the 300ms debounce delay and flush promises.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
|
||||
expect(mockFetchJailBannedIps).toHaveBeenLastCalledWith(
|
||||
"sshd",
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
"1.2.3",
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls unbanIp when the unban button is clicked", async () => {
|
||||
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4"]));
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("1.2.3.4")).toBeTruthy();
|
||||
});
|
||||
|
||||
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
|
||||
await userEvent.click(unbanBtn);
|
||||
|
||||
expect(mockUnbanIp).toHaveBeenCalledWith("1.2.3.4", "sshd");
|
||||
});
|
||||
|
||||
it("refreshes list after successful unban", async () => {
|
||||
mockFetchJailBannedIps
|
||||
.mockResolvedValueOnce(makeResponse(["1.2.3.4"]))
|
||||
.mockResolvedValue(EMPTY_RESPONSE);
|
||||
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
|
||||
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("1.2.3.4")).toBeTruthy();
|
||||
});
|
||||
|
||||
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
|
||||
await userEvent.click(unbanBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchJailBannedIps).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows pagination controls when total > 0", async () => {
|
||||
mockFetchJailBannedIps.mockResolvedValue(
|
||||
makeResponse(["1.2.3.4", "5.6.7.8"], 50),
|
||||
);
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Next page")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Previous page")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("previous page button is disabled on page 1", async () => {
|
||||
mockFetchJailBannedIps.mockResolvedValue(
|
||||
makeResponse(["1.2.3.4"], 50),
|
||||
);
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
const prevBtn = screen.getByLabelText("Previous page");
|
||||
expect(prevBtn).toHaveAttribute("disabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { useJailDetail } from "../hooks/useJails";
|
||||
import type { Jail } from "../types/jail";
|
||||
import { ApiError } from "../api/client";
|
||||
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
@@ -624,6 +625,7 @@ export function JailDetailPage(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
<JailInfoSection jail={jail} onRefresh={refresh} />
|
||||
<BannedIpsSection jailName={name} />
|
||||
<PatternsSection jail={jail} />
|
||||
<BantimeEscalationSection jail={jail} />
|
||||
<IgnoreListSection
|
||||
|
||||
@@ -198,6 +198,26 @@ export interface UnbanAllResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail-specific paginated bans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Paginated response from `GET /api/jails/{name}/banned`.
|
||||
*
|
||||
* Mirrors `JailBannedIpsResponse` from `backend/app/models/ban.py`.
|
||||
*/
|
||||
export interface JailBannedIpsResponse {
|
||||
/** Active ban entries for the current page. */
|
||||
items: ActiveBan[];
|
||||
/** Total matching entries (after applying any search filter). */
|
||||
total: number;
|
||||
/** Current page number (1-based). */
|
||||
page: number;
|
||||
/** Number of items per page. */
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface GeoDetail {
|
||||
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
|
||||
country_code: string | null;
|
||||
|
||||
Reference in New Issue
Block a user