From 25b4ebbd96c56df2e2699e0db1360f972a1d9cdd Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 20 Mar 2026 15:18:04 +0100 Subject: [PATCH] Refactor frontend API calls into hooks and complete task states --- Docs/Tasks.md | 16 ++ frontend/src/components/SetupGuard.tsx | 29 +- frontend/src/components/config/ServerTab.tsx | 64 ++--- .../src/components/jail/BannedIpsSection.tsx | 120 +++------ .../jail/__tests__/BannedIpsSection.test.tsx | 255 +++--------------- .../hooks/__tests__/useJailBannedIps.test.ts | 29 ++ .../__tests__/useMapColorThresholds.test.ts | 41 +++ frontend/src/hooks/useBlocklist.ts | 17 +- frontend/src/hooks/useJails.ts | 102 +++++++ frontend/src/hooks/useMapColorThresholds.ts | 55 ++++ frontend/src/hooks/useTimezoneData.ts | 41 +++ frontend/src/pages/BlocklistsPage.tsx | 17 +- frontend/src/pages/JailDetailPage.tsx | 33 ++- frontend/src/pages/MapPage.tsx | 33 ++- .../__tests__/JailDetailIgnoreSelf.test.tsx | 15 ++ frontend/src/providers/TimezoneProvider.tsx | 25 +- 16 files changed, 483 insertions(+), 409 deletions(-) create mode 100644 frontend/src/hooks/__tests__/useJailBannedIps.test.ts create mode 100644 frontend/src/hooks/__tests__/useMapColorThresholds.test.ts create mode 100644 frontend/src/hooks/useMapColorThresholds.ts create mode 100644 frontend/src/hooks/useTimezoneData.ts diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 265c094..d568e08 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -249,6 +249,8 @@ Remove or rewrite the docstring snippet so it does not contain a bare `print()` #### TASK F-2 — Wrap `JailDetailPage` jail-control API calls in a hook +**Status:** Completed ✅ + **Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly. **Files affected:** @@ -265,6 +267,8 @@ Remove or rewrite the docstring snippet so it does not contain a bare `print()` #### TASK F-3 — Wrap `MapPage` config API call in a hook +**Status:** Completed ✅ + **Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly. **Files affected:** @@ -280,6 +284,8 @@ Remove or rewrite the docstring snippet so it does not contain a bare `print()` #### TASK F-4 — Wrap `BlocklistsPage` preview API call in a hook +**Status:** Completed ✅ + **Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly. **Files affected:** @@ -295,6 +301,8 @@ Remove or rewrite the docstring snippet so it does not contain a bare `print()` #### TASK F-5 — Move all API calls out of `BannedIpsSection` into a hook +**Status:** Completed ✅ + **Violated rule:** Refactoring.md §3.2 — Components must not call API functions; all data must come via props or hooks invoked in the parent. **Files affected:** @@ -311,6 +319,8 @@ Remove or rewrite the docstring snippet so it does not contain a bare `print()` #### TASK F-6 — Move all API calls out of config tab and dialog components into hooks +**Status:** Completed ✅ + **Violated rule:** Refactoring.md §3.2 — Components must not call API functions. **Files affected (all in `frontend/src/components/config/`):** @@ -341,6 +351,8 @@ For each component listed: #### TASK F-7 — Move `SetupGuard` API call into a hook +**Status:** Completed ✅ + **Violated rule:** Refactoring.md §3.2 — Components must not contain a `useEffect` that calls an API function. **Files affected:** @@ -359,6 +371,8 @@ For each component listed: #### TASK F-8 — Move `ServerTab` direct API calls into hooks +**Status:** Completed ✅ + **Violated rule:** Refactoring.md §3.2 — Components must not call API functions. **Files affected:** @@ -380,6 +394,8 @@ For each component listed: #### TASK F-9 — Move `TimezoneProvider` API call into a hook +**Status:** Completed ✅ + **Violated rule:** Refactoring.md §3.2 — A component (including a provider component) must not contain a `useEffect` that calls an API function directly; API calls belong in `src/hooks/`. **Files affected:** diff --git a/frontend/src/components/SetupGuard.tsx b/frontend/src/components/SetupGuard.tsx index cb7d49a..f7b341d 100644 --- a/frontend/src/components/SetupGuard.tsx +++ b/frontend/src/components/SetupGuard.tsx @@ -6,12 +6,13 @@ * While the status is loading a full-screen spinner is shown. */ -import { useEffect, useState } from "react"; import { Navigate } from "react-router-dom"; import { Spinner } from "@fluentui/react-components"; -import { getSetupStatus } from "../api/setup"; +import { useSetup } from "../hooks/useSetup"; -type Status = "loading" | "done" | "pending"; +/** + * Component is intentionally simple; status load is handled by the hook. + */ interface SetupGuardProps { /** The protected content to render when setup is complete. */ @@ -24,25 +25,9 @@ interface SetupGuardProps { * Redirects to `/setup` if setup is still pending. */ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element { - const [status, setStatus] = useState("loading"); + const { status, loading } = useSetup(); - useEffect(() => { - let cancelled = false; - getSetupStatus() - .then((res): void => { - if (!cancelled) setStatus(res.completed ? "done" : "pending"); - }) - .catch((): void => { - // A failed check conservatively redirects to /setup — a crashed - // backend cannot serve protected routes anyway. - if (!cancelled) setStatus("pending"); - }); - return (): void => { - cancelled = true; - }; - }, []); - - if (status === "loading") { + if (loading) { return (
; } diff --git a/frontend/src/components/config/ServerTab.tsx b/frontend/src/components/config/ServerTab.tsx index 2937ac4..c519fef 100644 --- a/frontend/src/components/config/ServerTab.tsx +++ b/frontend/src/components/config/ServerTab.tsx @@ -25,15 +25,10 @@ import { ArrowSync24Regular, } from "@fluentui/react-icons"; import { ApiError } from "../../api/client"; -import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config"; +import type { ServerSettingsUpdate, MapColorThresholdsUpdate } from "../../types/config"; import { useServerSettings } from "../../hooks/useConfig"; import { useAutoSave } from "../../hooks/useAutoSave"; -import { - fetchMapColorThresholds, - updateMapColorThresholds, - reloadConfig, - restartFail2Ban, -} from "../../api/config"; +import { useMapColorThresholds } from "../../hooks/useMapColorThresholds"; import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { ServerHealthSection } from "./ServerHealthSection"; import { useConfigStyles } from "./configStyles"; @@ -48,7 +43,7 @@ const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"]; */ export function ServerTab(): React.JSX.Element { const styles = useConfigStyles(); - const { settings, loading, error, updateSettings, flush } = + const { settings, loading, error, updateSettings, flush, reload, restart } = useServerSettings(); const [logLevel, setLogLevel] = useState(""); const [logTarget, setLogTarget] = useState(""); @@ -62,11 +57,15 @@ export function ServerTab(): React.JSX.Element { const [isRestarting, setIsRestarting] = useState(false); // Map color thresholds - const [mapThresholds, setMapThresholds] = useState(null); + const { + thresholds: mapThresholds, + error: mapThresholdsError, + refresh: refreshMapThresholds, + updateThresholds: updateMapThresholds, + } = useMapColorThresholds(); const [mapThresholdHigh, setMapThresholdHigh] = useState(""); const [mapThresholdMedium, setMapThresholdMedium] = useState(""); const [mapThresholdLow, setMapThresholdLow] = useState(""); - const [mapLoadError, setMapLoadError] = useState(null); const effectiveLogLevel = logLevel || settings?.log_level || ""; const effectiveLogTarget = logTarget || settings?.log_target || ""; @@ -105,11 +104,11 @@ export function ServerTab(): React.JSX.Element { } }, [flush]); - const handleReload = useCallback(async () => { + const handleReload = async (): Promise => { setIsReloading(true); setMsg(null); try { - await reloadConfig(); + await reload(); setMsg({ text: "fail2ban reloaded successfully", ok: true }); } catch (err: unknown) { setMsg({ @@ -119,13 +118,13 @@ export function ServerTab(): React.JSX.Element { } finally { setIsReloading(false); } - }, []); + }; - const handleRestart = useCallback(async () => { + const handleRestart = async (): Promise => { setIsRestarting(true); setMsg(null); try { - await restartFail2Ban(); + await restart(); setMsg({ text: "fail2ban restart initiated", ok: true }); } catch (err: unknown) { setMsg({ @@ -135,27 +134,15 @@ export function ServerTab(): React.JSX.Element { } finally { setIsRestarting(false); } - }, []); - - // Load map color thresholds on mount. - const loadMapThresholds = useCallback(async (): Promise => { - try { - const data = await fetchMapColorThresholds(); - setMapThresholds(data); - setMapThresholdHigh(String(data.threshold_high)); - setMapThresholdMedium(String(data.threshold_medium)); - setMapThresholdLow(String(data.threshold_low)); - setMapLoadError(null); - } catch (err) { - setMapLoadError( - err instanceof ApiError ? err.message : "Failed to load map color thresholds", - ); - } - }, []); + }; useEffect(() => { - void loadMapThresholds(); - }, [loadMapThresholds]); + if (!mapThresholds) return; + + setMapThresholdHigh(String(mapThresholds.threshold_high)); + setMapThresholdMedium(String(mapThresholds.threshold_medium)); + setMapThresholdLow(String(mapThresholds.threshold_low)); + }, [mapThresholds]); // Map threshold validation and auto-save. const mapHigh = Number(mapThresholdHigh); @@ -190,9 +177,10 @@ export function ServerTab(): React.JSX.Element { const saveMapThresholds = useCallback( async (payload: MapColorThresholdsUpdate): Promise => { - await updateMapColorThresholds(payload); + await updateMapThresholds(payload); + await refreshMapThresholds(); }, - [], + [refreshMapThresholds, updateMapThresholds], ); const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } = @@ -332,10 +320,10 @@ export function ServerTab(): React.JSX.Element {
{/* Map Color Thresholds section */} - {mapLoadError ? ( + {mapThresholdsError ? (
- {mapLoadError} + {mapThresholdsError}
) : mapThresholds ? ( diff --git a/frontend/src/components/jail/BannedIpsSection.tsx b/frontend/src/components/jail/BannedIpsSection.tsx index 62f6676..bf9a6f6 100644 --- a/frontend/src/components/jail/BannedIpsSection.tsx +++ b/frontend/src/components/jail/BannedIpsSection.tsx @@ -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[] = [ /** 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([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(25); - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [opError, setOpError] = useState(null); - - const debounceRef = useRef | 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={} - onClick={load} + onClick={onRefresh} aria-label="Refresh banned IPs" /> @@ -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); }} /> @@ -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={} 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={} disabled={page >= totalPages} onClick={() => { - setPage((p) => p + 1); + onPageChange(page + 1); }} aria-label="Next page" /> diff --git a/frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx b/frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx index e4c5da7..8395ba8 100644 --- a/frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx +++ b/frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx @@ -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 - >(), - 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 = {}) { + 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( - + , ); } -// --------------------------------------------------------------------------- -// 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(); }); }); diff --git a/frontend/src/hooks/__tests__/useJailBannedIps.test.ts b/frontend/src/hooks/__tests__/useJailBannedIps.test.ts new file mode 100644 index 0000000..4b62eb5 --- /dev/null +++ b/frontend/src/hooks/__tests__/useJailBannedIps.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useJailBannedIps } from "../useJails"; +import * as api from "../../api/jails"; + +vi.mock("../../api/jails"); + +describe("useJailBannedIps", () => { + it("loads bans and allows unban", async () => { + const fetchMock = vi.mocked(api.fetchJailBannedIps); + const unbanMock = vi.mocked(api.unbanIp); + + fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", 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" }], total: 1, page: 1, page_size: 25 }); + unbanMock.mockResolvedValue({ message: "ok", jail: "sshd" }); + + const { result } = renderHook(() => useJailBannedIps("sshd")); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.items.length).toBe(1); + + await act(async () => { + await result.current.unban("1.2.3.4"); + }); + + expect(unbanMock).toHaveBeenCalledWith("1.2.3.4", "sshd"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/hooks/__tests__/useMapColorThresholds.test.ts b/frontend/src/hooks/__tests__/useMapColorThresholds.test.ts new file mode 100644 index 0000000..41f946d --- /dev/null +++ b/frontend/src/hooks/__tests__/useMapColorThresholds.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useMapColorThresholds } from "../useMapColorThresholds"; +import * as api from "../../api/config"; + +vi.mock("../../api/config"); + +describe("useMapColorThresholds", () => { + it("loads thresholds and exposes values", async () => { + const mocked = vi.mocked(api.fetchMapColorThresholds); + mocked.mockResolvedValue({ threshold_low: 10, threshold_medium: 20, threshold_high: 50 }); + + const { result } = renderHook(() => useMapColorThresholds()); + + expect(result.current.loading).toBe(true); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.thresholds).toEqual({ threshold_low: 10, threshold_medium: 20, threshold_high: 50 }); + expect(result.current.error).toBeNull(); + }); + + it("updates thresholds via callback", async () => { + const fetchMock = vi.mocked(api.fetchMapColorThresholds); + const updateMock = vi.mocked(api.updateMapColorThresholds); + + fetchMock.mockResolvedValue({ threshold_low: 10, threshold_medium: 20, threshold_high: 50 }); + updateMock.mockResolvedValue({ threshold_low: 15, threshold_medium: 25, threshold_high: 75 }); + + const { result } = renderHook(() => useMapColorThresholds()); + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.updateThresholds({ threshold_low: 15, threshold_medium: 25, threshold_high: 75 }); + }); + + expect(result.current.thresholds).toEqual({ threshold_low: 15, threshold_medium: 25, threshold_high: 75 }); + }); +}); diff --git a/frontend/src/hooks/useBlocklist.ts b/frontend/src/hooks/useBlocklist.ts index 9d64f64..11a61c9 100644 --- a/frontend/src/hooks/useBlocklist.ts +++ b/frontend/src/hooks/useBlocklist.ts @@ -9,6 +9,7 @@ import { fetchBlocklists, fetchImportLog, fetchSchedule, + previewBlocklist, runImportNow, updateBlocklist, updateSchedule, @@ -35,6 +36,7 @@ export interface UseBlocklistsReturn { createSource: (payload: BlocklistSourceCreate) => Promise; updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise; removeSource: (id: number) => Promise; + previewSource: (id: number) => Promise; } /** @@ -99,7 +101,20 @@ export function useBlocklists(): UseBlocklistsReturn { setSources((prev) => prev.filter((s) => s.id !== id)); }, []); - return { sources, loading, error, refresh: load, createSource, updateSource, removeSource }; + const previewSource = useCallback(async (id: number): Promise => { + return previewBlocklist(id); + }, []); + + return { + sources, + loading, + error, + refresh: load, + createSource, + updateSource, + removeSource, + previewSource, + }; } // --------------------------------------------------------------------------- diff --git a/frontend/src/hooks/useJails.ts b/frontend/src/hooks/useJails.ts index a47445f..f985213 100644 --- a/frontend/src/hooks/useJails.ts +++ b/frontend/src/hooks/useJails.ts @@ -13,6 +13,7 @@ import { delIgnoreIp, fetchActiveBans, fetchJail, + fetchJailBannedIps, fetchJails, lookupIp, reloadAllJails, @@ -261,6 +262,107 @@ export function useJailDetail(name: string): UseJailDetailResult { }; } +// --------------------------------------------------------------------------- +// useJailBannedIps + +export interface UseJailBannedIpsResult { + items: ActiveBan[]; + total: number; + page: number; + pageSize: number; + search: string; + loading: boolean; + error: string | null; + opError: string | null; + refresh: () => Promise; + setPage: (page: number) => void; + setPageSize: (size: number) => void; + setSearch: (term: string) => void; + unban: (ip: string) => Promise; +} + +export function useJailBannedIps(jailName: string): UseJailBannedIpsResult { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [opError, setOpError] = useState(null); + const debounceRef = useRef | null>(null); + + const load = useCallback(async (): Promise => { + if (!jailName) { + setItems([]); + setTotal(0); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const resp = await fetchJailBannedIps(jailName, page, pageSize, debouncedSearch || undefined); + setItems(resp.items); + setTotal(resp.total); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [jailName, page, pageSize, debouncedSearch]); + + useEffect(() => { + if (debounceRef.current !== null) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 300); + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + return () => { + if (debounceRef.current !== null) { + clearTimeout(debounceRef.current); + } + }; + }, [search]); + + useEffect(() => { + void load(); + }, [load]); + + const unban = useCallback(async (ip: string): Promise => { + setOpError(null); + try { + await unbanIp(ip, jailName); + await load(); + } catch (err: unknown) { + setOpError(err instanceof Error ? err.message : String(err)); + } + }, [jailName, load]); + + return { + items, + total, + page, + pageSize, + search, + loading, + error, + opError, + refresh: load, + setPage, + setPageSize, + setSearch, + unban, + }; +} + // --------------------------------------------------------------------------- // useActiveBans — live ban list // --------------------------------------------------------------------------- diff --git a/frontend/src/hooks/useMapColorThresholds.ts b/frontend/src/hooks/useMapColorThresholds.ts new file mode 100644 index 0000000..8b73e48 --- /dev/null +++ b/frontend/src/hooks/useMapColorThresholds.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from "react"; +import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config"; +import type { + MapColorThresholdsResponse, + MapColorThresholdsUpdate, +} from "../types/config"; + +export interface UseMapColorThresholdsResult { + thresholds: MapColorThresholdsResponse | null; + loading: boolean; + error: string | null; + refresh: () => Promise; + updateThresholds: (payload: MapColorThresholdsUpdate) => Promise; +} + +export function useMapColorThresholds(): UseMapColorThresholdsResult { + const [thresholds, setThresholds] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async (): Promise => { + setLoading(true); + setError(null); + + try { + const data = await fetchMapColorThresholds(); + setThresholds(data); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to fetch map color thresholds"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const updateThresholds = useCallback( + async (payload: MapColorThresholdsUpdate): Promise => { + const updated = await updateMapColorThresholds(payload); + setThresholds(updated); + return updated; + }, + [], + ); + + return { + thresholds, + loading, + error, + refresh: load, + updateThresholds, + }; +} diff --git a/frontend/src/hooks/useTimezoneData.ts b/frontend/src/hooks/useTimezoneData.ts new file mode 100644 index 0000000..6f919a9 --- /dev/null +++ b/frontend/src/hooks/useTimezoneData.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useState } from "react"; +import { fetchTimezone } from "../api/setup"; + +export interface UseTimezoneDataResult { + timezone: string; + loading: boolean; + error: string | null; + refresh: () => Promise; +} + +export function useTimezoneData(): UseTimezoneDataResult { + const [timezone, setTimezone] = useState("UTC"); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async (): Promise => { + setLoading(true); + setError(null); + + try { + const resp = await fetchTimezone(); + setTimezone(resp.timezone); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to fetch timezone"); + setTimezone("UTC"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return { + timezone, + loading, + error, + refresh: load, + }; +} diff --git a/frontend/src/pages/BlocklistsPage.tsx b/frontend/src/pages/BlocklistsPage.tsx index a494d69..dd9f728 100644 --- a/frontend/src/pages/BlocklistsPage.tsx +++ b/frontend/src/pages/BlocklistsPage.tsx @@ -51,7 +51,6 @@ import { useRunImport, useSchedule, } from "../hooks/useBlocklist"; -import { previewBlocklist } from "../api/blocklist"; import type { BlocklistSource, ImportRunResult, @@ -262,9 +261,10 @@ interface PreviewDialogProps { open: boolean; source: BlocklistSource | null; onClose: () => void; + fetchPreview: (id: number) => Promise; } -function PreviewDialog({ open, source, onClose }: PreviewDialogProps): React.JSX.Element { +function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element { const styles = useStyles(); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); @@ -276,7 +276,7 @@ function PreviewDialog({ open, source, onClose }: PreviewDialogProps): React.JSX setData(null); setError(null); setLoading(true); - previewBlocklist(source.id) + fetchPreview(source.id) .then((result) => { setData(result); setLoading(false); @@ -285,7 +285,7 @@ function PreviewDialog({ open, source, onClose }: PreviewDialogProps): React.JSX setError(err instanceof Error ? err.message : "Failed to fetch preview"); setLoading(false); }); - }, [source]); + }, [source, fetchPreview]); return ( (null); const [previewOpen, setPreviewOpen] = useState(false); - const [previewSource, setPreviewSource] = useState(null); + const [previewSourceItem, setPreviewSourceItem] = useState(null); const openAdd = useCallback((): void => { setDialogMode("add"); @@ -466,7 +466,7 @@ function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps): ); const handlePreview = useCallback((source: BlocklistSource): void => { - setPreviewSource(source); + setPreviewSourceItem(source); setPreviewOpen(true); }, []); @@ -594,10 +594,11 @@ function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps): { setPreviewOpen(false); }} + fetchPreview={previewSource} /> ); diff --git a/frontend/src/pages/JailDetailPage.tsx b/frontend/src/pages/JailDetailPage.tsx index 83d9f47..16542a6 100644 --- a/frontend/src/pages/JailDetailPage.tsx +++ b/frontend/src/pages/JailDetailPage.tsx @@ -33,7 +33,7 @@ import { StopRegular, } from "@fluentui/react-icons"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { useJailDetail } from "../hooks/useJails"; +import { useJailDetail, useJailBannedIps } from "../hooks/useJails"; import type { Jail } from "../types/jail"; import { BannedIpsSection } from "../components/jail/BannedIpsSection"; @@ -574,6 +574,21 @@ export function JailDetailPage(): React.JSX.Element { const { name = "" } = useParams<{ name: string }>(); const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf, start, stop, reload, setIdle } = useJailDetail(name); + const { + items, + total, + page, + pageSize, + search, + loading: bannedLoading, + error: bannedError, + opError, + refresh: refreshBanned, + setPage, + setPageSize, + setSearch, + unban, + } = useJailBannedIps(name); if (loading && !jail) { return ( @@ -618,7 +633,21 @@ export function JailDetailPage(): React.JSX.Element { - + ("24h"); const [originFilter, setOriginFilter] = useState("all"); const [selectedCountry, setSelectedCountry] = useState(null); - const [thresholdLow, setThresholdLow] = useState(20); - const [thresholdMedium, setThresholdMedium] = useState(50); - const [thresholdHigh, setThresholdHigh] = useState(100); const { countries, countryNames, bans, total, loading, error, refresh } = useMapData(range, originFilter); - // Fetch color thresholds on mount + const { + thresholds: mapThresholds, + error: mapThresholdError, + } = useMapColorThresholds(); + + const thresholdLow = mapThresholds?.threshold_low ?? 20; + const thresholdMedium = mapThresholds?.threshold_medium ?? 50; + const thresholdHigh = mapThresholds?.threshold_high ?? 100; + useEffect(() => { - const loadThresholds = async (): Promise => { - try { - const thresholds = await fetchMapColorThresholds(); - setThresholdLow(thresholds.threshold_low); - setThresholdMedium(thresholds.threshold_medium); - setThresholdHigh(thresholds.threshold_high); - } catch (err) { - // Silently fall back to defaults if fetch fails - console.warn("Failed to load map color thresholds:", err); - } - }; - void loadThresholds(); - }, []); + if (mapThresholdError) { + // Silently fall back to defaults if fetch fails + console.warn("Failed to load map color thresholds:", mapThresholdError); + } + }, [mapThresholdError]); /** Bans visible in the companion table (filtered by selected country). */ const visibleBans = useMemo(() => { diff --git a/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx b/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx index eab2348..bebcc8c 100644 --- a/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx +++ b/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx @@ -41,6 +41,21 @@ const { // Mock the jail detail hook — tests control the returned state directly. vi.mock("../../hooks/useJails", () => ({ useJailDetail: vi.fn(), + useJailBannedIps: vi.fn(() => ({ + items: [], + total: 0, + page: 1, + pageSize: 25, + search: "", + loading: false, + error: null, + opError: null, + refresh: vi.fn(), + setPage: vi.fn(), + setPageSize: vi.fn(), + setSearch: vi.fn(), + unban: vi.fn(), + })), })); // Mock API functions used by JailInfoSection control buttons to avoid side effects. diff --git a/frontend/src/providers/TimezoneProvider.tsx b/frontend/src/providers/TimezoneProvider.tsx index 176a12a..bc540f2 100644 --- a/frontend/src/providers/TimezoneProvider.tsx +++ b/frontend/src/providers/TimezoneProvider.tsx @@ -9,15 +9,8 @@ * always receive a safe fallback. */ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import { fetchTimezone } from "../api/setup"; +import { createContext, useContext, useMemo } from "react"; +import { useTimezoneData } from "../hooks/useTimezoneData"; // --------------------------------------------------------------------------- // Context definition @@ -52,19 +45,7 @@ export interface TimezoneProviderProps { export function TimezoneProvider({ children, }: TimezoneProviderProps): React.JSX.Element { - const [timezone, setTimezone] = useState("UTC"); - - const load = useCallback((): void => { - fetchTimezone() - .then((resp) => { setTimezone(resp.timezone); }) - .catch(() => { - // Silently fall back to UTC; the backend may not be reachable yet. - }); - }, []); - - useEffect(() => { - load(); - }, [load]); + const { timezone } = useTimezoneData(); const value = useMemo(() => ({ timezone }), [timezone]);