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

@@ -28,6 +28,7 @@ import {
import { PageEmpty, PageError, PageLoading } from "./PageFeedback";
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
import { useBans } from "../hooks/useBans";
import { getErrorMessage } from "../utils/fetchError";
import { formatTimestamp } from "../utils/formatDate";
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
@@ -212,7 +213,7 @@ export const BanTable = memo(function BanTable({ timeRange, origin, source }: Ba
// Error state
// --------------------------------------------------------------------------
if (error) {
return <PageError message={error} onRetry={refresh} />;
return <PageError message={getErrorMessage(error)} onRetry={refresh} />;
}
// --------------------------------------------------------------------------

View File

@@ -25,6 +25,7 @@ import {
CHART_PALETTE,
resolveFluentToken,
} from "../utils/chartTheme";
import { getErrorMessage } from "../utils/fetchError";
import { useThemeMode } from "../providers/ThemeProvider";
import { ChartStateWrapper } from "./ChartStateWrapper";
import { useBanTrend } from "../hooks/useBanTrend";
@@ -230,7 +231,7 @@ export const BanTrendChart = memo(function BanTrendChart({
return (
<ChartStateWrapper
isLoading={loading}
error={error}
error={error ? getErrorMessage(error) : null}
onRetry={reload}
isEmpty={isEmpty}
emptyMessage="No bans in this time range."

View File

@@ -7,6 +7,7 @@ import * as chartTheme from "../../utils/chartTheme";
import * as useBanTrendModule from "../../hooks/useBanTrend";
import type { UseBanTrendResult } from "../../hooks/useBanTrend";
import type { BanTrendBucket } from "../../types/ban";
import type { FetchError } from "../../types/api";
vi.mock("recharts", () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
@@ -61,7 +62,8 @@ describe("BanTrendChart", () => {
it("shows error message and retry button on error", async () => {
const reload = vi.fn();
mockHook({ error: "Failed to fetch trend", reload });
const error: FetchError = { type: "network_error", message: "Failed to fetch trend" };
mockHook({ error, reload });
const user = userEvent.setup();
wrap(<BanTrendChart timeRange="24h" origin="all" source="fail2ban" />);
expect(screen.getByText("Failed to fetch trend")).toBeInTheDocument();

View File

@@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ServerStatusBar } from "../ServerStatusBar";
import type { FetchError } from "../../types/api";
// ---------------------------------------------------------------------------
// Mock useServerStatus so tests never touch the network.
@@ -158,10 +159,11 @@ describe("ServerStatusBar", () => {
});
it("renders an error message when the status fetch fails", () => {
const error: FetchError = { type: "network_error", message: "Network error" };
mockedUseServerStatus.mockReturnValue({
status: null,
loading: false,
error: "Network error",
error,
refresh: vi.fn(),
});
renderBar();

View File

@@ -24,6 +24,7 @@ import {
tokens,
} from "@fluentui/react-components";
import { useJails } from "../../hooks/useJailList";
import { getErrorMessage } from "../../utils/fetchError";
import type { AssignActionRequest } from "../../types/config";
import { ApiError } from "../../api/client";
@@ -120,7 +121,7 @@ function AssignActionDialogInner({
useEffect(() => {
if (jailsError) {
setError(jailsError);
setError(getErrorMessage(jailsError));
}
}, [jailsError]);

View File

@@ -24,6 +24,7 @@ import {
tokens,
} from "@fluentui/react-components";
import { useJails } from "../../hooks/useJailList";
import { getErrorMessage } from "../../utils/fetchError";
import type { AssignFilterRequest } from "../../types/config";
import { ApiError } from "../../api/client";
@@ -120,7 +121,7 @@ function AssignFilterDialogInner({
useEffect(() => {
if (jailsError) {
setError(jailsError);
setError(getErrorMessage(jailsError));
}
}, [jailsError]);

View File

@@ -20,6 +20,7 @@ import {
} from "@fluentui/react-components";
import { Add24Regular } from "@fluentui/react-icons";
import type { FilterConfig } from "../../types/config";
import { getErrorMessage } from "../../utils/fetchError";
import { AssignFilterDialog } from "./AssignFilterDialog";
import { ConfigListDetail } from "./ConfigListDetail";
import { CreateFilterDialog } from "./CreateFilterDialog";
@@ -77,7 +78,7 @@ export function FiltersTab(): React.JSX.Element {
if (error) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
<MessageBarBody>{getErrorMessage(error)}</MessageBarBody>
</MessageBar>
);
}
@@ -102,7 +103,7 @@ export function FiltersTab(): React.JSX.Element {
selectedName={selectedName}
onSelect={setSelectedName}
loading={loading}
error={error}
error={error ? getErrorMessage(error) : null}
listHeader={listHeader}
>
{selectedFilter !== null && (

View File

@@ -32,7 +32,7 @@ import {
Play24Regular,
} from "@fluentui/react-icons";
import { ApiError } from "../../api/client";
import { handleFetchError } from "../../utils/fetchError";
import { handleFetchError, createStringErrorAdapter } from "../../utils/fetchError";
import type {
AddLogPathRequest,
BackendType,
@@ -733,7 +733,7 @@ function InactiveJailDetail({
onValidate()
.then((result) => { setValidationResult(result); })
.catch((err: unknown) => {
handleFetchError(err, setValidationError, "Validation request failed.");
handleFetchError(err, createStringErrorAdapter(setValidationError), "Validation request failed.");
})
.finally(() => { setValidating(false); });
}, [onValidate]);