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:
@@ -1,31 +1,3 @@
|
|||||||
### TASK-QUALITY-02 — `useConfigItem.save()` Briefly Shows Session-Expiry as Save Error
|
|
||||||
|
|
||||||
**Where found**
|
|
||||||
`frontend/src/hooks/useConfigItem.ts` lines 70–80. 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**
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user