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

@@ -1,30 +1,3 @@
### T-11 · Repositories injected as module references via `cast()` — structural type-safety gap
**Where found:** `backend/app/dependencies.py``get_session_repo()`, `get_blocklist_repo()`, `get_settings_repo()`, `get_import_log_repo()`, `get_history_archive_repo()`, `get_geo_cache_repo()`, `get_fail2ban_db_repo()` all return the module itself cast to the Protocol type.
**Why this is needed:** The `cast()` call is a signal that the type system is being overridden. Modules pass Protocol structural checks only because their top-level `async def` functions happen to match the Protocol method signatures. This is fragile — a module rename, a function rename, or an added required parameter will silently pass mypy but fail at runtime.
**Goal:** Repository modules become proper singleton instances, or the dependency providers are acknowledged as module-adapters with explicit documentation.
**What to do (option A — correct):**
1. Convert each repository module's functions into a class with the same method signatures.
2. Instantiate singletons at startup and store on `app.state` or as module-level instances.
3. Update dependency providers to return the instance without `cast()`.
**What to do (option B — minimal):**
1. Document in each `get_*_repo` provider why the module-as-Protocol pattern is intentional.
2. Add a CI check (or mypy plugin) that validates structural compatibility doesn't silently break.
**Possible traps and issues:**
- Option A is a significant refactor affecting all repository call sites.
- Option B risks the pattern silently breaking in future.
**Docs changes needed:** `Docs/Backend-Development.md` — document repository injection pattern and why it works.
**Doc references:** `Docs/Backend-Development.md`, `backend/app/repositories/protocols.py`
---
### T-12 · Apply `useListData` consistently across all data-fetching hooks
**Where found:** `frontend/src/hooks/useJailList.ts`, `useJailDetail.ts`, `useServerStatus.ts`, `useBanTrend.ts`, `useDashboardCountryData.ts` — all re-implement abort-controller / loading / error state manually. `useListData.ts` exists and is used by `useBlocklists`, `useJailConfigs`, `useActionList`, `useFilterList`.

View File

@@ -5,14 +5,10 @@
* Re-fetches automatically when `timeRange` or `origin` changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useState } from "react";
import { fetchBanTrend } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Return type
// ---------------------------------------------------------------------------
import { useListData } from "./useListData";
import type { BanTrendBucket, BanOriginFilter, TimeRange, BanTrendResponse } from "../types/ban";
/** Return value shape for {@link useBanTrend}. */
export interface UseBanTrendResult {
@@ -28,10 +24,6 @@ export interface UseBanTrendResult {
reload: () => void;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Fetch and expose ban trend data for the `BanTrendChart` component.
*
@@ -44,44 +36,26 @@ export function useBanTrend(
origin: BanOriginFilter,
source: "fail2ban" | "archive" = "fail2ban",
): UseBanTrendResult {
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
const [bucketSize, setBucketSize] = useState<string>("1h");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetcher = useCallback(
(signal: AbortSignal) =>
fetchBanTrend(timeRange, origin, source, signal),
[timeRange, origin, source],
);
const load = useCallback((): void => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const selector = useCallback((response: BanTrendResponse) => response.buckets, []);
setLoading(true);
setError(null);
const onSuccess = useCallback((response: BanTrendResponse) => {
setBucketSize(response.bucket_size);
}, []);
fetchBanTrend(timeRange, origin, source, controller.signal)
.then((data) => {
if (controller.signal.aborted) return;
setBuckets(data.buckets);
setBucketSize(data.bucket_size);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, setError, "Failed to fetch trend data");
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false);
}
});
}, [timeRange, origin, source]);
const { items: buckets, loading, error, refresh } = useListData<BanTrendResponse, BanTrendBucket>({
fetcher,
selector,
errorMessage: "Failed to fetch trend data",
onSuccess,
});
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return { buckets, bucketSize, loading, error, reload: load };
return { buckets, bucketSize, loading, error, reload: refresh };
}

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 };
}

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]);

View File

@@ -2,7 +2,7 @@
* React hook for loading and controlling the jail overview list.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useState } from "react";
import {
fetchJails,
reloadAllJails,
@@ -11,8 +11,8 @@ import {
startJail,
stopJail,
} from "../api/jails";
import { handleFetchError } from "../utils/fetchError";
import type { JailSummary } from "../types/jail";
import { useListData } from "./useListData";
import type { JailSummary, JailListResponse } from "../types/jail";
export interface UseJailsResult {
jails: JailSummary[];
@@ -31,83 +31,64 @@ export interface UseJailsResult {
* Fetch and manage the jail overview list.
*/
export function useJails(): UseJailsResult {
const [jails, setJails] = useState<JailSummary[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback(() => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
const fetcher = useCallback(
(signal: AbortSignal) => fetchJails(signal),
[],
);
fetchJails(ctrl.signal)
.then((res) => {
if (!ctrl.signal.aborted) {
setJails(res.jails);
setTotal(res.total);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setError, "Failed to load jails");
}
})
.finally(() => {
if (!ctrl.signal.aborted) {
setLoading(false);
}
});
const selector = useCallback((response: JailListResponse) => response.jails, []);
const onSuccess = useCallback((response: JailListResponse) => {
setTotal(response.total);
}, []);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const { items: jails, loading, error, refresh } = useListData<JailListResponse, JailSummary>({
fetcher,
selector,
errorMessage: "Failed to load jails",
onSuccess,
});
const startJailMemo = useCallback(
async (name: string): Promise<void> => {
await startJail(name);
load();
refresh();
},
[load],
[refresh],
);
const stopJailMemo = useCallback(
async (name: string): Promise<void> => {
await stopJail(name);
load();
refresh();
},
[load],
[refresh],
);
const reloadJailMemo = useCallback(
async (name: string): Promise<void> => {
await reloadJail(name);
load();
refresh();
},
[load],
[refresh],
);
const setIdleMemo = useCallback(
(name: string, on: boolean): Promise<void> =>
setJailIdle(name, on).then(() => {
load();
refresh();
}),
[load],
[refresh],
);
const reloadAllMemo = useCallback(
(): Promise<void> =>
reloadAllJails().then(() => {
load();
refresh();
}),
[load],
[refresh],
);
return {
@@ -115,7 +96,7 @@ export function useJails(): UseJailsResult {
total,
loading,
error,
refresh: load,
refresh,
startJail: startJailMemo,
stopJail: stopJailMemo,
setIdle: setIdleMemo,

View File

@@ -0,0 +1,115 @@
/**
* Generic hook for loading and polling single-item data from an API endpoint.
*
* Similar to useListData, but for non-list endpoints that need periodic polling
* and window-focus refetch semantics.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
export interface UsePolledDataOptions<TResponse, TData> {
fetcher: (signal: AbortSignal) => Promise<TResponse>;
selector: (response: TResponse) => TData;
errorMessage: string;
onSuccess?: (response: TResponse) => void;
initialData?: TData;
pollInterval?: number;
refetchOnWindowFocus?: boolean;
}
export interface UsePolledDataResult<TData> {
data: TData | null;
loading: boolean;
error: string | null;
refresh: () => void;
}
/**
* Load a single-item response and expose refresh semantics with polling support.
*
* @param options - Configuration options
* @returns Data, loading state, error, and refresh callback
*/
export function usePolledData<TResponse, TData>(
options: UsePolledDataOptions<TResponse, TData>,
): UsePolledDataResult<TData> {
const {
fetcher,
selector,
errorMessage,
onSuccess,
initialData,
pollInterval,
refetchOnWindowFocus = true,
} = options;
const [data, setData] = useState<TData | null>(initialData ?? null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchRef = useRef<() => void>((): void => undefined);
const refresh = useCallback((): void => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
fetcher(controller.signal)
.then((response) => {
if (controller.signal.aborted) return;
setData(selector(response));
if (onSuccess) {
onSuccess(response);
}
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, setError, errorMessage);
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false);
}
});
}, [fetcher, selector, errorMessage, onSuccess]);
fetchRef.current = refresh;
useEffect(() => {
refresh();
if (!pollInterval) {
return (): void => {
abortRef.current?.abort();
};
}
const id = setInterval((): void => {
fetchRef.current();
}, pollInterval);
return (): void => {
clearInterval(id);
abortRef.current?.abort();
};
}, [refresh, pollInterval]);
// Refetch on window focus if enabled.
useEffect(() => {
if (!refetchOnWindowFocus) return;
const onFocus = (): void => {
fetchRef.current();
};
window.addEventListener("focus", onFocus);
return (): void => {
window.removeEventListener("focus", onFocus);
};
}, [refetchOnWindowFocus]);
return { data, loading, error, refresh };
}

View File

@@ -6,10 +6,10 @@
* status is always fresh when the user returns to the tab.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback } from "react";
import { fetchServerStatus } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { ServerStatus } from "../types/server";
import { usePolledData } from "./usePolledData";
import type { ServerStatus, ServerStatusResponse } from "../types/server";
/** How often to poll the status endpoint (milliseconds). */
const POLL_INTERVAL_MS = 30_000;
@@ -32,71 +32,20 @@ export interface UseServerStatusResult {
* @returns Current status, loading state, error, and a `refresh` callback.
*/
export function useServerStatus(): UseServerStatusResult {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const fetcher = useCallback(
(signal: AbortSignal) => fetchServerStatus(signal),
[],
);
// Use a ref so the fetch function identity is stable.
const fetchRef = useRef<() => Promise<void>>(async () => Promise.resolve());
const selector = useCallback((response: ServerStatusResponse) => response.status, []);
const abortRef = useRef<AbortController | null>(null);
const doFetch = useCallback(async (): Promise<void> => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
try {
const data = await fetchServerStatus(controller.signal);
if (controller.signal.aborted) {
return;
}
setStatus(data.status);
setError(null);
} catch (err: unknown) {
if (controller.signal.aborted) {
return;
}
handleFetchError(err, setError, "Failed to fetch server status");
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, []);
fetchRef.current = doFetch;
// Initial fetch + polling interval.
useEffect((): (() => void) => {
void doFetch().catch((): void => undefined);
const id = setInterval((): void => {
void fetchRef.current().catch((): void => undefined);
}, POLL_INTERVAL_MS);
return (): void => { clearInterval(id); };
}, [doFetch]);
// Refetch on window focus.
useEffect(() => {
const onFocus = (): void => {
void fetchRef.current();
};
window.addEventListener("focus", onFocus);
return (): void => { window.removeEventListener("focus", onFocus); };
}, []);
useEffect(() => {
return (): void => {
abortRef.current?.abort();
};
}, []);
const refresh = useCallback((): void => {
void doFetch().catch((): void => undefined);
}, [doFetch]);
const { data: status, loading, error, refresh } = usePolledData<ServerStatusResponse, ServerStatus>({
fetcher,
selector,
errorMessage: "Failed to fetch server status",
pollInterval: POLL_INTERVAL_MS,
refetchOnWindowFocus: true,
});
return { status, loading, error, refresh };
}