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:
@@ -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`.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
115
frontend/src/hooks/usePolledData.ts
Normal file
115
frontend/src/hooks/usePolledData.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user