Files
BanGUI/frontend/src/hooks/useMapData.ts
Lukas ffaa14f864 Switch dashboard/map/history views to archive source for long-term data
Update fail2ban dbpurgeage to 648000 and history sync backfill/pagination for archive-based 7.5 day history.
2026-04-05 20:21:54 +02:00

119 lines
3.7 KiB
TypeScript

/**
* `useMapData` hook — fetches and manages ban-by-country data.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByCountry } from "../api/map";
import { handleFetchError } from "../utils/fetchError";
import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map";
import type { BanOriginFilter } from "../types/ban";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Debounce delay in milliseconds before firing a fetch after filter changes. */
const DEBOUNCE_MS = 300;
// ---------------------------------------------------------------------------
// Return type
// ---------------------------------------------------------------------------
export interface UseMapDataResult {
/** Per-country ban counts (ISO alpha-2 → count). */
countries: Record<string, number>;
/** ISO alpha-2 → country name mapping. */
countryNames: Record<string, string>;
/** All ban records in the selected window. */
bans: MapBanItem[];
/** Total ban count. */
total: number;
/** True while a fetch is in flight. */
loading: boolean;
/** Error message or null. */
error: string | null;
/** Trigger a manual re-fetch. */
refresh: () => void;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useMapData(
range: TimeRange = "24h",
origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): UseMapDataResult {
const [data, setData] = useState<BansByCountryResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const load = useCallback((): void => {
// Cancel any pending debounce timer.
if (debounceRef.current != null) {
clearTimeout(debounceRef.current);
}
// Show loading immediately so the skeleton / spinner appears.
setLoading(true);
setError(null);
debounceRef.current = setTimeout((): void => {
// Abort any in-flight request from a previous filter selection.
abortRef.current?.abort();
abortRef.current = new AbortController();
fetchBansByCountry(range, origin, source)
.then((resp) => {
setData(resp);
})
.catch((err: unknown) => {
handleFetchError(err, setError, "Failed to fetch map data");
})
.finally((): void => {
setLoading(false);
});
}, DEBOUNCE_MS);
}, [range, origin, source]);
useEffect((): (() => void) => {
load();
return (): void => {
if (debounceRef.current != null) {
clearTimeout(debounceRef.current);
}
abortRef.current?.abort();
};
}, [load]);
return {
countries: data?.countries ?? {},
countryNames: data?.country_names ?? {},
bans: data?.bans ?? [],
total: data?.total ?? 0,
loading,
error,
refresh: load,
};
}
/**
* Test helper: returns arguments most recently used to call `useMapData`.
*
* This helper is only intended for test use with a mock implementation.
*/
export function getLastArgs(): { range: string; origin: string } {
throw new Error("getLastArgs is only available in tests with a mocked useMapData");
}
/**
* Test helper: mutates mocked map data state.
*
* This helper is only intended for test use with a mock implementation.
*/
export function setMapData(_: Partial<UseMapDataResult>): void {
throw new Error("setMapData is only available in tests with a mocked useMapData");
}