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
|
### 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`.
|
**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.
|
* 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 { fetchBanTrend } from "../api/dashboard";
|
||||||
import { handleFetchError } from "../utils/fetchError";
|
import { useListData } from "./useListData";
|
||||||
import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban";
|
import type { BanTrendBucket, BanOriginFilter, TimeRange, BanTrendResponse } from "../types/ban";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Return type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Return value shape for {@link useBanTrend}. */
|
/** Return value shape for {@link useBanTrend}. */
|
||||||
export interface UseBanTrendResult {
|
export interface UseBanTrendResult {
|
||||||
@@ -28,10 +24,6 @@ export interface UseBanTrendResult {
|
|||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Hook
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and expose ban trend data for the `BanTrendChart` component.
|
* Fetch and expose ban trend data for the `BanTrendChart` component.
|
||||||
*
|
*
|
||||||
@@ -44,44 +36,26 @@ export function useBanTrend(
|
|||||||
origin: BanOriginFilter,
|
origin: BanOriginFilter,
|
||||||
source: "fail2ban" | "archive" = "fail2ban",
|
source: "fail2ban" | "archive" = "fail2ban",
|
||||||
): UseBanTrendResult {
|
): UseBanTrendResult {
|
||||||
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
|
|
||||||
const [bucketSize, setBucketSize] = useState<string>("1h");
|
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 => {
|
const selector = useCallback((response: BanTrendResponse) => response.buckets, []);
|
||||||
abortRef.current?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
abortRef.current = controller;
|
|
||||||
|
|
||||||
setLoading(true);
|
const onSuccess = useCallback((response: BanTrendResponse) => {
|
||||||
setError(null);
|
setBucketSize(response.bucket_size);
|
||||||
|
}, []);
|
||||||
|
|
||||||
fetchBanTrend(timeRange, origin, source, controller.signal)
|
const { items: buckets, loading, error, refresh } = useListData<BanTrendResponse, BanTrendBucket>({
|
||||||
.then((data) => {
|
fetcher,
|
||||||
if (controller.signal.aborted) return;
|
selector,
|
||||||
setBuckets(data.buckets);
|
errorMessage: "Failed to fetch trend data",
|
||||||
setBucketSize(data.bucket_size);
|
onSuccess,
|
||||||
})
|
});
|
||||||
.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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return { buckets, bucketSize, loading, error, reload: refresh };
|
||||||
load();
|
|
||||||
return (): void => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
};
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
return { buckets, bucketSize, loading, error, reload: load };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,11 @@
|
|||||||
* Re-fetches automatically when `timeRange` or `origin` changes.
|
* 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 { fetchBansByCountry } from "../api/map";
|
||||||
import { handleFetchError } from "../utils/fetchError";
|
import { useListData } from "./useListData";
|
||||||
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban";
|
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||||
|
import type { BansByCountryResponse, MapBanItem } from "../types/map";
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Return type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Return value shape for {@link useDashboardCountryData}. */
|
/** Return value shape for {@link useDashboardCountryData}. */
|
||||||
export interface UseDashboardCountryDataResult {
|
export interface UseDashboardCountryDataResult {
|
||||||
@@ -23,7 +20,7 @@ export interface UseDashboardCountryDataResult {
|
|||||||
/** ISO alpha-2 country code → human-readable country name. */
|
/** ISO alpha-2 country code → human-readable country name. */
|
||||||
countryNames: Record<string, string>;
|
countryNames: Record<string, string>;
|
||||||
/** All ban records in the selected window. */
|
/** All ban records in the selected window. */
|
||||||
bans: DashboardBanItem[];
|
bans: MapBanItem[];
|
||||||
/** Total ban count in the window. */
|
/** Total ban count in the window. */
|
||||||
total: number;
|
total: number;
|
||||||
/** True while a fetch is in flight. */
|
/** True while a fetch is in flight. */
|
||||||
@@ -34,10 +31,6 @@ export interface UseDashboardCountryDataResult {
|
|||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Hook
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and expose ban-by-country data for dashboard charts.
|
* Fetch and expose ban-by-country data for dashboard charts.
|
||||||
*
|
*
|
||||||
@@ -52,47 +45,28 @@ export function useDashboardCountryData(
|
|||||||
): UseDashboardCountryDataResult {
|
): UseDashboardCountryDataResult {
|
||||||
const [countries, setCountries] = useState<Record<string, number>>({});
|
const [countries, setCountries] = useState<Record<string, number>>({});
|
||||||
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
|
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
|
||||||
const [bans, setBans] = useState<DashboardBanItem[]>([]);
|
|
||||||
const [total, setTotal] = useState<number>(0);
|
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 => {
|
const selector = useCallback((response: BansByCountryResponse) => response.bans, []);
|
||||||
// Abort any in-flight request.
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
abortRef.current = controller;
|
|
||||||
|
|
||||||
setLoading(true);
|
const onSuccess = useCallback((response: BansByCountryResponse) => {
|
||||||
setError(null);
|
setCountries(response.countries);
|
||||||
|
setCountryNames(response.country_names);
|
||||||
|
setTotal(response.total);
|
||||||
|
}, []);
|
||||||
|
|
||||||
fetchBansByCountry(timeRange, origin, source, undefined, controller.signal)
|
const { items: bans, loading, error, refresh } = useListData<BansByCountryResponse, MapBanItem>({
|
||||||
.then((data) => {
|
fetcher,
|
||||||
if (controller.signal.aborted) return;
|
selector,
|
||||||
setCountries(data.countries);
|
errorMessage: "Failed to fetch dashboard country data",
|
||||||
setCountryNames(data.country_names);
|
onSuccess,
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return { countries, countryNames, bans, total, loading, error, reload: refresh };
|
||||||
load();
|
|
||||||
return (): void => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
};
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
return { countries, countryNames, bans, total, loading, error, reload: load };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fetchHistory, fetchIpHistory } from "../api/history";
|
import { fetchHistory, fetchIpHistory } from "../api/history";
|
||||||
import { handleFetchError } from "../utils/fetchError";
|
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";
|
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// useHistory — paginated list
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface UseHistoryResult {
|
export interface UseHistoryResult {
|
||||||
items: HistoryBanItem[];
|
items: HistoryBanItem[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -43,50 +40,38 @@ export function useHistory(
|
|||||||
ip?: string,
|
ip?: string,
|
||||||
source: "fail2ban" | "archive" = "archive",
|
source: "fail2ban" | "archive" = "archive",
|
||||||
): UseHistoryResult {
|
): UseHistoryResult {
|
||||||
const [items, setItems] = useState<HistoryBanItem[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [currentPage, setCurrentPage] = useState(page);
|
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 => {
|
const fetcher = useCallback(
|
||||||
abortRef.current?.abort();
|
(signal: AbortSignal) =>
|
||||||
abortRef.current = new AbortController();
|
fetchHistory(
|
||||||
setLoading(true);
|
{
|
||||||
setError(null);
|
page: currentPage,
|
||||||
|
page_size: pageSize,
|
||||||
|
range,
|
||||||
|
origin,
|
||||||
|
jail,
|
||||||
|
ip,
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
[currentPage, pageSize, range, origin, jail, ip, source],
|
||||||
|
);
|
||||||
|
|
||||||
fetchHistory(
|
const selector = useCallback((response: HistoryListResponse) => response.items, []);
|
||||||
{
|
|
||||||
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 onSuccess = useCallback((response: HistoryListResponse) => {
|
||||||
|
setTotal(response.total);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect((): (() => void) => {
|
const { items, loading, error, refresh } = useListData<HistoryListResponse, HistoryBanItem>({
|
||||||
load();
|
fetcher,
|
||||||
return (): void => {
|
selector,
|
||||||
abortRef.current?.abort();
|
errorMessage: "Failed to fetch history",
|
||||||
};
|
onSuccess,
|
||||||
}, [load]);
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
@@ -95,7 +80,7 @@ export function useHistory(
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
setPage: setCurrentPage,
|
setPage: setCurrentPage,
|
||||||
refresh: load,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +95,12 @@ export interface UseIpHistoryResult {
|
|||||||
refresh: () => void;
|
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 {
|
export function useIpHistory(ip: string): UseIpHistoryResult {
|
||||||
const [detail, setDetail] = useState<IpDetailResponse | null>(null);
|
const [detail, setDetail] = useState<IpDetailResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -124,13 +115,19 @@ export function useIpHistory(ip: string): UseIpHistoryResult {
|
|||||||
|
|
||||||
fetchIpHistory(ip, abortRef.current.signal)
|
fetchIpHistory(ip, abortRef.current.signal)
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setDetail(resp);
|
if (!abortRef.current?.signal.aborted) {
|
||||||
|
setDetail(resp);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.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 => {
|
.finally((): void => {
|
||||||
setLoading(false);
|
if (!abortRef.current?.signal.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [ip]);
|
}, [ip]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* React hook for loading and controlling the jail overview list.
|
* React hook for loading and controlling the jail overview list.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import {
|
import {
|
||||||
fetchJails,
|
fetchJails,
|
||||||
reloadAllJails,
|
reloadAllJails,
|
||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
startJail,
|
startJail,
|
||||||
stopJail,
|
stopJail,
|
||||||
} from "../api/jails";
|
} from "../api/jails";
|
||||||
import { handleFetchError } from "../utils/fetchError";
|
import { useListData } from "./useListData";
|
||||||
import type { JailSummary } from "../types/jail";
|
import type { JailSummary, JailListResponse } from "../types/jail";
|
||||||
|
|
||||||
export interface UseJailsResult {
|
export interface UseJailsResult {
|
||||||
jails: JailSummary[];
|
jails: JailSummary[];
|
||||||
@@ -31,83 +31,64 @@ export interface UseJailsResult {
|
|||||||
* Fetch and manage the jail overview list.
|
* Fetch and manage the jail overview list.
|
||||||
*/
|
*/
|
||||||
export function useJails(): UseJailsResult {
|
export function useJails(): UseJailsResult {
|
||||||
const [jails, setJails] = useState<JailSummary[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
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(() => {
|
const fetcher = useCallback(
|
||||||
abortRef.current?.abort();
|
(signal: AbortSignal) => fetchJails(signal),
|
||||||
const ctrl = new AbortController();
|
[],
|
||||||
abortRef.current = ctrl;
|
);
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
fetchJails(ctrl.signal)
|
const selector = useCallback((response: JailListResponse) => response.jails, []);
|
||||||
.then((res) => {
|
|
||||||
if (!ctrl.signal.aborted) {
|
const onSuccess = useCallback((response: JailListResponse) => {
|
||||||
setJails(res.jails);
|
setTotal(response.total);
|
||||||
setTotal(res.total);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
if (!ctrl.signal.aborted) {
|
|
||||||
handleFetchError(err, setError, "Failed to load jails");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!ctrl.signal.aborted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const { items: jails, loading, error, refresh } = useListData<JailListResponse, JailSummary>({
|
||||||
load();
|
fetcher,
|
||||||
return (): void => {
|
selector,
|
||||||
abortRef.current?.abort();
|
errorMessage: "Failed to load jails",
|
||||||
};
|
onSuccess,
|
||||||
}, [load]);
|
});
|
||||||
|
|
||||||
const startJailMemo = useCallback(
|
const startJailMemo = useCallback(
|
||||||
async (name: string): Promise<void> => {
|
async (name: string): Promise<void> => {
|
||||||
await startJail(name);
|
await startJail(name);
|
||||||
load();
|
refresh();
|
||||||
},
|
},
|
||||||
[load],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stopJailMemo = useCallback(
|
const stopJailMemo = useCallback(
|
||||||
async (name: string): Promise<void> => {
|
async (name: string): Promise<void> => {
|
||||||
await stopJail(name);
|
await stopJail(name);
|
||||||
load();
|
refresh();
|
||||||
},
|
},
|
||||||
[load],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reloadJailMemo = useCallback(
|
const reloadJailMemo = useCallback(
|
||||||
async (name: string): Promise<void> => {
|
async (name: string): Promise<void> => {
|
||||||
await reloadJail(name);
|
await reloadJail(name);
|
||||||
load();
|
refresh();
|
||||||
},
|
},
|
||||||
[load],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setIdleMemo = useCallback(
|
const setIdleMemo = useCallback(
|
||||||
(name: string, on: boolean): Promise<void> =>
|
(name: string, on: boolean): Promise<void> =>
|
||||||
setJailIdle(name, on).then(() => {
|
setJailIdle(name, on).then(() => {
|
||||||
load();
|
refresh();
|
||||||
}),
|
}),
|
||||||
[load],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reloadAllMemo = useCallback(
|
const reloadAllMemo = useCallback(
|
||||||
(): Promise<void> =>
|
(): Promise<void> =>
|
||||||
reloadAllJails().then(() => {
|
reloadAllJails().then(() => {
|
||||||
load();
|
refresh();
|
||||||
}),
|
}),
|
||||||
[load],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -115,7 +96,7 @@ export function useJails(): UseJailsResult {
|
|||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refresh: load,
|
refresh,
|
||||||
startJail: startJailMemo,
|
startJail: startJailMemo,
|
||||||
stopJail: stopJailMemo,
|
stopJail: stopJailMemo,
|
||||||
setIdle: setIdleMemo,
|
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.
|
* 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 { fetchServerStatus } from "../api/dashboard";
|
||||||
import { handleFetchError } from "../utils/fetchError";
|
import { usePolledData } from "./usePolledData";
|
||||||
import type { ServerStatus } from "../types/server";
|
import type { ServerStatus, ServerStatusResponse } from "../types/server";
|
||||||
|
|
||||||
/** How often to poll the status endpoint (milliseconds). */
|
/** How often to poll the status endpoint (milliseconds). */
|
||||||
const POLL_INTERVAL_MS = 30_000;
|
const POLL_INTERVAL_MS = 30_000;
|
||||||
@@ -32,71 +32,20 @@ export interface UseServerStatusResult {
|
|||||||
* @returns Current status, loading state, error, and a `refresh` callback.
|
* @returns Current status, loading state, error, and a `refresh` callback.
|
||||||
*/
|
*/
|
||||||
export function useServerStatus(): UseServerStatusResult {
|
export function useServerStatus(): UseServerStatusResult {
|
||||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
const fetcher = useCallback(
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
(signal: AbortSignal) => fetchServerStatus(signal),
|
||||||
const [error, setError] = useState<string | null>(null);
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Use a ref so the fetch function identity is stable.
|
const selector = useCallback((response: ServerStatusResponse) => response.status, []);
|
||||||
const fetchRef = useRef<() => Promise<void>>(async () => Promise.resolve());
|
|
||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const { data: status, loading, error, refresh } = usePolledData<ServerStatusResponse, ServerStatus>({
|
||||||
|
fetcher,
|
||||||
const doFetch = useCallback(async (): Promise<void> => {
|
selector,
|
||||||
abortRef.current?.abort();
|
errorMessage: "Failed to fetch server status",
|
||||||
const controller = new AbortController();
|
pollInterval: POLL_INTERVAL_MS,
|
||||||
abortRef.current = controller;
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
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]);
|
|
||||||
|
|
||||||
return { status, loading, error, refresh };
|
return { status, loading, error, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user