Refactor useHistory hook: replace HistoryQuery with explicit parameters and add documentation

- Split useHistory interface to accept explicit parameters (page, pageSize, range, origin, jail, ip, source) instead of HistoryQuery object
- Add comprehensive JSDoc for useHistory function
- Update HistoryPage and tests to use new parameter structure
- Move TaskList documentation from Tasks.md to Web-Development.md
- Improve type safety with explicit TimeRange and BanOriginFilter types

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-23 09:51:16 +02:00
parent 8904e180d1
commit 9c5757eeb0
5 changed files with 119 additions and 93 deletions

View File

@@ -1,31 +1,3 @@
### TASK-QUALITY-02 — `useConfigItem.save()` Briefly Shows Session-Expiry as Save Error
**Where found**
`frontend/src/hooks/useConfigItem.ts` lines 7080. The `save()` function's catch block calls `setSaveError(err.message)` for all errors including `ApiError(401)` and `ApiError(403)`. The HTTP client layer dispatches `SESSION_EXPIRED_EVENT` on those status codes, which triggers auth handling, but `setSaveError` still runs first and may briefly display an "Unauthorized" or similar message before the navigation occurs.
**Goal**
Check for auth errors before setting save error state:
```ts
} catch (err: unknown) {
if (isAuthError(err)) throw err; // let auth handler deal with it
const message = err instanceof Error ? err.message : "Failed to save data";
setSaveError(message);
throw err;
}
```
**Possible traps and issues**
- Rethrowing auth errors is correct here since the caller might also have error handling. Confirm that all callers of `save()` handle the re-thrown auth error gracefully (typically by not doing anything — the session expiry flow handles navigation).
- Import `isAuthError` from `../api/client`.
**Docs changes needed**
None required.
**Why this is needed**
Briefly flashing "Unauthorized" in a form's save-error field is confusing UX when the correct outcome is a redirect to the login page.
---
### TASK-QUALITY-03 — `useHistory` Object Identity Dependency Footgun ### TASK-QUALITY-03 — `useHistory` Object Identity Dependency Footgun
**Where found** **Where found**

View File

@@ -328,6 +328,37 @@ export default BanTable;
- Always include the correct dependency arrays in `useEffect`, `useMemo`, and `useCallback`. Disable the ESLint exhaustive-deps rule **only** with a comment explaining why. - Always include the correct dependency arrays in `useEffect`, `useMemo`, and `useCallback`. Disable the ESLint exhaustive-deps rule **only** with a comment explaining why.
- Clean up side effects (subscriptions, timers, abort controllers) in the `useEffect` cleanup function. - Clean up side effects (subscriptions, timers, abort controllers) in the `useEffect` cleanup function.
### Object Parameters in Hooks (Reference Stability)
**When a hook accepts an object parameter, include it in dependency arrays only if it is guaranteed to be a stable reference.** If callers pass inline object literals, the object reference changes on every render, causing unnecessary re-fetches and potential infinite loops.
**Preferred solution:** Design hook signatures to accept individual **primitive parameters** instead of objects. This makes incorrect usage a compile-time error:
```ts
// ❌ Footgun: query object causes infinite fetches if caller uses inline literals
export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
const load = useCallback(() => { /* ... */ }, [query]);
}
// Called like this (creates new object every render):
const result = useHistory({ page: 1, jail: selectedJail });
// ✅ Safe: individual primitives can't be accidentally unstable
export function useHistory(
page: number = 1,
pageSize: number = 50,
jail?: string,
): UseHistoryResult {
const load = useCallback(() => { /* ... */ }, [page, pageSize, jail]);
}
// Called like this (all primitives are stable):
const result = useHistory(page, PAGE_SIZE, jailFilter);
```
If refactoring to individual parameters is not feasible, document the constraint clearly in JSDoc and require callers to stabilize the reference using `useMemo`.
```tsx ```tsx
// hooks/useBans.ts // hooks/useBans.ts
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";

View File

@@ -5,11 +5,8 @@
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 { import type { HistoryBanItem, IpDetailResponse } from "../types/history";
HistoryBanItem, import type { BanOriginFilter, TimeRange } from "../types/ban";
HistoryQuery,
IpDetailResponse,
} from "../types/history";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// useHistory — paginated list // useHistory — paginated list
@@ -25,10 +22,30 @@ export interface UseHistoryResult {
refresh: () => void; refresh: () => void;
} }
export function useHistory(query: HistoryQuery = {}): UseHistoryResult { /**
* Fetch and manage paginated ban history with optional filters.
*
* @param page - Current page number (1-indexed)
* @param pageSize - Items per page
* @param range - Time range filter (e.g., "7d", "30d")
* @param origin - Ban origin filter (e.g., "fail2ban", "blocklist")
* @param jail - Jail name filter (optional)
* @param ip - IP address filter (optional)
* @param source - Data source ("fail2ban" | "archive")
* @returns History data with pagination and error state
*/
export function useHistory(
page: number = 1,
pageSize: number = 50,
range?: TimeRange,
origin?: BanOriginFilter,
jail?: string,
ip?: string,
source: "fail2ban" | "archive" = "archive",
): UseHistoryResult {
const [items, setItems] = useState<HistoryBanItem[]>([]); const [items, setItems] = useState<HistoryBanItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(query.page ?? 1); const [currentPage, setCurrentPage] = useState(page);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@@ -39,7 +56,18 @@ export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
setLoading(true); setLoading(true);
setError(null); setError(null);
fetchHistory({ ...query, page }, abortRef.current.signal) fetchHistory(
{
page: currentPage,
page_size: pageSize,
range,
origin,
jail,
ip,
source,
},
abortRef.current.signal,
)
.then((resp) => { .then((resp) => {
setItems(resp.items); setItems(resp.items);
setTotal(resp.total); setTotal(resp.total);
@@ -50,7 +78,8 @@ export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
.finally((): void => { .finally((): void => {
setLoading(false); setLoading(false);
}); });
}, [query, page]); }, [currentPage, pageSize, range, origin, jail, ip, source]);
useEffect((): (() => void) => { useEffect((): (() => void) => {
load(); load();
@@ -59,7 +88,15 @@ export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
}; };
}, [load]); }, [load]);
return { items, total, page, loading, error, setPage, refresh: load }; return {
items,
total,
page: currentPage,
loading,
error,
setPage: setCurrentPage,
refresh: load,
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -6,7 +6,7 @@
* Rows with repeatedly-banned IPs are highlighted in amber. * Rows with repeatedly-banned IPs are highlighted in amber.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Badge, Badge,
Button, Button,
@@ -32,8 +32,7 @@ import {
import { DashboardFilterBar } from "../components/DashboardFilterBar"; import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { useHistory } from "../hooks/useHistory"; import { useHistory } from "../hooks/useHistory";
import { IpDetailView } from "./history/IpDetailView"; import { IpDetailView } from "./history/IpDetailView";
import { areHistoryQueriesEqual } from "../utils/queryUtils"; import type { HistoryBanItem, TimeRange } from "../types/history";
import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history";
import type { BanOriginFilter } from "../types/ban"; import type { BanOriginFilter } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -199,20 +198,21 @@ export function HistoryPage(): React.JSX.Element {
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all"); const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [jailFilter, setJailFilter] = useState(""); const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState(""); const [ipFilter, setIpFilter] = useState("");
const defaultQuery: HistoryQuery = { const [page, setPage] = useState(1);
range: "7d",
source: "archive",
page_size: PAGE_SIZE,
page: 1,
};
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>(defaultQuery);
const appliedQueryRef = useRef<HistoryQuery>(defaultQuery);
// Per-IP detail navigation // Per-IP detail navigation
const [selectedIp, setSelectedIp] = useState<string | null>(null); const [selectedIp, setSelectedIp] = useState<string | null>(null);
const { items, total, page, loading, error, setPage, refresh } = const { items, total, page: currentPage, loading, error, setPage: setCurrentPage, refresh } =
useHistory(appliedQuery); useHistory(
page,
PAGE_SIZE,
range,
originFilter !== "all" ? originFilter : undefined,
jailFilter.trim() || undefined,
ipFilter.trim() || undefined,
"archive",
);
const handleIpClick = useCallback((ip: string): void => { const handleIpClick = useCallback((ip: string): void => {
setSelectedIp(ip); setSelectedIp(ip);
@@ -223,25 +223,10 @@ export function HistoryPage(): React.JSX.Element {
[handleIpClick, styles], [handleIpClick, styles],
); );
// Reset to page 1 when filters change
useEffect((): void => { useEffect((): void => {
const nextQuery: HistoryQuery = {
range,
origin: originFilter !== "all" ? originFilter : undefined,
jail: jailFilter.trim() || undefined,
ip: ipFilter.trim() || undefined,
source: "archive",
page: 1,
page_size: PAGE_SIZE,
};
if (areHistoryQueriesEqual(nextQuery, appliedQueryRef.current)) {
return;
}
setPage(1); setPage(1);
setAppliedQuery(nextQuery); }, [range, originFilter, jailFilter, ipFilter]);
appliedQueryRef.current = nextQuery;
}, [range, originFilter, jailFilter, ipFilter, setPage]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
@@ -310,7 +295,7 @@ export function HistoryPage(): React.JSX.Element {
{!loading && !error && ( {!loading && !error && (
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}> <Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
{String(total)} record{total !== 1 ? "s" : ""} found · {String(total)} record{total !== 1 ? "s" : ""} found ·
Page {String(page)} of {String(totalPages)} · Page {String(currentPage)} of {String(totalPages)} ·
Rows highlighted in yellow have {String(HIGH_BAN_THRESHOLD)}+ repeat bans Rows highlighted in yellow have {String(HIGH_BAN_THRESHOLD)}+ repeat bans
</Text> </Text>
)} )}
@@ -362,21 +347,21 @@ export function HistoryPage(): React.JSX.Element {
icon={<ChevronLeftRegular />} icon={<ChevronLeftRegular />}
appearance="subtle" appearance="subtle"
size="small" size="small"
disabled={page <= 1} disabled={currentPage <= 1}
onClick={(): void => { onClick={(): void => {
setPage(page - 1); setCurrentPage(currentPage - 1);
}} }}
/> />
<Text size={200}> <Text size={200}>
Page {String(page)} / {String(totalPages)} Page {String(currentPage)} / {String(totalPages)}
</Text> </Text>
<Button <Button
icon={<ChevronRightRegular />} icon={<ChevronRightRegular />}
appearance="subtle" appearance="subtle"
size="small" size="small"
disabled={page >= totalPages} disabled={currentPage >= totalPages}
onClick={(): void => { onClick={(): void => {
setPage(page + 1); setCurrentPage(currentPage + 1);
}} }}
/> />
</div> </div>

View File

@@ -3,10 +3,10 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { FluentProvider, webLightTheme } from "@fluentui/react-components";
let lastQuery: Record<string, unknown> | null = null; let lastCallArgs: unknown[] | null = null;
const mockUseHistory = vi.fn((query: Record<string, unknown>) => { const mockUseHistory = vi.fn((...args: unknown[]) => {
console.log("mockUseHistory called", query); console.log("mockUseHistory called with args:", args);
lastQuery = query; lastCallArgs = args;
return { return {
items: [], items: [],
total: 0, total: 0,
@@ -19,7 +19,7 @@ const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
}); });
vi.mock("../../hooks/useHistory", () => ({ vi.mock("../../hooks/useHistory", () => ({
useHistory: (query: Record<string, unknown>) => mockUseHistory(query), useHistory: (...args: unknown[]) => mockUseHistory(...args),
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }), useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
})); }));
@@ -47,17 +47,18 @@ describe("HistoryPage", () => {
</FluentProvider>, </FluentProvider>,
); );
// Initial load should include the auto-applied default query. // Initial load should have default parameters: page, pageSize, range, origin, jail, ip, source
// Arguments: (page: 1, pageSize: 50, range: "7d", origin: undefined, jail: undefined, ip: undefined, source: "archive")
await waitFor(() => { await waitFor(() => {
expect(lastQuery).toEqual({ expect(lastCallArgs).toEqual([
range: "7d", 1, // page
source: "archive", 50, // pageSize
origin: undefined, "7d", // range
jail: undefined, undefined, // origin
ip: undefined, undefined, // jail
page: 1, undefined, // ip
page_size: 50, "archive", // source
}); ]);
}); });
expect(screen.queryByRole("button", { name: /apply/i })).toBeNull(); expect(screen.queryByRole("button", { name: /apply/i })).toBeNull();
@@ -66,12 +67,12 @@ describe("HistoryPage", () => {
// Time-range and origin updates should be applied automatically. // Time-range and origin updates should be applied automatically.
await user.click(screen.getByRole("button", { name: /Last 7 days/i })); await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
await waitFor(() => { await waitFor(() => {
expect(lastQuery).toMatchObject({ range: "7d" }); expect(lastCallArgs?.[2]).toBe("7d"); // range is at index 2
}); });
await user.click(screen.getByRole("button", { name: /Blocklist/i })); await user.click(screen.getByRole("button", { name: /Blocklist/i }));
await waitFor(() => { await waitFor(() => {
expect(lastQuery).toMatchObject({ origin: "blocklist" }); expect(lastCallArgs?.[3]).toBe("blocklist"); // origin is at index 3
}); });
}); });
@@ -83,9 +84,9 @@ describe("HistoryPage", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(lastQuery?.range).toBe("7d"); expect(lastCallArgs?.[2]).toBe("7d"); // range is at index 2
}); });
expect(mockUseHistory.mock.calls.every((call) => call[0].range === "7d")).toBe(true); expect(mockUseHistory.mock.calls.every((call) => call[2] === "7d")).toBe(true);
}); });
}); });