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
|
#### 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.
|
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly.
|
||||||
|
|
||||||
**Files affected:**
|
**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
|
#### 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.
|
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly.
|
||||||
|
|
||||||
**Files affected:**
|
**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
|
#### 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.
|
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly.
|
||||||
|
|
||||||
**Files affected:**
|
**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
|
#### 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.
|
**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:**
|
**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
|
#### 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.
|
**Violated rule:** Refactoring.md §3.2 — Components must not call API functions.
|
||||||
|
|
||||||
**Files affected (all in `frontend/src/components/config/`):**
|
**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
|
#### 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.
|
**Violated rule:** Refactoring.md §3.2 — Components must not contain a `useEffect` that calls an API function.
|
||||||
|
|
||||||
**Files affected:**
|
**Files affected:**
|
||||||
@@ -359,6 +371,8 @@ For each component listed:
|
|||||||
|
|
||||||
#### TASK F-8 — Move `ServerTab` direct API calls into hooks
|
#### TASK F-8 — Move `ServerTab` direct API calls into hooks
|
||||||
|
|
||||||
|
**Status:** Completed ✅
|
||||||
|
|
||||||
**Violated rule:** Refactoring.md §3.2 — Components must not call API functions.
|
**Violated rule:** Refactoring.md §3.2 — Components must not call API functions.
|
||||||
|
|
||||||
**Files affected:**
|
**Files affected:**
|
||||||
@@ -380,6 +394,8 @@ For each component listed:
|
|||||||
|
|
||||||
#### TASK F-9 — Move `TimezoneProvider` API call into a hook
|
#### 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/`.
|
**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:**
|
**Files affected:**
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
* While the status is loading a full-screen spinner is shown.
|
* While the status is loading a full-screen spinner is shown.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { Spinner } from "@fluentui/react-components";
|
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 {
|
interface SetupGuardProps {
|
||||||
/** The protected content to render when setup is complete. */
|
/** The protected content to render when setup is complete. */
|
||||||
@@ -24,25 +25,9 @@ interface SetupGuardProps {
|
|||||||
* Redirects to `/setup` if setup is still pending.
|
* Redirects to `/setup` if setup is still pending.
|
||||||
*/
|
*/
|
||||||
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const { status, loading } = useSetup();
|
||||||
|
|
||||||
useEffect(() => {
|
if (loading) {
|
||||||
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") {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -57,7 +42,7 @@ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "pending") {
|
if (!status?.completed) {
|
||||||
return <Navigate to="/setup" replace />;
|
return <Navigate to="/setup" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,10 @@ import {
|
|||||||
ArrowSync24Regular,
|
ArrowSync24Regular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { ApiError } from "../../api/client";
|
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 { useServerSettings } from "../../hooks/useConfig";
|
||||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||||
import {
|
import { useMapColorThresholds } from "../../hooks/useMapColorThresholds";
|
||||||
fetchMapColorThresholds,
|
|
||||||
updateMapColorThresholds,
|
|
||||||
reloadConfig,
|
|
||||||
restartFail2Ban,
|
|
||||||
} from "../../api/config";
|
|
||||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||||
import { ServerHealthSection } from "./ServerHealthSection";
|
import { ServerHealthSection } from "./ServerHealthSection";
|
||||||
import { useConfigStyles } from "./configStyles";
|
import { useConfigStyles } from "./configStyles";
|
||||||
@@ -48,7 +43,7 @@ const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
|
|||||||
*/
|
*/
|
||||||
export function ServerTab(): React.JSX.Element {
|
export function ServerTab(): React.JSX.Element {
|
||||||
const styles = useConfigStyles();
|
const styles = useConfigStyles();
|
||||||
const { settings, loading, error, updateSettings, flush } =
|
const { settings, loading, error, updateSettings, flush, reload, restart } =
|
||||||
useServerSettings();
|
useServerSettings();
|
||||||
const [logLevel, setLogLevel] = useState("");
|
const [logLevel, setLogLevel] = useState("");
|
||||||
const [logTarget, setLogTarget] = useState("");
|
const [logTarget, setLogTarget] = useState("");
|
||||||
@@ -62,11 +57,15 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
const [isRestarting, setIsRestarting] = useState(false);
|
const [isRestarting, setIsRestarting] = useState(false);
|
||||||
|
|
||||||
// Map color thresholds
|
// 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 [mapThresholdHigh, setMapThresholdHigh] = useState("");
|
||||||
const [mapThresholdMedium, setMapThresholdMedium] = useState("");
|
const [mapThresholdMedium, setMapThresholdMedium] = useState("");
|
||||||
const [mapThresholdLow, setMapThresholdLow] = useState("");
|
const [mapThresholdLow, setMapThresholdLow] = useState("");
|
||||||
const [mapLoadError, setMapLoadError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const effectiveLogLevel = logLevel || settings?.log_level || "";
|
const effectiveLogLevel = logLevel || settings?.log_level || "";
|
||||||
const effectiveLogTarget = logTarget || settings?.log_target || "";
|
const effectiveLogTarget = logTarget || settings?.log_target || "";
|
||||||
@@ -105,11 +104,11 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [flush]);
|
}, [flush]);
|
||||||
|
|
||||||
const handleReload = useCallback(async () => {
|
const handleReload = async (): Promise<void> => {
|
||||||
setIsReloading(true);
|
setIsReloading(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
await reloadConfig();
|
await reload();
|
||||||
setMsg({ text: "fail2ban reloaded successfully", ok: true });
|
setMsg({ text: "fail2ban reloaded successfully", ok: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setMsg({
|
setMsg({
|
||||||
@@ -119,13 +118,13 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsReloading(false);
|
setIsReloading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRestart = useCallback(async () => {
|
const handleRestart = async (): Promise<void> => {
|
||||||
setIsRestarting(true);
|
setIsRestarting(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
await restartFail2Ban();
|
await restart();
|
||||||
setMsg({ text: "fail2ban restart initiated", ok: true });
|
setMsg({ text: "fail2ban restart initiated", ok: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setMsg({
|
setMsg({
|
||||||
@@ -135,27 +134,15 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsRestarting(false);
|
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(() => {
|
useEffect(() => {
|
||||||
void loadMapThresholds();
|
if (!mapThresholds) return;
|
||||||
}, [loadMapThresholds]);
|
|
||||||
|
setMapThresholdHigh(String(mapThresholds.threshold_high));
|
||||||
|
setMapThresholdMedium(String(mapThresholds.threshold_medium));
|
||||||
|
setMapThresholdLow(String(mapThresholds.threshold_low));
|
||||||
|
}, [mapThresholds]);
|
||||||
|
|
||||||
// Map threshold validation and auto-save.
|
// Map threshold validation and auto-save.
|
||||||
const mapHigh = Number(mapThresholdHigh);
|
const mapHigh = Number(mapThresholdHigh);
|
||||||
@@ -190,9 +177,10 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
|
|
||||||
const saveMapThresholds = useCallback(
|
const saveMapThresholds = useCallback(
|
||||||
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
||||||
await updateMapColorThresholds(payload);
|
await updateMapThresholds(payload);
|
||||||
|
await refreshMapThresholds();
|
||||||
},
|
},
|
||||||
[],
|
[refreshMapThresholds, updateMapThresholds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } =
|
const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } =
|
||||||
@@ -332,10 +320,10 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map Color Thresholds section */}
|
{/* Map Color Thresholds section */}
|
||||||
{mapLoadError ? (
|
{mapThresholdsError ? (
|
||||||
<div className={styles.sectionCard}>
|
<div className={styles.sectionCard}>
|
||||||
<MessageBar intent="error">
|
<MessageBar intent="error">
|
||||||
<MessageBarBody>{mapLoadError}</MessageBarBody>
|
<MessageBarBody>{mapThresholdsError}</MessageBarBody>
|
||||||
</MessageBar>
|
</MessageBar>
|
||||||
</div>
|
</div>
|
||||||
) : mapThresholds ? (
|
) : mapThresholds ? (
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
* remains fast even when a jail contains thousands of banned IPs.
|
* remains fast even when a jail contains thousands of banned IPs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -40,17 +39,12 @@ import {
|
|||||||
DismissRegular,
|
DismissRegular,
|
||||||
SearchRegular,
|
SearchRegular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { fetchJailBannedIps, unbanIp } from "../../api/jails";
|
|
||||||
import type { ActiveBan } from "../../types/jail";
|
import type { ActiveBan } from "../../types/jail";
|
||||||
import { ApiError } from "../../api/client";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Debounce delay in milliseconds for the search input. */
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
|
||||||
|
|
||||||
/** Available page-size options. */
|
/** Available page-size options. */
|
||||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
|
||||||
|
|
||||||
@@ -229,8 +223,19 @@ const columns: TableColumnDefinition<BanRow>[] = [
|
|||||||
|
|
||||||
/** Props for {@link BannedIpsSection}. */
|
/** Props for {@link BannedIpsSection}. */
|
||||||
export interface BannedIpsSectionProps {
|
export interface BannedIpsSectionProps {
|
||||||
/** The jail name whose banned IPs are displayed. */
|
items: ActiveBan[];
|
||||||
jailName: string;
|
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}
|
* @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 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) => ({
|
const rows: BanRow[] = items.map((ban) => ({
|
||||||
ban,
|
ban,
|
||||||
onUnban: handleUnban,
|
onUnban,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
|
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
|
||||||
@@ -335,7 +285,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
|||||||
size="small"
|
size="small"
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
icon={<ArrowClockwiseRegular />}
|
icon={<ArrowClockwiseRegular />}
|
||||||
onClick={load}
|
onClick={onRefresh}
|
||||||
aria-label="Refresh banned IPs"
|
aria-label="Refresh banned IPs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +300,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
|||||||
placeholder="e.g. 192.168"
|
placeholder="e.g. 192.168"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(_, d) => {
|
onChange={(_, d) => {
|
||||||
setSearch(d.value);
|
onSearch(d.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -420,8 +370,8 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
|||||||
onOptionSelect={(_, d) => {
|
onOptionSelect={(_, d) => {
|
||||||
const newSize = Number(d.optionValue);
|
const newSize = Number(d.optionValue);
|
||||||
if (!Number.isNaN(newSize)) {
|
if (!Number.isNaN(newSize)) {
|
||||||
setPageSize(newSize);
|
onPageSizeChange(newSize);
|
||||||
setPage(1);
|
onPageChange(1);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ minWidth: "80px" }}
|
style={{ minWidth: "80px" }}
|
||||||
@@ -445,7 +395,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
|||||||
icon={<ChevronLeftRegular />}
|
icon={<ChevronLeftRegular />}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPage((p) => Math.max(1, p - 1));
|
onPageChange(Math.max(1, page - 1));
|
||||||
}}
|
}}
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
/>
|
/>
|
||||||
@@ -455,7 +405,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
|||||||
icon={<ChevronRightRegular />}
|
icon={<ChevronRightRegular />}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPage((p) => p + 1);
|
onPageChange(page + 1);
|
||||||
}}
|
}}
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,52 +1,11 @@
|
|||||||
/**
|
import { describe, it, expect, vi } from "vitest";
|
||||||
* Tests for the `BannedIpsSection` component.
|
import { render, screen } from "@testing-library/react";
|
||||||
*
|
|
||||||
* Verifies:
|
|
||||||
* - Renders the section header and total count badge.
|
|
||||||
* - Shows a spinner while loading.
|
|
||||||
* - Renders a table with IP rows on success.
|
|
||||||
* - Shows an empty-state message when there are no banned IPs.
|
|
||||||
* - Displays an error message bar when the API call fails.
|
|
||||||
* - Search input re-fetches with the search parameter after debounce.
|
|
||||||
* - Unban button calls `unbanIp` and refreshes the list.
|
|
||||||
* - Pagination buttons are shown and change the page.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
import { BannedIpsSection } from "../BannedIpsSection";
|
import { BannedIpsSection, type BannedIpsSectionProps } from "../BannedIpsSection";
|
||||||
import type { JailBannedIpsResponse } from "../../../types/jail";
|
import type { ActiveBan } from "../../../types/jail";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
function makeBan(ip: string): ActiveBan {
|
||||||
// Module mocks
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const { mockFetchJailBannedIps, mockUnbanIp } = vi.hoisted(() => ({
|
|
||||||
mockFetchJailBannedIps: vi.fn<
|
|
||||||
(
|
|
||||||
jailName: string,
|
|
||||||
page?: number,
|
|
||||||
pageSize?: number,
|
|
||||||
search?: string,
|
|
||||||
) => Promise<JailBannedIpsResponse>
|
|
||||||
>(),
|
|
||||||
mockUnbanIp: vi.fn<
|
|
||||||
(ip: string, jail?: string) => Promise<{ message: string; jail: string }>
|
|
||||||
>(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../api/jails", () => ({
|
|
||||||
fetchJailBannedIps: mockFetchJailBannedIps,
|
|
||||||
unbanIp: mockUnbanIp,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fixtures
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeBan(ip: string) {
|
|
||||||
return {
|
return {
|
||||||
ip,
|
ip,
|
||||||
jail: "sshd",
|
jail: "sshd",
|
||||||
@@ -57,195 +16,65 @@ function makeBan(ip: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeResponse(
|
function renderWithProps(props: Partial<BannedIpsSectionProps> = {}) {
|
||||||
ips: string[] = ["1.2.3.4", "5.6.7.8"],
|
const defaults: BannedIpsSectionProps = {
|
||||||
total = 2,
|
items: [makeBan("1.2.3.4"), makeBan("5.6.7.8")],
|
||||||
): JailBannedIpsResponse {
|
total: 2,
|
||||||
return {
|
|
||||||
items: ips.map(makeBan),
|
|
||||||
total,
|
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 25,
|
pageSize: 25,
|
||||||
};
|
search: "",
|
||||||
}
|
loading: false,
|
||||||
|
error: null,
|
||||||
const EMPTY_RESPONSE: JailBannedIpsResponse = {
|
opError: null,
|
||||||
items: [],
|
onSearch: vi.fn(),
|
||||||
total: 0,
|
onPageChange: vi.fn(),
|
||||||
page: 1,
|
onPageSizeChange: vi.fn(),
|
||||||
page_size: 25,
|
onRefresh: vi.fn(),
|
||||||
|
onUnban: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function renderSection(jailName = "sshd") {
|
|
||||||
return render(
|
return render(
|
||||||
<FluentProvider theme={webLightTheme}>
|
<FluentProvider theme={webLightTheme}>
|
||||||
<BannedIpsSection jailName={jailName} />
|
<BannedIpsSection {...defaults} {...props} />
|
||||||
</FluentProvider>,
|
</FluentProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("BannedIpsSection", () => {
|
describe("BannedIpsSection", () => {
|
||||||
beforeEach(() => {
|
it("shows the table rows and total count", () => {
|
||||||
vi.clearAllMocks();
|
renderWithProps();
|
||||||
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();
|
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("1.2.3.4")).toBeTruthy();
|
||||||
expect(screen.getByText("5.6.7.8")).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 () => {
|
it("shows error message when error is present", () => {
|
||||||
mockFetchJailBannedIps.mockResolvedValue(EMPTY_RESPONSE);
|
renderWithProps({ error: "Failed to load" });
|
||||||
renderSection();
|
expect(screen.getByText(/Failed to load/i)).toBeTruthy();
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.getByText("No IPs currently banned in this jail."),
|
|
||||||
).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an error message bar on API failure", async () => {
|
it("triggers onUnban for IP row button", async () => {
|
||||||
mockFetchJailBannedIps.mockRejectedValue(new Error("socket dead"));
|
const onUnban = vi.fn();
|
||||||
renderSection();
|
renderWithProps({ onUnban });
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/socket dead/i)).toBeTruthy();
|
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 () => {
|
it("calls onSearch when the search input changes", async () => {
|
||||||
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
|
const onSearch = vi.fn();
|
||||||
renderSection("nginx");
|
renderWithProps({ onSearch });
|
||||||
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");
|
const input = screen.getByPlaceholderText("e.g. 192.168");
|
||||||
act(() => {
|
await userEvent.type(input, "1.2.3");
|
||||||
fireEvent.change(input, { target: { value: "1.2.3" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Advance just past the 300ms debounce delay and flush promises.
|
expect(onSearch).toHaveBeenCalled();
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
fetchBlocklists,
|
||||||
fetchImportLog,
|
fetchImportLog,
|
||||||
fetchSchedule,
|
fetchSchedule,
|
||||||
|
previewBlocklist,
|
||||||
runImportNow,
|
runImportNow,
|
||||||
updateBlocklist,
|
updateBlocklist,
|
||||||
updateSchedule,
|
updateSchedule,
|
||||||
@@ -35,6 +36,7 @@ export interface UseBlocklistsReturn {
|
|||||||
createSource: (payload: BlocklistSourceCreate) => Promise<BlocklistSource>;
|
createSource: (payload: BlocklistSourceCreate) => Promise<BlocklistSource>;
|
||||||
updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise<BlocklistSource>;
|
updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise<BlocklistSource>;
|
||||||
removeSource: (id: number) => Promise<void>;
|
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));
|
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,
|
delIgnoreIp,
|
||||||
fetchActiveBans,
|
fetchActiveBans,
|
||||||
fetchJail,
|
fetchJail,
|
||||||
|
fetchJailBannedIps,
|
||||||
fetchJails,
|
fetchJails,
|
||||||
lookupIp,
|
lookupIp,
|
||||||
reloadAllJails,
|
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
|
// 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,
|
useRunImport,
|
||||||
useSchedule,
|
useSchedule,
|
||||||
} from "../hooks/useBlocklist";
|
} from "../hooks/useBlocklist";
|
||||||
import { previewBlocklist } from "../api/blocklist";
|
|
||||||
import type {
|
import type {
|
||||||
BlocklistSource,
|
BlocklistSource,
|
||||||
ImportRunResult,
|
ImportRunResult,
|
||||||
@@ -262,9 +261,10 @@ interface PreviewDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
source: BlocklistSource | null;
|
source: BlocklistSource | null;
|
||||||
onClose: () => void;
|
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 styles = useStyles();
|
||||||
const [data, setData] = useState<PreviewResponse | null>(null);
|
const [data, setData] = useState<PreviewResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -276,7 +276,7 @@ function PreviewDialog({ open, source, onClose }: PreviewDialogProps): React.JSX
|
|||||||
setData(null);
|
setData(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
previewBlocklist(source.id)
|
fetchPreview(source.id)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setData(result);
|
setData(result);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -285,7 +285,7 @@ function PreviewDialog({ open, source, onClose }: PreviewDialogProps): React.JSX
|
|||||||
setError(err instanceof Error ? err.message : "Failed to fetch preview");
|
setError(err instanceof Error ? err.message : "Failed to fetch preview");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [source]);
|
}, [source, fetchPreview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -400,7 +400,7 @@ interface SourcesSectionProps {
|
|||||||
|
|
||||||
function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps): React.JSX.Element {
|
function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { sources, loading, error, refresh, createSource, updateSource, removeSource } =
|
const { sources, loading, error, refresh, createSource, updateSource, removeSource, previewSource } =
|
||||||
useBlocklists();
|
useBlocklists();
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -410,7 +410,7 @@ function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps):
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
const [previewSource, setPreviewSource] = useState<BlocklistSource | null>(null);
|
const [previewSourceItem, setPreviewSourceItem] = useState<BlocklistSource | null>(null);
|
||||||
|
|
||||||
const openAdd = useCallback((): void => {
|
const openAdd = useCallback((): void => {
|
||||||
setDialogMode("add");
|
setDialogMode("add");
|
||||||
@@ -466,7 +466,7 @@ function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps):
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePreview = useCallback((source: BlocklistSource): void => {
|
const handlePreview = useCallback((source: BlocklistSource): void => {
|
||||||
setPreviewSource(source);
|
setPreviewSourceItem(source);
|
||||||
setPreviewOpen(true);
|
setPreviewOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -594,10 +594,11 @@ function SourcesSection({ onRunImport, runImportRunning }: SourcesSectionProps):
|
|||||||
|
|
||||||
<PreviewDialog
|
<PreviewDialog
|
||||||
open={previewOpen}
|
open={previewOpen}
|
||||||
source={previewSource}
|
source={previewSourceItem}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setPreviewOpen(false);
|
setPreviewOpen(false);
|
||||||
}}
|
}}
|
||||||
|
fetchPreview={previewSource}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
StopRegular,
|
StopRegular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
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 type { Jail } from "../types/jail";
|
||||||
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
|
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
|
||||||
|
|
||||||
@@ -574,6 +574,21 @@ export function JailDetailPage(): React.JSX.Element {
|
|||||||
const { name = "" } = useParams<{ name: string }>();
|
const { name = "" } = useParams<{ name: string }>();
|
||||||
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf, start, stop, reload, setIdle } =
|
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf, start, stop, reload, setIdle } =
|
||||||
useJailDetail(name);
|
useJailDetail(name);
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search,
|
||||||
|
loading: bannedLoading,
|
||||||
|
error: bannedError,
|
||||||
|
opError,
|
||||||
|
refresh: refreshBanned,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
setSearch,
|
||||||
|
unban,
|
||||||
|
} = useJailBannedIps(name);
|
||||||
|
|
||||||
if (loading && !jail) {
|
if (loading && !jail) {
|
||||||
return (
|
return (
|
||||||
@@ -618,7 +633,21 @@ export function JailDetailPage(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<JailInfoSection jail={jail} onRefresh={refresh} onStart={start} onStop={stop} onReload={reload} onSetIdle={setIdle} />
|
<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} />
|
<PatternsSection jail={jail} />
|
||||||
<BantimeEscalationSection jail={jail} />
|
<BantimeEscalationSection jail={jail} />
|
||||||
<IgnoreListSection
|
<IgnoreListSection
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-ic
|
|||||||
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
||||||
import { WorldMap } from "../components/WorldMap";
|
import { WorldMap } from "../components/WorldMap";
|
||||||
import { useMapData } from "../hooks/useMapData";
|
import { useMapData } from "../hooks/useMapData";
|
||||||
import { fetchMapColorThresholds } from "../api/config";
|
import { useMapColorThresholds } from "../hooks/useMapColorThresholds";
|
||||||
import type { TimeRange } from "../types/map";
|
import type { TimeRange } from "../types/map";
|
||||||
import type { BanOriginFilter } from "../types/ban";
|
import type { BanOriginFilter } from "../types/ban";
|
||||||
|
|
||||||
@@ -79,28 +79,25 @@ export function MapPage(): React.JSX.Element {
|
|||||||
const [range, setRange] = useState<TimeRange>("24h");
|
const [range, setRange] = useState<TimeRange>("24h");
|
||||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
|
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 } =
|
const { countries, countryNames, bans, total, loading, error, refresh } =
|
||||||
useMapData(range, originFilter);
|
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(() => {
|
useEffect(() => {
|
||||||
const loadThresholds = async (): Promise<void> => {
|
if (mapThresholdError) {
|
||||||
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
|
// 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);
|
||||||
}
|
}
|
||||||
};
|
}, [mapThresholdError]);
|
||||||
void loadThresholds();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/** Bans visible in the companion table (filtered by selected country). */
|
/** Bans visible in the companion table (filtered by selected country). */
|
||||||
const visibleBans = useMemo(() => {
|
const visibleBans = useMemo(() => {
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ const {
|
|||||||
// Mock the jail detail hook — tests control the returned state directly.
|
// Mock the jail detail hook — tests control the returned state directly.
|
||||||
vi.mock("../../hooks/useJails", () => ({
|
vi.mock("../../hooks/useJails", () => ({
|
||||||
useJailDetail: vi.fn(),
|
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.
|
// Mock API functions used by JailInfoSection control buttons to avoid side effects.
|
||||||
|
|||||||
@@ -9,15 +9,8 @@
|
|||||||
* always receive a safe fallback.
|
* always receive a safe fallback.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { createContext, useContext, useMemo } from "react";
|
||||||
createContext,
|
import { useTimezoneData } from "../hooks/useTimezoneData";
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { fetchTimezone } from "../api/setup";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Context definition
|
// Context definition
|
||||||
@@ -52,19 +45,7 @@ export interface TimezoneProviderProps {
|
|||||||
export function TimezoneProvider({
|
export function TimezoneProvider({
|
||||||
children,
|
children,
|
||||||
}: TimezoneProviderProps): React.JSX.Element {
|
}: TimezoneProviderProps): React.JSX.Element {
|
||||||
const [timezone, setTimezone] = useState<string>("UTC");
|
const { timezone } = useTimezoneData();
|
||||||
|
|
||||||
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 value = useMemo<TimezoneContextValue>(() => ({ timezone }), [timezone]);
|
const value = useMemo<TimezoneContextValue>(() => ({ timezone }), [timezone]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user