Standardise frontend hook fetch error handling and mark Task 12 done

This commit is contained in:
2026-03-22 10:17:15 +01:00
parent e2876fc35c
commit e0c21dcc10
17 changed files with 62 additions and 45 deletions

View File

@@ -263,7 +263,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
--- ---
### Task 12 — Standardise error handling in frontend hooks ### Task 12 — Standardise error handling in frontend hooks (✅ completed)
**Priority**: Low **Priority**: Low
**Refactoring ref**: Refactoring.md §9 **Refactoring ref**: Refactoring.md §9

View File

@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBanTrend } from "../api/dashboard"; import { fetchBanTrend } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban"; import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -65,7 +66,7 @@ export function useBanTrend(
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to fetch trend data"); handleFetchError(err, setError, "Failed to fetch trend data");
}) })
.finally(() => { .finally(() => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {

View File

@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBans } from "../api/dashboard"; import { fetchBans } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban"; import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
/** Items per page for the ban table. */ /** Items per page for the ban table. */
@@ -63,7 +64,7 @@ export function useBans(
setBanItems(data.items); setBanItems(data.items);
setTotal(data.total); setTotal(data.total);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch data"); handleFetchError(err, setError, "Failed to fetch bans");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -14,12 +14,14 @@ import {
updateBlocklist, updateBlocklist,
updateSchedule, updateSchedule,
} from "../api/blocklist"; } from "../api/blocklist";
import { handleFetchError } from "../utils/fetchError";
import type { import type {
BlocklistSource, BlocklistSource,
BlocklistSourceCreate, BlocklistSourceCreate,
BlocklistSourceUpdate, BlocklistSourceUpdate,
ImportLogListResponse, ImportLogListResponse,
ImportRunResult, ImportRunResult,
PreviewResponse,
ScheduleConfig, ScheduleConfig,
ScheduleInfo, ScheduleInfo,
} from "../types/blocklist"; } from "../types/blocklist";
@@ -65,7 +67,7 @@ export function useBlocklists(): UseBlocklistsReturn {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load blocklists"); handleFetchError(err, setError, "Failed to load blocklists");
setLoading(false); setLoading(false);
} }
}); });
@@ -144,7 +146,7 @@ export function useSchedule(): UseScheduleReturn {
setLoading(false); setLoading(false);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to load schedule"); handleFetchError(err, setError, "Failed to load schedule");
setLoading(false); setLoading(false);
}); });
}, []); }, []);
@@ -200,7 +202,7 @@ export function useImportLog(
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load import log"); handleFetchError(err, setError, "Failed to load import log");
setLoading(false); setLoading(false);
} }
}); });
@@ -242,7 +244,7 @@ export function useRunImport(): UseRunImportReturn {
const result = await runImportNow(); const result = await runImportNow();
setLastResult(result); setLastResult(result);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Import failed"); handleFetchError(err, setError, "Import failed");
} finally { } finally {
setRunning(false); setRunning(false);
} }

View File

@@ -12,11 +12,13 @@ import {
flushLogs, flushLogs,
previewLog, previewLog,
reloadConfig, reloadConfig,
restartFail2Ban,
testRegex, testRegex,
updateGlobalConfig, updateGlobalConfig,
updateJailConfig, updateJailConfig,
updateServerSettings, updateServerSettings,
} from "../api/config"; } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type { import type {
AddLogPathRequest, AddLogPathRequest,
GlobalConfig, GlobalConfig,
@@ -65,9 +67,7 @@ export function useJailConfigs(): UseJailConfigsResult {
setTotal(resp.total); setTotal(resp.total);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch jail configs");
setError(err.message);
}
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -128,9 +128,7 @@ export function useJailConfigDetail(name: string): UseJailConfigDetailResult {
setJail(resp.jail); setJail(resp.jail);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch jail config");
setError(err.message);
}
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -191,9 +189,7 @@ export function useGlobalConfig(): UseGlobalConfigResult {
fetchGlobalConfig() fetchGlobalConfig()
.then(setConfig) .then(setConfig)
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch global config");
setError(err.message);
}
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -251,9 +247,7 @@ export function useServerSettings(): UseServerSettingsResult {
setSettings(resp.settings); setSettings(resp.settings);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch server settings");
setError(err.message);
}
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);

View File

@@ -13,6 +13,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchJails } from "../api/jails"; import { fetchJails } from "../api/jails";
import { fetchJailConfigs } from "../api/config"; import { fetchJailConfigs } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type { JailConfig } from "../types/config"; import type { JailConfig } from "../types/config";
import type { JailSummary } from "../types/jail"; import type { JailSummary } from "../types/jail";
@@ -110,7 +111,7 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load status."); handleFetchError(err, setError, "Failed to load active status.");
setLoading(false); setLoading(false);
}); });
}, []); }, []);

View File

@@ -2,6 +2,7 @@
* Generic config hook for loading and saving a single entity. * Generic config hook for loading and saving a single entity.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
export interface UseConfigItemResult<T, U> { export interface UseConfigItemResult<T, U> {
data: T | null; data: T | null;
@@ -46,7 +47,7 @@ export function useConfigItem<T, U>(
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load data"); handleFetchError(err, setError, "Failed to load data");
setLoading(false); setLoading(false);
}); });
}, [fetchFn]); }, [fetchFn]);

View File

@@ -9,6 +9,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByCountry } from "../api/map"; import { fetchBansByCountry } from "../api/map";
import { handleFetchError } from "../utils/fetchError";
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban"; import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -77,7 +78,7 @@ export function useDashboardCountryData(
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to fetch data"); handleFetchError(err, setError, "Failed to fetch dashboard country data");
}) })
.finally(() => { .finally(() => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {

View File

@@ -4,6 +4,7 @@
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 type { import type {
HistoryBanItem, HistoryBanItem,
HistoryQuery, HistoryQuery,
@@ -44,9 +45,7 @@ export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
setTotal(resp.total); setTotal(resp.total);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch history");
setError(err.message);
}
}) })
.finally((): void => { .finally((): void => {
setLoading(false); setLoading(false);
@@ -91,9 +90,7 @@ export function useIpHistory(ip: string): UseIpHistoryResult {
setDetail(resp); setDetail(resp);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch IP history");
setError(err.message);
}
}) })
.finally((): void => { .finally((): void => {
setLoading(false); setLoading(false);

View File

@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByJail } from "../api/dashboard"; import { fetchBansByJail } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { BanOriginFilter, JailBanCount, TimeRange } from "../types/ban"; import type { BanOriginFilter, JailBanCount, TimeRange } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -65,9 +66,7 @@ export function useJailDistribution(
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setError( handleFetchError(err, setError, "Failed to fetch jail distribution");
err instanceof Error ? err.message : "Failed to fetch jail distribution",
);
}) })
.finally(() => { .finally(() => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {

View File

@@ -7,6 +7,7 @@
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
import { import {
addIgnoreIp, addIgnoreIp,
banIp, banIp,
@@ -92,7 +93,7 @@ export function useJails(): UseJailsResult {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to load jails");
} }
}) })
.finally(() => { .finally(() => {
@@ -195,7 +196,7 @@ export function useJailDetail(name: string): UseJailDetailResult {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to fetch jail detail");
} }
}) })
.finally(() => { .finally(() => {
@@ -309,7 +310,7 @@ export function useJailBannedIps(jailName: string): UseJailBannedIpsResult {
setItems(resp.items); setItems(resp.items);
setTotal(resp.total); setTotal(resp.total);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to fetch jailed IPs");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -415,7 +416,7 @@ export function useActiveBans(): UseActiveBansResult {
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to fetch active bans");
} }
}) })
.finally(() => { .finally(() => {
@@ -496,7 +497,7 @@ export function useIpLookup(): UseIpLookupResult {
setResult(res); setResult(res);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
setError(err instanceof Error ? err.message : String(err)); handleFetchError(err, setError, "Failed to lookup IP");
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config"; import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type { import type {
MapColorThresholdsResponse, MapColorThresholdsResponse,
MapColorThresholdsUpdate, MapColorThresholdsUpdate,
@@ -26,7 +27,7 @@ export function useMapColorThresholds(): UseMapColorThresholdsResult {
const data = await fetchMapColorThresholds(); const data = await fetchMapColorThresholds();
setThresholds(data); setThresholds(data);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch map color thresholds"); handleFetchError(err, setError, "Failed to fetch map color thresholds");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -4,6 +4,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByCountry } from "../api/map"; import { fetchBansByCountry } from "../api/map";
import { handleFetchError } from "../utils/fetchError";
import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map"; import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map";
import type { BanOriginFilter } from "../types/ban"; import type { BanOriginFilter } from "../types/ban";
@@ -68,9 +69,7 @@ export function useMapData(
setData(resp); setData(resp);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") { handleFetchError(err, setError, "Failed to fetch map data");
setError(err.message);
}
}) })
.finally((): void => { .finally((): void => {
setLoading(false); setLoading(false);

View File

@@ -8,6 +8,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchServerStatus } from "../api/dashboard"; import { fetchServerStatus } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError";
import type { ServerStatus } from "../types/server"; import type { ServerStatus } from "../types/server";
/** How often to poll the status endpoint (milliseconds). */ /** How often to poll the status endpoint (milliseconds). */
@@ -45,7 +46,7 @@ export function useServerStatus(): UseServerStatusResult {
setStatus(data.status); setStatus(data.status);
setError(null); setError(null);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch server status"); handleFetchError(err, setError, "Failed to fetch server status");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -6,6 +6,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { handleFetchError } from "../utils/fetchError";
import { getSetupStatus, submitSetup } from "../api/setup"; import { getSetupStatus, submitSetup } from "../api/setup";
import type { import type {
SetupRequest, SetupRequest,
@@ -44,9 +45,11 @@ export function useSetup(): UseSetupResult {
const resp = await getSetupStatus(); const resp = await getSetupStatus();
setStatus(resp); setStatus(resp);
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : "Failed to fetch setup status"; const fallback = "Failed to fetch setup status";
console.warn("Setup status check failed:", errorMessage); handleFetchError(err, setError, fallback);
setError(errorMessage); if (!(err instanceof DOMException && err.name === "AbortError")) {
console.warn("Setup status check failed:", err instanceof Error ? err.message : fallback);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { fetchTimezone } from "../api/setup"; import { fetchTimezone } from "../api/setup";
import { handleFetchError } from "../utils/fetchError";
export interface UseTimezoneDataResult { export interface UseTimezoneDataResult {
timezone: string; timezone: string;
@@ -21,7 +22,7 @@ export function useTimezoneData(): UseTimezoneDataResult {
const resp = await fetchTimezone(); const resp = await fetchTimezone();
setTimezone(resp.timezone); setTimezone(resp.timezone);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch timezone"); handleFetchError(err, setError, "Failed to fetch timezone");
setTimezone("UTC"); setTimezone("UTC");
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -0,0 +1,14 @@
/**
* Normalize fetch error handling across hooks.
*/
export function handleFetchError(
err: unknown,
setError: (value: string | null) => void,
fallback: string = "Unknown error",
): void {
if (err instanceof DOMException && err.name === "AbortError") {
return;
}
setError(err instanceof Error ? err.message : fallback);
}