fix(#16): Establish consistent API usage layering patterns
- Refactor useActiveBans to use useListData generic hook instead of inline state management - Refactor useBans to use useListData generic hook for consistency - Add comprehensive 'API Usage Layering' section to Web-Development.md documenting: - Tier 1: API Functions (pure wrappers around HTTP calls) - Tier 2: Reusable Generic Hooks (useListData, useConfigItem for common patterns) - Tier 3: Domain Hooks (compose Tier 2 with domain-specific logic) - Tier 4: Components (receive data/actions via props or context) - Document pattern for action callbacks with automatic data refresh - List anti-patterns to avoid for future consistency These changes improve composability, testability, and reduce code duplication by establishing a clear convention for data-fetching patterns across the frontend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2,10 +2,10 @@
|
||||
* React hook for live active ban list management.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { banIp, fetchActiveBans, unbanAllBans, unbanIp } from "../api/jails";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import type { ActiveBan, UnbanAllResponse } from "../types/jail";
|
||||
import { useListData } from "./useListData";
|
||||
import type { ActiveBan, UnbanAllResponse, ActiveBanListResponse } from "../types/jail";
|
||||
|
||||
export interface UseActiveBansResult {
|
||||
bans: ActiveBan[];
|
||||
@@ -20,69 +20,54 @@ export interface UseActiveBansResult {
|
||||
|
||||
/**
|
||||
* Fetch and manage the currently-active ban list.
|
||||
*
|
||||
* Provides operations to ban, unban, and refresh the active ban list.
|
||||
* Automatically re-fetches after state-mutating operations.
|
||||
*
|
||||
* @returns Active ban list, loading/error states, and action callbacks.
|
||||
*/
|
||||
export function useActiveBans(): UseActiveBansResult {
|
||||
const [bans, setBans] = useState<ActiveBan[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fetcher = useCallback((signal: AbortSignal) => fetchActiveBans(signal), []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const selector = useCallback((response: ActiveBanListResponse) => response.bans, []);
|
||||
|
||||
fetchActiveBans(ctrl.signal)
|
||||
.then((res) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setBans(res.bans);
|
||||
setTotal(res.total);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to fetch active bans");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
const { items: bans, loading, error, refresh } = useListData<ActiveBanListResponse, ActiveBan>({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to fetch active bans",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
const doBan = useCallback(
|
||||
async (jail: string, ip: string): Promise<void> => {
|
||||
await banIp(jail, ip);
|
||||
refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const doBan = useCallback(async (jail: string, ip: string): Promise<void> => {
|
||||
await banIp(jail, ip);
|
||||
load();
|
||||
}, [load]);
|
||||
const doUnban = useCallback(
|
||||
async (ip: string, jail?: string): Promise<void> => {
|
||||
await unbanIp(ip, jail);
|
||||
refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const doUnban = useCallback(async (ip: string, jail?: string): Promise<void> => {
|
||||
await unbanIp(ip, jail);
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const doUnbanAll = useCallback(async (): Promise<UnbanAllResponse> => {
|
||||
const result = await unbanAllBans();
|
||||
load();
|
||||
return result;
|
||||
}, [load]);
|
||||
const doUnbanAll = useCallback(
|
||||
async (): Promise<UnbanAllResponse> => {
|
||||
const result = await unbanAllBans();
|
||||
refresh();
|
||||
return result;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
return {
|
||||
bans,
|
||||
total,
|
||||
total: bans.length,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
refresh,
|
||||
banIp: doBan,
|
||||
unbanIp: doUnban,
|
||||
unbanAll: doUnbanAll,
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* `useBans` hook.
|
||||
*
|
||||
* Fetches and manages paginated ban-list data from the dashboard endpoint.
|
||||
* Re-fetches automatically when `timeRange` or `page` changes.
|
||||
* Re-fetches automatically when `timeRange`, `page`, `origin`, or `source` changes.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { fetchBans } from "../api/dashboard";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { useListData } from "./useListData";
|
||||
import { BAN_PAGE_SIZE } from "../utils/constants";
|
||||
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
|
||||
import type { DashboardBanItem, TimeRange, BanOriginFilter, DashboardBanListResponse } from "../types/ban";
|
||||
|
||||
/** Return value shape for {@link useBans}. */
|
||||
export interface UseBansResult {
|
||||
@@ -32,68 +32,39 @@ export interface UseBansResult {
|
||||
/**
|
||||
* Fetch and manage dashboard ban-list data.
|
||||
*
|
||||
* Automatically re-fetches when `timeRange`, `origin`, or `page` changes.
|
||||
* Automatically re-fetches when `timeRange`, `origin`, `page`, or `source` changes.
|
||||
*
|
||||
* @param timeRange - Time-range preset that controls how far back to look.
|
||||
* @param origin - Origin filter (default `"all"`).
|
||||
* @returns Current data, pagination state, loading flag, and a `refresh`
|
||||
* callback.
|
||||
* @param source - Data source: `"fail2ban"` (live) or `"archive"` (historical).
|
||||
* @returns Current data, pagination state, loading flag, and a `refresh` callback.
|
||||
*/
|
||||
export function useBans(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): UseBansResult {
|
||||
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
|
||||
// Reset page when time range, origin filter, or source changes.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [timeRange, origin, source]);
|
||||
const fetcher = useCallback(
|
||||
(signal: AbortSignal) => fetchBans(timeRange, page, BAN_PAGE_SIZE, origin, source, signal),
|
||||
[timeRange, page, origin, source],
|
||||
);
|
||||
|
||||
const doFetch = useCallback(async (): Promise<void> => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const selector = useCallback((response: DashboardBanListResponse) => response.items, []);
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchBans(timeRange, page, BAN_PAGE_SIZE, origin, source, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setBanItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to fetch bans");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [timeRange, page, origin, source]);
|
||||
|
||||
// Stable ref to the latest doFetch so the refresh callback is always current.
|
||||
const doFetchRef = useRef(doFetch);
|
||||
doFetchRef.current = doFetch;
|
||||
|
||||
useEffect(() => {
|
||||
void doFetch();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [doFetch]);
|
||||
|
||||
const refresh = useCallback((): void => {
|
||||
void doFetchRef.current();
|
||||
const onSuccess = useCallback((response: DashboardBanListResponse) => {
|
||||
setTotal(response.total);
|
||||
}, []);
|
||||
|
||||
const { items: banItems, loading, error, refresh } = useListData<DashboardBanListResponse, DashboardBanItem>({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to fetch bans",
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
return {
|
||||
banItems,
|
||||
total,
|
||||
|
||||
Reference in New Issue
Block a user