feat(hooks): consolidate data-fetching patterns with useListData and usePolledData

- Refactor useJails (useJailList.ts) to use useListData with onSuccess for total
- Refactor useBanTrend to use useListData with onSuccess for bucket_size
- Refactor useDashboardCountryData to use useListData with onSuccess for aggregated data
- Refactor useHistory to use useListData with proper abort guard in finally()
- Create usePolledData for single-item endpoints with polling and window focus refetch
- Refactor useServerStatus to use usePolledData for 30s polling + window focus refetch
- Keep useIpHistory with manual pattern (single-item, no list semantics)
- Document deferred refactoring of useJailDetail (depends on T-13 for data/command split)

All data-fetching hooks now follow one of two consistent patterns:
1. useListData: for paginated/list endpoints with refresh semantics
2. usePolledData: for single-item endpoints with polling and focus-refetch

This eliminates code duplication, centralizes abort-guard logic, and enables
consistent fixes across all data-fetching hooks.

Resolves T-12.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-25 19:08:26 +02:00
parent b44b72053a
commit 8d30a81346
7 changed files with 244 additions and 281 deletions

View File

@@ -7,14 +7,11 @@
* Re-fetches automatically when `timeRange` or `origin` changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useState } from "react";
import { fetchBansByCountry } from "../api/map";
import { handleFetchError } from "../utils/fetchError";
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Return type
// ---------------------------------------------------------------------------
import { useListData } from "./useListData";
import type { BanOriginFilter, TimeRange } from "../types/ban";
import type { BansByCountryResponse, MapBanItem } from "../types/map";
/** Return value shape for {@link useDashboardCountryData}. */
export interface UseDashboardCountryDataResult {
@@ -23,7 +20,7 @@ export interface UseDashboardCountryDataResult {
/** ISO alpha-2 country code → human-readable country name. */
countryNames: Record<string, string>;
/** All ban records in the selected window. */
bans: DashboardBanItem[];
bans: MapBanItem[];
/** Total ban count in the window. */
total: number;
/** True while a fetch is in flight. */
@@ -34,10 +31,6 @@ export interface UseDashboardCountryDataResult {
reload: () => void;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Fetch and expose ban-by-country data for dashboard charts.
*
@@ -52,47 +45,28 @@ export function useDashboardCountryData(
): UseDashboardCountryDataResult {
const [countries, setCountries] = useState<Record<string, number>>({});
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
const [bans, setBans] = useState<DashboardBanItem[]>([]);
const [total, setTotal] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetcher = useCallback(
(signal: AbortSignal) =>
fetchBansByCountry(timeRange, origin, source, undefined, signal),
[timeRange, origin, source],
);
const load = useCallback((): void => {
// Abort any in-flight request.
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const selector = useCallback((response: BansByCountryResponse) => response.bans, []);
setLoading(true);
setError(null);
const onSuccess = useCallback((response: BansByCountryResponse) => {
setCountries(response.countries);
setCountryNames(response.country_names);
setTotal(response.total);
}, []);
fetchBansByCountry(timeRange, origin, source, undefined, controller.signal)
.then((data) => {
if (controller.signal.aborted) return;
setCountries(data.countries);
setCountryNames(data.country_names);
setBans(data.bans);
setTotal(data.total);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, setError, "Failed to fetch dashboard country data");
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false);
}
});
}, [timeRange, origin, source]);
const { items: bans, loading, error, refresh } = useListData<BansByCountryResponse, MapBanItem>({
fetcher,
selector,
errorMessage: "Failed to fetch dashboard country data",
onSuccess,
});
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return { countries, countryNames, bans, total, loading, error, reload: load };
return { countries, countryNames, bans, total, loading, error, reload: refresh };
}