Refactor frontend API calls into hooks and complete task states

This commit is contained in:
2026-03-20 15:18:04 +01:00
parent d30d138146
commit 28a7610276
16 changed files with 483 additions and 409 deletions

View File

@@ -9,7 +9,6 @@
* remains fast even when a jail contains thousands of banned IPs.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
Badge,
Button,
@@ -40,17 +39,12 @@ import {
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;
@@ -229,8 +223,19 @@ const columns: TableColumnDefinition<BanRow>[] = [
/** Props for {@link BannedIpsSection}. */
export interface BannedIpsSectionProps {
/** The jail name whose banned IPs are displayed. */
jailName: string;
items: ActiveBan[];
total: number;
page: number;
pageSize: number;
search: string;
loading: boolean;
error: string | null;
opError: string | null;
onSearch: (term: string) => void;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onRefresh: () => void;
onUnban: (ip: string) => void;
}
// ---------------------------------------------------------------------------
@@ -242,81 +247,26 @@ export interface BannedIpsSectionProps {
*
* @param props - {@link BannedIpsSectionProps}
*/
export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX.Element {
export function BannedIpsSection({
items,
total,
page,
pageSize,
search,
loading,
error,
opError,
onSearch,
onPageChange,
onPageSizeChange,
onRefresh,
onUnban,
}: 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,
onUnban,
}));
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
@@ -335,7 +285,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={load}
onClick={onRefresh}
aria-label="Refresh banned IPs"
/>
</div>
@@ -350,7 +300,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
placeholder="e.g. 192.168"
value={search}
onChange={(_, d) => {
setSearch(d.value);
onSearch(d.value);
}}
/>
</Field>
@@ -420,8 +370,8 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
onOptionSelect={(_, d) => {
const newSize = Number(d.optionValue);
if (!Number.isNaN(newSize)) {
setPageSize(newSize);
setPage(1);
onPageSizeChange(newSize);
onPageChange(1);
}
}}
style={{ minWidth: "80px" }}
@@ -445,7 +395,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
icon={<ChevronLeftRegular />}
disabled={page <= 1}
onClick={() => {
setPage((p) => Math.max(1, p - 1));
onPageChange(Math.max(1, page - 1));
}}
aria-label="Previous page"
/>
@@ -455,7 +405,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
icon={<ChevronRightRegular />}
disabled={page >= totalPages}
onClick={() => {
setPage((p) => p + 1);
onPageChange(page + 1);
}}
aria-label="Next page"
/>

View File

@@ -1,52 +1,11 @@
/**
* 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 { describe, it, expect, vi } from "vitest";
import { render, screen } 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";
import { BannedIpsSection, type BannedIpsSectionProps } from "../BannedIpsSection";
import type { ActiveBan } 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) {
function makeBan(ip: string): ActiveBan {
return {
ip,
jail: "sshd",
@@ -57,195 +16,65 @@ function makeBan(ip: string) {
};
}
function makeResponse(
ips: string[] = ["1.2.3.4", "5.6.7.8"],
total = 2,
): JailBannedIpsResponse {
return {
items: ips.map(makeBan),
total,
function renderWithProps(props: Partial<BannedIpsSectionProps> = {}) {
const defaults: BannedIpsSectionProps = {
items: [makeBan("1.2.3.4"), makeBan("5.6.7.8")],
total: 2,
page: 1,
page_size: 25,
pageSize: 25,
search: "",
loading: false,
error: null,
opError: null,
onSearch: vi.fn(),
onPageChange: vi.fn(),
onPageSizeChange: vi.fn(),
onRefresh: vi.fn(),
onUnban: vi.fn(),
};
}
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} />
<BannedIpsSection {...defaults} {...props} />
</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 the table rows and total count", () => {
renderWithProps();
expect(screen.getByText("Currently Banned IPs")).toBeTruthy();
expect(screen.getByText("1.2.3.4")).toBeTruthy();
expect(screen.getByText("5.6.7.8")).toBeTruthy();
});
it("shows a spinner while loading", () => {
// Never resolves during this test so we see the spinner.
mockFetchJailBannedIps.mockReturnValue(new Promise(() => void 0));
renderSection();
renderWithProps({ loading: true, items: [] });
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 error message when error is present", () => {
renderWithProps({ error: "Failed to load" });
expect(screen.getByText(/Failed to load/i)).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("triggers onUnban for IP row button", async () => {
const onUnban = vi.fn();
renderWithProps({ onUnban });
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
await userEvent.click(unbanBtn);
expect(onUnban).toHaveBeenCalledWith("1.2.3.4");
});
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 onSearch when the search input changes", async () => {
const onSearch = vi.fn();
renderWithProps({ onSearch });
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" } });
});
await userEvent.type(input, "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");
});
expect(onSearch).toHaveBeenCalled();
});
});