Refactor frontend API calls into hooks and complete task states
This commit is contained in:
@@ -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:**
|
||||
|
||||
@@ -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<Status>("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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -57,7 +42,7 @@ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
if (!status?.completed) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MapColorThresholdsResponse | null>(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<string | null>(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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 {
|
||||
</div>
|
||||
|
||||
{/* Map Color Thresholds section */}
|
||||
{mapLoadError ? (
|
||||
{mapThresholdsError ? (
|
||||
<div className={styles.sectionCard}>
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{mapLoadError}</MessageBarBody>
|
||||
<MessageBarBody>{mapThresholdsError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
</div>
|
||||
) : mapThresholds ? (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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(() => {
|
||||
it("shows the table rows and total count", () => {
|
||||
renderWithProps();
|
||||
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 a spinner while loading", () => {
|
||||
renderWithProps({ loading: true, items: [] });
|
||||
expect(screen.getByText("Loading banned IPs…")).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 error message when error is present", () => {
|
||||
renderWithProps({ error: "Failed to load" });
|
||||
expect(screen.getByText(/Failed to load/i)).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("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("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("calls onSearch when the search input changes", async () => {
|
||||
const onSearch = vi.fn();
|
||||
renderWithProps({ onSearch });
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
29
frontend/src/hooks/__tests__/useJailBannedIps.test.ts
Normal file
29
frontend/src/hooks/__tests__/useJailBannedIps.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
41
frontend/src/hooks/__tests__/useMapColorThresholds.test.ts
Normal file
41
frontend/src/hooks/__tests__/useMapColorThresholds.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fetchBlocklists,
|
||||
fetchImportLog,
|
||||
fetchSchedule,
|
||||
previewBlocklist,
|
||||
runImportNow,
|
||||
updateBlocklist,
|
||||
updateSchedule,
|
||||
@@ -35,6 +36,7 @@ export interface UseBlocklistsReturn {
|
||||
createSource: (payload: BlocklistSourceCreate) => Promise<BlocklistSource>;
|
||||
updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise<BlocklistSource>;
|
||||
removeSource: (id: number) => Promise<void>;
|
||||
previewSource: (id: number) => Promise<PreviewResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<PreviewResponse> => {
|
||||
return previewBlocklist(id);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sources,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
createSource,
|
||||
updateSource,
|
||||
removeSource,
|
||||
previewSource,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<void>;
|
||||
setPage: (page: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
setSearch: (term: string) => void;
|
||||
unban: (ip: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useJailBannedIps(jailName: string): UseJailBannedIpsResult {
|
||||
const [items, setItems] = useState<ActiveBan[]>([]);
|
||||
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<string | null>(null);
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
55
frontend/src/hooks/useMapColorThresholds.ts
Normal file
55
frontend/src/hooks/useMapColorThresholds.ts
Normal file
@@ -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<void>;
|
||||
updateThresholds: (payload: MapColorThresholdsUpdate) => Promise<MapColorThresholdsResponse>;
|
||||
}
|
||||
|
||||
export function useMapColorThresholds(): UseMapColorThresholdsResult {
|
||||
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
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<MapColorThresholdsResponse> => {
|
||||
const updated = await updateMapColorThresholds(payload);
|
||||
setThresholds(updated);
|
||||
return updated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
thresholds,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
updateThresholds,
|
||||
};
|
||||
}
|
||||
41
frontend/src/hooks/useTimezoneData.ts
Normal file
41
frontend/src/hooks/useTimezoneData.ts
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
export function useTimezoneData(): UseTimezoneDataResult {
|
||||
const [timezone, setTimezone] = useState<string>("UTC");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<PreviewResponse>;
|
||||
}
|
||||
|
||||
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<PreviewResponse | null>(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 (
|
||||
<Dialog
|
||||
@@ -400,7 +400,7 @@ interface SourcesSectionProps {
|
||||
|
||||
function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { sources, loading, error, refresh, createSource, updateSource, removeSource } =
|
||||
const { sources, loading, error, refresh, createSource, updateSource, removeSource, previewSource } =
|
||||
useBlocklists();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -410,7 +410,7 @@ function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps):
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewSource, setPreviewSource] = useState<BlocklistSource | null>(null);
|
||||
const [previewSourceItem, setPreviewSourceItem] = useState<BlocklistSource | null>(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):
|
||||
|
||||
<PreviewDialog
|
||||
open={previewOpen}
|
||||
source={previewSource}
|
||||
source={previewSourceItem}
|
||||
onClose={() => {
|
||||
setPreviewOpen(false);
|
||||
}}
|
||||
fetchPreview={previewSource}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
</div>
|
||||
|
||||
<JailInfoSection jail={jail} onRefresh={refresh} onStart={start} onStop={stop} onReload={reload} onSetIdle={setIdle} />
|
||||
<BannedIpsSection jailName={name} />
|
||||
<BannedIpsSection
|
||||
items={items}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
loading={bannedLoading}
|
||||
error={bannedError}
|
||||
opError={opError}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
onRefresh={refreshBanned}
|
||||
onUnban={unban}
|
||||
/>
|
||||
<PatternsSection jail={jail} />
|
||||
<BantimeEscalationSection jail={jail} />
|
||||
<IgnoreListSection
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
||||
import { WorldMap } from "../components/WorldMap";
|
||||
import { useMapData } from "../hooks/useMapData";
|
||||
import { fetchMapColorThresholds } from "../api/config";
|
||||
import { useMapColorThresholds } from "../hooks/useMapColorThresholds";
|
||||
import type { TimeRange } from "../types/map";
|
||||
import type { BanOriginFilter } from "../types/ban";
|
||||
import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban";
|
||||
@@ -93,28 +93,25 @@ export function MapPage(): React.JSX.Element {
|
||||
const [range, setRange] = useState<TimeRange>("24h");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
|
||||
const [thresholdLow, setThresholdLow] = useState<number>(20);
|
||||
const [thresholdMedium, setThresholdMedium] = useState<number>(50);
|
||||
const [thresholdHigh, setThresholdHigh] = useState<number>(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<void> => {
|
||||
try {
|
||||
const thresholds = await fetchMapColorThresholds();
|
||||
setThresholdLow(thresholds.threshold_low);
|
||||
setThresholdMedium(thresholds.threshold_medium);
|
||||
setThresholdHigh(thresholds.threshold_high);
|
||||
} catch (err) {
|
||||
if (mapThresholdError) {
|
||||
// Silently fall back to defaults if fetch fails
|
||||
console.warn("Failed to load map color thresholds:", err);
|
||||
console.warn("Failed to load map color thresholds:", mapThresholdError);
|
||||
}
|
||||
};
|
||||
void loadThresholds();
|
||||
}, []);
|
||||
}, [mapThresholdError]);
|
||||
|
||||
/** Bans visible in the companion table (filtered by selected country). */
|
||||
const visibleBans = useMemo(() => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string>("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<TimezoneContextValue>(() => ({ timezone }), [timezone]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user