feat: Implement typed error contracts in generic hooks

Introduce discriminated FetchError union type to replace weak string error
handling in API calls and hooks. Enables actionable error diagnostics.

Changes:
- Create types/api.ts with FetchError discriminated union (api_error,
  network_error, abort_error)
- Export type guards: isAuthError, isAbortError, isNetworkError, isApiError
- Update useListData and usePolledData to expose typed FetchError instead of
  string
- Add getErrorMessage() helper to extract displayable messages from FetchError
- Add createStringErrorAdapter() for backward compatibility with string error
  state
- Update handleFetchError() to work with both FetchError and string setters
- Update all consumer hooks to expose typed errors
- Update components to use getErrorMessage() when displaying errors
- Update tests to mock FetchError instead of strings
- Add comprehensive typed error model documentation to Web-Development.md

This enables better error handling patterns:
- Check error.type to distinguish between API, network, and abort errors
- Extract status codes for specific handling (401/403 auth, 50x server errors)
- Maintain backward compatibility with existing string-based error states

All TypeScript compilation passes with no errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 09:13:47 +02:00
parent 6c8e2b3423
commit 5166789b68
45 changed files with 531 additions and 125 deletions

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useListData } from "../useListData";
import type { FetchError } from "../../types/api";
describe("useListData", () => {
it("loads items and updates loading", async () => {
@@ -26,9 +27,10 @@ describe("useListData", () => {
expect(selector).toHaveBeenCalledWith({ items: ["one", "two"] });
expect(result.current.items).toEqual(["one", "two"]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
it("sets error when the fetcher rejects", async () => {
it("sets typed error when the fetcher rejects", async () => {
const fetcher = vi.fn().mockRejectedValue(new Error("network"));
const selector = vi.fn();
@@ -44,7 +46,10 @@ describe("useListData", () => {
await Promise.resolve();
});
expect(result.current.error).toBe("network");
const error = result.current.error as FetchError | null;
expect(error).not.toBeNull();
expect(error?.type).toBe("network_error");
expect(error?.message).toBe("network");
expect(result.current.loading).toBe(false);
expect(result.current.items).toEqual([]);
});
@@ -109,4 +114,58 @@ describe("useListData", () => {
// Items should not be updated after abort
expect(result.current.items).toEqual([]);
});
it("silently handles abort errors", async () => {
const fetcher = vi.fn().mockRejectedValue(new DOMException("Aborted", "AbortError"));
const selector = vi.fn();
const { result } = renderHook(() =>
useListData({
fetcher,
selector,
errorMessage: "Failed to load",
})
);
await act(async () => {
await Promise.resolve();
});
// Abort errors should not set error state
expect(result.current.error).toBeNull();
});
it("exposes typed API error information", async () => {
// Mock an API error response
const fetcher = vi.fn().mockImplementation(() => {
return Promise.reject((() => {
const error = new Error("API error 500: Server error");
(error as any).name = "ApiError";
(error as any).status = 500;
(error as any).body = "Server error";
return error;
})());
});
const selector = vi.fn();
const { result } = renderHook(() =>
useListData({
fetcher,
selector,
errorMessage: "Failed to load",
})
);
await act(async () => {
await Promise.resolve();
});
const error = result.current.error as FetchError | null;
expect(error).not.toBeNull();
expect(error?.type).toBe("api_error");
if (error?.type === "api_error") {
expect(error.status).toBe(500);
expect(error.body).toBe("Server error");
}
});
});

View File

@@ -5,11 +5,12 @@ import { useCallback } from "react";
import { fetchActions, removeActionFromJail, createAction, assignActionToJail } from "../api/config";
import { useListData } from "./useListData";
import type { ActionConfig, ActionCreateRequest, ActionListResponse } from "../types/config";
import type { FetchError } from "../types/api";
export interface UseActionListResult {
actions: ActionConfig[];
loading: boolean;
error: string | null;
error: FetchError | null;
refresh: () => void;
removeActionFromJail: (jailName: string, actionName: string) => Promise<void>;
createAction: (payload: ActionCreateRequest) => Promise<ActionConfig>;
@@ -18,6 +19,9 @@ export interface UseActionListResult {
/**
* Load the action inventory and expose related action operations.
*
* Exposes typed errors via the `error` property, which is either `null` or a discriminated
* `FetchError` union. Check the error's `type` field to determine the failure mode.
*/
export function useActionList(): UseActionListResult {
const fetcher = useCallback(

View File

@@ -6,12 +6,13 @@ import { useCallback } from "react";
import { banIp, fetchActiveBans, unbanAllBans, unbanIp } from "../api/jails";
import { useListData } from "./useListData";
import type { ActiveBan, UnbanAllResponse, ActiveBanListResponse } from "../types/jail";
import type { FetchError } from "../types/api";
export interface UseActiveBansResult {
bans: ActiveBan[];
total: number;
loading: boolean;
error: string | null;
error: FetchError | null;
refresh: () => void;
banIp: (jail: string, ip: string) => Promise<void>;
unbanIp: (ip: string, jail?: string) => Promise<void>;

View File

@@ -9,6 +9,7 @@ import { useCallback, useState } from "react";
import { fetchBanTrend } from "../api/dashboard";
import { useListData } from "./useListData";
import type { BanTrendBucket, BanOriginFilter, TimeRange, BanTrendResponse } from "../types/ban";
import type { FetchError } from "../types/api";
/** Return value shape for {@link useBanTrend}. */
export interface UseBanTrendResult {
@@ -19,7 +20,7 @@ export interface UseBanTrendResult {
/** True while a fetch is in flight. */
loading: boolean;
/** Error message or `null`. */
error: string | null;
error: FetchError | null;
/** Re-fetch the data immediately. */
reload: () => void;
}

View File

@@ -10,6 +10,7 @@ import { fetchBans } from "../api/dashboard";
import { useListData } from "./useListData";
import { BAN_PAGE_SIZE } from "../utils/constants";
import type { DashboardBanItem, TimeRange, BanOriginFilter, DashboardBanListResponse } from "../types/ban";
import type { FetchError } from "../types/api";
/** Return value shape for {@link useBans}. */
export interface UseBansResult {
@@ -24,7 +25,7 @@ export interface UseBansResult {
/** Whether a fetch is currently in flight. */
loading: boolean;
/** Error message if the last fetch failed, otherwise `null`. */
error: string | null;
error: FetchError | null;
/** Imperatively re-fetch the current page. */
refresh: () => void;
}

View File

@@ -11,7 +11,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByCountry } from "../api/map";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { BanOriginFilter, TimeRange } from "../types/ban";
import type { BansByCountryResponse, MapBanItem } from "../types/map";
@@ -80,7 +80,7 @@ export function useBansByCountry(
})
.catch((err: unknown) => {
if (!controller.signal.aborted) {
handleFetchError(err, setError, "Failed to fetch ban-by-country data");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch ban-by-country data");
}
})
.finally((): void => {

View File

@@ -18,11 +18,12 @@ import type {
BlocklistListResponse,
PreviewResponse,
} from "../types/blocklist";
import type { FetchError } from "../types/api";
export interface UseBlocklistsReturn {
sources: BlocklistSource[];
loading: boolean;
error: string | null;
error: FetchError | null;
refresh: () => void;
createSource: (payload: BlocklistSourceCreate) => Promise<BlocklistSource>;
updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise<BlocklistSource>;

View File

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

View File

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

View File

@@ -5,11 +5,12 @@ import { useCallback } from "react";
import { fetchFilters, createFilter, assignFilterToJail } from "../api/config";
import { useListData } from "./useListData";
import type { FilterConfig, FilterCreateRequest, FilterListResponse } from "../types/config";
import type { FetchError } from "../types/api";
export interface UseFilterListResult {
filters: FilterConfig[];
loading: boolean;
error: string | null;
error: FetchError | null;
refresh: () => void;
createFilter: (payload: FilterCreateRequest) => Promise<FilterConfig>;
assignFilterToJail: (jailName: string, payload: { filter_name: string }, reload: boolean) => Promise<void>;

View File

@@ -4,7 +4,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchGlobalConfig, updateGlobalConfig } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { GlobalConfig, GlobalConfigUpdate } from "../types/config";
export interface UseGlobalConfigResult {
@@ -39,7 +39,7 @@ export function useGlobalConfig(): UseGlobalConfigResult {
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setError, "Failed to fetch global config");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch global config");
}
})
.finally(() => {

View File

@@ -4,17 +4,18 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchHistory, fetchIpHistory } from "../api/history";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import { useListData } from "./useListData";
import type { HistoryBanItem, IpDetailResponse, HistoryListResponse } from "../types/history";
import type { BanOriginFilter, TimeRange } from "../types/ban";
import type { FetchError } from "../types/api";
export interface UseHistoryResult {
items: HistoryBanItem[];
total: number;
page: number;
loading: boolean;
error: string | null;
error: FetchError | null;
setPage: (page: number) => void;
refresh: () => void;
}
@@ -121,7 +122,7 @@ export function useIpHistory(ip: string): UseIpHistoryResult {
})
.catch((err: unknown) => {
if (!abortRef.current?.signal.aborted) {
handleFetchError(err, setError, "Failed to fetch IP history");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch IP history");
}
})
.finally((): void => {

View File

@@ -4,7 +4,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchImportLog } from "../api/blocklist";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { ImportLogListResponse } from "../types/blocklist";
export interface UseImportLogReturn {
@@ -46,7 +46,7 @@ export function useImportLog(
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setError, "Failed to load import log");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to load import log");
setLoading(false);
}
});

View File

@@ -3,7 +3,7 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import { lookupIp } from "../api/jails";
import type { IpLookupResponse } from "../types/jail";
@@ -41,7 +41,7 @@ export function useIpLookup(): UseIpLookupResult {
setResult(res);
} catch (err: unknown) {
if (ctrl.signal.aborted) return;
handleFetchError(err, setError, "Failed to lookup IP");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to lookup IP");
} finally {
if (!ctrl.signal.aborted) {
setLoading(false);

View File

@@ -10,7 +10,7 @@ import {
validateJailConfig,
createJailConfigFile,
} from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type {
ActivateJailRequest,
ConfFileCreateRequest,
@@ -57,7 +57,7 @@ export function useJailAdmin(): UseJailAdminResult {
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setInactiveError, "Failed to load inactive jails");
handleFetchError(err, createStringErrorAdapter(setInactiveError), "Failed to load inactive jails");
}
})
.finally(() => {

View File

@@ -4,7 +4,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchJailBannedIps, unbanIp } from "../api/jails";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { ActiveBan } from "../types/jail";
export interface UseJailBannedIpsResult {
@@ -69,7 +69,7 @@ export function useJailBannedIps(jailName: string): UseJailBannedIpsResult {
if (signal.aborted) {
return;
}
handleFetchError(err, setError, "Failed to fetch jailed IPs");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch jailed IPs");
} finally {
if (!signal.aborted) {
setLoading(false);

View File

@@ -4,7 +4,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { addLogPath, fetchJailConfig, updateJailConfig } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { AddLogPathRequest, JailConfig, JailConfigUpdate } from "../types/config";
export interface UseJailConfigDetailResult {
@@ -40,7 +40,7 @@ export function useJailConfigDetail(name: string): UseJailConfigDetailResult {
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setError, "Failed to fetch jail config");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch jail config");
}
})
.finally(() => {

View File

@@ -6,12 +6,13 @@ import { useCallback, useState } from "react";
import { fetchJailConfigs, reloadConfig, updateJailConfig } from "../api/config";
import { useListData } from "./useListData";
import type { JailConfig, JailConfigUpdate, JailConfigListResponse } from "../types/config";
import type { FetchError } from "../types/api";
export interface UseJailConfigsResult {
jails: JailConfig[];
total: number;
loading: boolean;
error: string | null;
error: FetchError | null;
refresh: () => void;
updateJail: (name: string, update: JailConfigUpdate) => Promise<void>;
reloadAll: () => Promise<void>;

View File

@@ -7,7 +7,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchJail } from "../api/jails";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { Jail } from "../types/jail";
export interface UseJailDataResult {
@@ -50,7 +50,7 @@ export function useJailData(name: string): UseJailDataResult {
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setError, "Failed to fetch jail detail");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch jail detail");
}
})
.finally(() => {

View File

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

View File

@@ -13,12 +13,13 @@ import {
} from "../api/jails";
import { useListData } from "./useListData";
import type { JailSummary, JailListResponse } from "../types/jail";
import type { FetchError } from "../types/api";
export interface UseJailsResult {
jails: JailSummary[];
total: number;
loading: boolean;
error: string | null;
error: FetchError | null;
refresh: () => void;
startJail: (name: string) => Promise<void>;
stopJail: (name: string) => Promise<void>;

View File

@@ -3,6 +3,7 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
import type { FetchError } from "../types/api";
export interface UseListDataOptions<TResponse, TItem> {
fetcher: (signal: AbortSignal) => Promise<TResponse>;
@@ -15,12 +16,23 @@ export interface UseListDataOptions<TResponse, TItem> {
export interface UseListDataResult<TItem> {
items: TItem[];
loading: boolean;
error: string | null;
error: FetchError | null;
refresh: () => void;
}
/**
* Load a list response and expose refresh semantics with abort support.
*
* Provides typed error handling through the `error` property, which is either
* `null` or a discriminated `FetchError` union. Use the error's `type` field
* to determine how to handle it:
*
* - `"api_error"`: Server returned HTTP error (check `status` for 401/403/50x)
* - `"network_error"`: Network, DNS, or JSON parse failure
* - `"abort_error"`: Request was cancelled (typically silently ignored by hook)
*
* @param options - Configuration options
* @returns Data, loading state, typed error, and refresh callback
*/
export function useListData<TResponse, TItem>(
options: UseListDataOptions<TResponse, TItem>,
@@ -28,7 +40,7 @@ export function useListData<TResponse, TItem>(
const { fetcher, selector, errorMessage, onSuccess, initialItems } = options;
const [items, setItems] = useState<TItem[]>(initialItems ?? []);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<FetchError | null>(null);
const abortRef = useRef<AbortController | null>(null);
const refresh = useCallback((): void => {

View File

@@ -4,7 +4,7 @@
import { useCallback, useState } from "react";
import { previewLog } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { LogPreviewRequest, LogPreviewResponse } from "../types/config";
export interface UseLogPreviewResult {
@@ -29,7 +29,7 @@ export function useLogPreview(): UseLogPreviewResult {
setPreview(resp);
setError(null);
} catch (err: unknown) {
handleFetchError(err, setError, "Log preview failed");
handleFetchError(err, createStringErrorAdapter(setError), "Log preview failed");
} finally {
setLoading(false);
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type {
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
@@ -29,7 +29,7 @@ export function useMapColorThresholds(): UseMapColorThresholdsResult {
setThresholds(data);
} catch (err: unknown) {
if (signal?.aborted) return;
handleFetchError(err, setError, "Failed to fetch map color thresholds");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch map color thresholds");
} finally {
if (!signal?.aborted) {
setLoading(false);

View File

@@ -6,6 +6,7 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
import type { FetchError } from "../types/api";
export interface UsePolledDataOptions<TResponse, TData> {
fetcher: (signal: AbortSignal) => Promise<TResponse>;
@@ -20,15 +21,23 @@ export interface UsePolledDataOptions<TResponse, TData> {
export interface UsePolledDataResult<TData> {
data: TData | null;
loading: boolean;
error: string | null;
error: FetchError | null;
refresh: () => void;
}
/**
* Load a single-item response and expose refresh semantics with polling support.
*
* Provides typed error handling through the `error` property, which is either
* `null` or a discriminated `FetchError` union. Use the error's `type` field
* to determine how to handle it:
*
* - `"api_error"`: Server returned HTTP error (check `status` for 401/403/50x)
* - `"network_error"`: Network, DNS, or JSON parse failure
* - `"abort_error"`: Request was cancelled (typically silently ignored by hook)
*
* @param options - Configuration options
* @returns Data, loading state, error, and refresh callback
* @returns Data, loading state, typed error, and refresh callback
*/
export function usePolledData<TResponse, TData>(
options: UsePolledDataOptions<TResponse, TData>,
@@ -45,7 +54,7 @@ export function usePolledData<TResponse, TData>(
const [data, setData] = useState<TData | null>(initialData ?? null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<FetchError | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchRef = useRef<() => void>((): void => undefined);

View File

@@ -4,7 +4,7 @@
import { useCallback, useState } from "react";
import { testRegex } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { RegexTestRequest, RegexTestResponse } from "../types/config";
export interface UseRegexTesterResult {
@@ -29,7 +29,7 @@ export function useRegexTester(): UseRegexTesterResult {
setResult(resp);
setError(null);
} catch (err: unknown) {
handleFetchError(err, setError, "Regex test failed");
handleFetchError(err, createStringErrorAdapter(setError), "Regex test failed");
} finally {
setTesting(false);
}

View File

@@ -4,7 +4,7 @@
import { useCallback, useState } from "react";
import { runImportNow } from "../api/blocklist";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { ImportRunResult } from "../types/blocklist";
export interface UseRunImportReturn {
@@ -29,7 +29,7 @@ export function useRunImport(): UseRunImportReturn {
const result = await runImportNow();
setLastResult(result);
} catch (err: unknown) {
handleFetchError(err, setError, "Import failed");
handleFetchError(err, createStringErrorAdapter(setError), "Import failed");
} finally {
setRunning(false);
}

View File

@@ -4,7 +4,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchSchedule, updateSchedule } from "../api/blocklist";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { ScheduleConfig, ScheduleInfo } from "../types/blocklist";
export interface UseScheduleReturn {
@@ -39,7 +39,7 @@ export function useSchedule(): UseScheduleReturn {
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, setError, "Failed to load schedule");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to load schedule");
})
.finally(() => {
if (!controller.signal.aborted) {

View File

@@ -5,7 +5,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchServerSettings, flushLogs, updateServerSettings } from "../api/server";
import { reloadConfig, restartFail2Ban } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
import type { ServerSettings, ServerSettingsUpdate } from "../types/config";
export interface UseServerSettingsResult {
@@ -43,7 +43,7 @@ export function useServerSettings(): UseServerSettingsResult {
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setError, "Failed to fetch server settings");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch server settings");
}
})
.finally(() => {

View File

@@ -10,6 +10,7 @@ import { useCallback } from "react";
import { fetchServerStatus } from "../api/dashboard";
import { usePolledData } from "./usePolledData";
import type { ServerStatus, ServerStatusResponse } from "../types/server";
import type { FetchError } from "../types/api";
/** How often to poll the status endpoint (milliseconds). */
const POLL_INTERVAL_MS = 30_000;
@@ -21,7 +22,7 @@ export interface UseServerStatusResult {
/** Whether a fetch is currently in flight. */
loading: boolean;
/** Error message string when the last fetch failed, otherwise `null`. */
error: string | null;
error: FetchError | null;
/** Manually trigger a refresh immediately. */
refresh: () => void;
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { fetchTimezone } from "../api/setup";
import { handleFetchError } from "../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
export interface UseTimezoneDataResult {
timezone: string;
@@ -24,7 +24,7 @@ export function useTimezoneData(): UseTimezoneDataResult {
setTimezone(resp.timezone);
} catch (err: unknown) {
if (signal?.aborted) return;
handleFetchError(err, setError, "Failed to fetch timezone");
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch timezone");
setTimezone("UTC");
} finally {
if (!signal?.aborted) {