diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 69deb48..fd09468 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,23 +1,3 @@ -### TASK-BUG-09 — `linesCount` Input in `ServerHealthSection` Fires Fetch on Every Keystroke - -**Where found** -`frontend/src/components/config/ServerHealthSection.tsx` line 410: `onChange={(_e, d) => { setLinesCount(Number(d.value)); }}`. The `linesCount` value is passed directly to `useServerHealth(linesCount, filterValue)`. `useServerHealth` re-creates its `refresh` callback when `linesCount` changes, which rebuilds `fetchData`, which triggers `useEffect([fetchData])`, firing a `GET /api/config/server/log` for every digit typed. Unlike `filterValue` (which is debounced at 500ms), `linesCount` has no debounce. - -**Goal** -Introduce a `debouncedLinesCount` state mirroring the existing `filterValue` / `filterRaw` pattern already in the component. Update the `onChange` handler to set a raw state, and apply a debounce (300–500ms) before committing to `linesCount` passed to `useServerHealth`. - -**Possible traps and issues** -- The debounce ref pattern (`filterDebounceRef`) is already present in the component; the linesCount debounce should reuse the same approach to avoid introducing a second debounce timer ref. -- `Number(d.value)` on an empty field produces `0`. Guard against `0` or negative values before passing to the API, or the backend may reject them. - -**Docs changes needed** -None required. - -**Why this is needed** -Typing `"500"` in the Lines field currently fires three HTTP requests (`"5"`, `"50"`, `"500"`). Each request fetches potentially hundreds of log lines and serializes them, adding unnecessary backend load. - ---- - ### TASK-ABORT-01 — Missing `signal` Parameter on Multiple API Functions **Where found** diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md index 063a826..833a167 100644 --- a/Docs/Web-Development.md +++ b/Docs/Web-Development.md @@ -76,14 +76,16 @@ import type { Ban } from "../types/ban"; - Use a **central API client** (e.g., a thin wrapper around `fetch` or `axios`) that returns typed data — individual components never call `fetch` directly. - Validate or assert the response structure at the boundary when dealing with untrusted data; for critical flows, consider a runtime validation library (e.g., `zod`). - API endpoint paths are **constants** defined in a single file (`api/endpoints.ts`) — never hard-code URLs in components. +- **All API functions that perform a `GET` request must accept an optional `signal?: AbortSignal` parameter and forward it to the HTTP client.** This enables hooks to cancel in-flight requests when components unmount, preventing silent state-update errors and wasted resources. When an API function calls another internal API function, thread the signal through to the underlying call. ```ts // api/client.ts const BASE_URL = import.meta.env.VITE_API_URL ?? "/api"; -async function get(path: string): Promise { +async function get(path: string, signal?: AbortSignal): Promise { const response: Response = await fetch(`${BASE_URL}${path}`, { credentials: "include", + signal, }); if (!response.ok) { throw new ApiError(response.status, await response.text()); @@ -99,11 +101,19 @@ export const api = { get, post, put, del } as const; import type { BanListResponse } from "../types/ban"; import { api } from "./client"; -export async function fetchBans(hours: number): Promise { - return api.get(`/bans?hours=${hours}`); +export async function fetchBans(hours: number, signal?: AbortSignal): Promise { + return api.get(`/bans?hours=${hours}`, signal); } ``` +```ts +// hooks/useBans.ts +const ctrl = new AbortController(); +fetchBans(24, ctrl.signal) // Pass the signal to enable cancellation on unmount + .then(resp => { /* ... */ }) + .catch(err => { /* ... */ }); +``` + --- ## 4. Code Organization diff --git a/frontend/src/api/blocklist.ts b/frontend/src/api/blocklist.ts index cb01dc9..b1f0430 100644 --- a/frontend/src/api/blocklist.ts +++ b/frontend/src/api/blocklist.ts @@ -50,8 +50,8 @@ export async function deleteBlocklist(id: number): Promise { // --------------------------------------------------------------------------- /** Preview the contents of a blocklist source URL. */ -export async function previewBlocklist(id: number): Promise { - return get(ENDPOINTS.blocklistPreview(id)); +export async function previewBlocklist(id: number, signal?: AbortSignal): Promise { + return get(ENDPOINTS.blocklistPreview(id), signal); } // --------------------------------------------------------------------------- @@ -86,6 +86,7 @@ export async function fetchImportLog( page = 1, pageSize = 50, sourceId?: number, + signal?: AbortSignal, ): Promise { const params = new URLSearchParams(); params.set("page", String(page)); @@ -93,5 +94,6 @@ export async function fetchImportLog( if (sourceId !== undefined) params.set("source_id", String(sourceId)); return get( `${ENDPOINTS.blocklistsLog}?${params.toString()}`, + signal, ); } diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 129f88e..6813f70 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -57,9 +57,10 @@ signal?: AbortSignal, } export async function fetchJailConfig( - name: string + name: string, + signal?: AbortSignal, ): Promise { - return get(ENDPOINTS.configJail(name)); + return get(ENDPOINTS.configJail(name), signal); } export async function updateJailConfig( @@ -161,8 +162,8 @@ export async function updateMapColorThresholds( // Jail config files (Task 4a) // --------------------------------------------------------------------------- -export async function fetchJailConfigFiles(): Promise { - return get(ENDPOINTS.configJailFiles); +export async function fetchJailConfigFiles(signal?: AbortSignal): Promise { + return get(ENDPOINTS.configJailFiles, signal); } export async function createJailConfigFile( @@ -202,8 +203,8 @@ export async function setJailConfigFileEnabled( * returns active-status data) and maps the result down to the simpler * ``ConfFilesResponse`` shape expected by the raw-file editor and export tab. */ -export async function fetchFilterFiles(): Promise { - const result = await fetchFilters(); +export async function fetchFilterFiles(signal?: AbortSignal): Promise { + const result = await fetchFilters(signal); return { files: result.filters.map((f) => ({ name: f.name, filename: f.filename })), total: result.total, @@ -211,8 +212,8 @@ export async function fetchFilterFiles(): Promise { } /** Fetch the raw content of a filter definition file for the raw editor. */ -export async function fetchFilterFile(name: string): Promise { - return get(ENDPOINTS.configFilterRaw(name)); +export async function fetchFilterFile(name: string, signal?: AbortSignal): Promise { + return get(ENDPOINTS.configFilterRaw(name), signal); } /** Save raw content to a filter definition file (``PUT /filters/{name}/raw``). */ @@ -234,12 +235,12 @@ export async function createFilterFile( // Action files (Task 4e) // --------------------------------------------------------------------------- -export async function fetchActionFiles(): Promise { - return get(ENDPOINTS.configActions); +export async function fetchActionFiles(signal?: AbortSignal): Promise { + return get(ENDPOINTS.configActions, signal); } -export async function fetchActionFile(name: string): Promise { - return get(ENDPOINTS.configActionRaw(name)); +export async function fetchActionFile(name: string, signal?: AbortSignal): Promise { + return get(ENDPOINTS.configActionRaw(name), signal); } export async function updateActionFile( @@ -495,9 +496,8 @@ export async function updateParsedJailFile( // Inactive jails (Stage 1) // --------------------------------------------------------------------------- -/** Fetch all inactive jails from config files. */ -export async function fetchInactiveJails(): Promise { - return get(ENDPOINTS.configJailsInactive); +export async function fetchInactiveJails(signal?: AbortSignal): Promise { + return get(ENDPOINTS.configJailsInactive, signal); } /** diff --git a/frontend/src/api/history.ts b/frontend/src/api/history.ts index f239317..dcd5a1c 100644 --- a/frontend/src/api/history.ts +++ b/frontend/src/api/history.ts @@ -15,6 +15,7 @@ import type { */ export async function fetchHistory( query: HistoryQuery = {}, + signal?: AbortSignal, ): Promise { const params = new URLSearchParams(); if (query.range) params.set("range", query.range); @@ -30,7 +31,7 @@ export async function fetchHistory( const url = qs ? `${ENDPOINTS.history}?${qs}` : ENDPOINTS.history; - return get(url); + return get(url, signal); } /** @@ -38,9 +39,9 @@ export async function fetchHistory( * * @returns null when the server returns 404 (no history for this IP). */ -export async function fetchIpHistory(ip: string): Promise { +export async function fetchIpHistory(ip: string, signal?: AbortSignal): Promise { try { - return await get(ENDPOINTS.historyIp(ip)); + return await get(ENDPOINTS.historyIp(ip), signal); } catch (err: unknown) { if ( typeof err === "object" && diff --git a/frontend/src/api/jails.ts b/frontend/src/api/jails.ts index 9ed3b67..16d5b78 100644 --- a/frontend/src/api/jails.ts +++ b/frontend/src/api/jails.ts @@ -27,8 +27,8 @@ import type { * @returns A {@link JailListResponse} containing summary info for each jail. * @throws {ApiError} On non-2xx responses. */ -export async function fetchJails(): Promise { - return get(ENDPOINTS.jails); +export async function fetchJails(signal?: AbortSignal): Promise { + return get(ENDPOINTS.jails, signal); } /** @@ -213,8 +213,8 @@ export async function unbanIp( * @returns An {@link ActiveBanListResponse} with geo-enriched entries. * @throws {ApiError} On non-2xx responses. */ -export async function fetchActiveBans(): Promise { - return get(ENDPOINTS.bansActive); +export async function fetchActiveBans(signal?: AbortSignal): Promise { + return get(ENDPOINTS.bansActive, signal); } /** diff --git a/frontend/src/api/map.ts b/frontend/src/api/map.ts index 086217d..d0d4836 100644 --- a/frontend/src/api/map.ts +++ b/frontend/src/api/map.ts @@ -19,6 +19,7 @@ export async function fetchBansByCountry( origin: BanOriginFilter = "all", source: "fail2ban" | "archive" = "fail2ban", countryCode?: string, + signal?: AbortSignal, ): Promise { const params = new URLSearchParams({ range }); if (origin !== "all") { @@ -30,5 +31,5 @@ export async function fetchBansByCountry( if (countryCode) { params.set("country_code", countryCode); } - return get(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`); + return get(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`, signal); } diff --git a/frontend/src/hooks/useActiveBans.ts b/frontend/src/hooks/useActiveBans.ts index cbb1b9f..70d2897 100644 --- a/frontend/src/hooks/useActiveBans.ts +++ b/frontend/src/hooks/useActiveBans.ts @@ -35,7 +35,7 @@ export function useActiveBans(): UseActiveBansResult { setLoading(true); setError(null); - fetchActiveBans() + fetchActiveBans(ctrl.signal) .then((res) => { if (!ctrl.signal.aborted) { setBans(res.bans); diff --git a/frontend/src/hooks/useBlocklists.ts b/frontend/src/hooks/useBlocklists.ts index b68cd04..35c847f 100644 --- a/frontend/src/hooks/useBlocklists.ts +++ b/frontend/src/hooks/useBlocklists.ts @@ -74,7 +74,8 @@ export function useBlocklists(): UseBlocklistsReturn { ); const previewSource = useCallback(async (id: number): Promise => { - return previewBlocklist(id); + const ctrl = new AbortController(); + return previewBlocklist(id, ctrl.signal); }, []); return { diff --git a/frontend/src/hooks/useConfigActiveStatus.ts b/frontend/src/hooks/useConfigActiveStatus.ts index 43763fe..2ade447 100644 --- a/frontend/src/hooks/useConfigActiveStatus.ts +++ b/frontend/src/hooks/useConfigActiveStatus.ts @@ -66,8 +66,8 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult { setError(null); Promise.all([ - fetchJails(), - fetchJailConfigs(), + fetchJails(ctrl.signal), + fetchJailConfigs(ctrl.signal), ]) .then(([jailsResp, configsResp]) => { if (ctrl.signal.aborted) return; diff --git a/frontend/src/hooks/useHistory.ts b/frontend/src/hooks/useHistory.ts index dff104c..33089d6 100644 --- a/frontend/src/hooks/useHistory.ts +++ b/frontend/src/hooks/useHistory.ts @@ -39,7 +39,7 @@ export function useHistory(query: HistoryQuery = {}): UseHistoryResult { setLoading(true); setError(null); - fetchHistory({ ...query, page }) + fetchHistory({ ...query, page }, abortRef.current.signal) .then((resp) => { setItems(resp.items); setTotal(resp.total); @@ -85,7 +85,7 @@ export function useIpHistory(ip: string): UseIpHistoryResult { setLoading(true); setError(null); - fetchIpHistory(ip) + fetchIpHistory(ip, abortRef.current.signal) .then((resp) => { setDetail(resp); }) diff --git a/frontend/src/hooks/useImportLog.ts b/frontend/src/hooks/useImportLog.ts index b666707..05e3c59 100644 --- a/frontend/src/hooks/useImportLog.ts +++ b/frontend/src/hooks/useImportLog.ts @@ -37,7 +37,7 @@ export function useImportLog( setLoading(true); setError(null); - fetchImportLog(page, pageSize, sourceId) + fetchImportLog(page, pageSize, sourceId, ctrl.signal) .then((result) => { if (!ctrl.signal.aborted) { setData(result); diff --git a/frontend/src/hooks/useJailAdmin.ts b/frontend/src/hooks/useJailAdmin.ts index e8c7b24..9d663e2 100644 --- a/frontend/src/hooks/useJailAdmin.ts +++ b/frontend/src/hooks/useJailAdmin.ts @@ -49,7 +49,7 @@ export function useJailAdmin(): UseJailAdminResult { setInactiveLoading(true); setInactiveError(null); - fetchInactiveJails() + fetchInactiveJails(ctrl.signal) .then((resp) => { if (!ctrl.signal.aborted) { setInactiveJails(resp.jails); diff --git a/frontend/src/hooks/useJailConfigDetail.ts b/frontend/src/hooks/useJailConfigDetail.ts index 936eca6..6f3c26e 100644 --- a/frontend/src/hooks/useJailConfigDetail.ts +++ b/frontend/src/hooks/useJailConfigDetail.ts @@ -32,7 +32,7 @@ export function useJailConfigDetail(name: string): UseJailConfigDetailResult { setLoading(true); setError(null); - fetchJailConfig(name) + fetchJailConfig(name, ctrl.signal) .then((resp) => { if (!ctrl.signal.aborted) { setJail(resp.jail); diff --git a/frontend/src/hooks/useJailList.ts b/frontend/src/hooks/useJailList.ts index 483248a..c5d3e33 100644 --- a/frontend/src/hooks/useJailList.ts +++ b/frontend/src/hooks/useJailList.ts @@ -44,7 +44,7 @@ export function useJails(): UseJailsResult { setLoading(true); setError(null); - fetchJails() + fetchJails(ctrl.signal) .then((res) => { if (!ctrl.signal.aborted) { setJails(res.jails); diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index 644b2ed..0702cc9 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -66,7 +66,7 @@ export function useMapData( abortRef.current?.abort(); abortRef.current = new AbortController(); - fetchBansByCountry(range, origin, source, countryCode) + fetchBansByCountry(range, origin, source, countryCode, abortRef.current.signal) .then((resp) => { setData(resp); })