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

@@ -5,13 +5,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchHistory, fetchIpHistory } from "../api/history";
import { handleFetchError } from "../utils/fetchError";
import type { HistoryBanItem, IpDetailResponse } from "../types/history";
import { useListData } from "./useListData";
import type { HistoryBanItem, IpDetailResponse, HistoryListResponse } from "../types/history";
import type { BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// useHistory — paginated list
// ---------------------------------------------------------------------------
export interface UseHistoryResult {
items: HistoryBanItem[];
total: number;
@@ -43,50 +40,38 @@ export function useHistory(
ip?: string,
source: "fail2ban" | "archive" = "archive",
): UseHistoryResult {
const [items, setItems] = useState<HistoryBanItem[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(page);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setError(null);
const fetcher = useCallback(
(signal: AbortSignal) =>
fetchHistory(
{
page: currentPage,
page_size: pageSize,
range,
origin,
jail,
ip,
source,
},
signal,
),
[currentPage, pageSize, range, origin, jail, ip, source],
);
fetchHistory(
{
page: currentPage,
page_size: pageSize,
range,
origin,
jail,
ip,
source,
},
abortRef.current.signal,
)
.then((resp) => {
setItems(resp.items);
setTotal(resp.total);
})
.catch((err: unknown) => {
handleFetchError(err, setError, "Failed to fetch history");
})
.finally((): void => {
setLoading(false);
});
}, [currentPage, pageSize, range, origin, jail, ip, source]);
const selector = useCallback((response: HistoryListResponse) => response.items, []);
const onSuccess = useCallback((response: HistoryListResponse) => {
setTotal(response.total);
}, []);
useEffect((): (() => void) => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const { items, loading, error, refresh } = useListData<HistoryListResponse, HistoryBanItem>({
fetcher,
selector,
errorMessage: "Failed to fetch history",
onSuccess,
});
return {
items,
@@ -95,7 +80,7 @@ export function useHistory(
loading,
error,
setPage: setCurrentPage,
refresh: load,
refresh,
};
}
@@ -110,6 +95,12 @@ export interface UseIpHistoryResult {
refresh: () => void;
}
/**
* Fetch and manage IP detail history.
*
* @param ip - IP address to fetch history for
* @returns IP detail response, loading state, error, and refresh callback
*/
export function useIpHistory(ip: string): UseIpHistoryResult {
const [detail, setDetail] = useState<IpDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
@@ -124,13 +115,19 @@ export function useIpHistory(ip: string): UseIpHistoryResult {
fetchIpHistory(ip, abortRef.current.signal)
.then((resp) => {
setDetail(resp);
if (!abortRef.current?.signal.aborted) {
setDetail(resp);
}
})
.catch((err: unknown) => {
handleFetchError(err, setError, "Failed to fetch IP history");
if (!abortRef.current?.signal.aborted) {
handleFetchError(err, setError, "Failed to fetch IP history");
}
})
.finally((): void => {
setLoading(false);
if (!abortRef.current?.signal.aborted) {
setLoading(false);
}
});
}, [ip]);