diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 70c44ca..0dadcef 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,22 +1,3 @@ -## 13) Config page is over-centralized -- Where found: - - [frontend/src/pages/ConfigPage.tsx](frontend/src/pages/ConfigPage.tsx) -- Why this is needed: - - Tab orchestration and UI concerns are too concentrated. -- Goal: - - Decompose page into focused route/tab controllers. -- What to do: - - Split tab state/routing logic from rendering components. - - Extract domain-specific subcontainers. -- Possible traps and issues: - - Shared state sync across tabs can regress. -- Docs changes needed: - - Add config page composition map. -- Doc references: - - [Docs/Web-Development.md](Docs/Web-Development.md) - ---- - ## 14) Error boundary granularity is too coarse - Where found: - [frontend/src/App.tsx](frontend/src/App.tsx) diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md index e371a97..24b5950 100644 --- a/Docs/Web-Development.md +++ b/Docs/Web-Development.md @@ -808,15 +808,82 @@ When an API request returns 401 or 403: --- -## 12. Error Handling +## 12. Error Handling & Resilience + +### API Error Handling - Wrap API calls in `try-catch` inside hooks — components should never see raw exceptions. - **All hook catch blocks must use `handleFetchError` rather than directly calling `setError`.** This ensures auth errors (401/403) are routed to the global session-expiry flow instead of displaying confusing error text in the UI. Use the pattern: `handleFetchError(err, setError, "User-friendly fallback message")`. - Display user-friendly error messages — never expose stack traces or raw server responses in the UI. -- Use an **error boundary** (`ErrorBoundary` component) at the page level to catch unexpected render errors. - Log errors to the console (or a future logging service) with sufficient context for debugging. - Always handle the **loading**, **error**, and **empty** states for every data-driven component. +### Error Boundaries — Granular Fallback Strategy + +React error boundaries catch render-time exceptions and allow graceful fallback UI instead of a full white screen crash. BanGUI implements a **three-level error boundary strategy** to balance resilience with UX clarity: + +#### Top-Level Boundary (``) +- Wraps the entire application in `App.tsx` +- Catches critical failures in auth, theming, or routing infrastructure +- Shows a full-page fallback with reload button +- **Use case:** Rare catastrophic failures; most errors should be caught at lower levels + +#### Page-Level Boundary (``) +- Wraps each route in `App.tsx` (Dashboard, Map, Jails, Config, History, Blocklists, etc.) +- Catches render errors in page components and their children +- Shows a page-level fallback but preserves app shell (sidebar navigation stays functional) +- User can still navigate away via the sidebar and retry the page +- **Use case:** Page component crashes (component tree errors, unhandled render-time exceptions) + +**Example:** +```tsx + + + + } +/> +``` + +#### Section-Level Boundary (``) +- Wraps individual data-heavy components within a page (charts, tables, forms) +- Examples: `BanTrendChart`, `TopCountriesBarChart`, `BanTable`, `JailOverviewSection` +- Shows a section-level fallback card but the rest of the page remains functional +- User can retry just that section or interact with other sections +- **Use case:** Component-specific data fetching errors, rendering issues in risky components + +**Example:** +```tsx +
+
+ Ban Trend +
+ + + +
+``` + +### When to Use Each Boundary + +- **Page boundaries:** Always wrap page routes in `App.tsx` (`Dashboard`, `Map`, `Jails`, etc.) +- **Section boundaries:** Wrap risky components that fetch data or have complex side effects + - Data visualizations (charts) + - Data tables and lists + - Complex forms + - Components using external libraries (D3, Canvas, etc.) +- **Top-level boundary:** Leave as-is; only modify if auth/routing infrastructure changes + +### Boundary Best Practices + +- Do not over-use boundaries — too many nested boundaries can confuse error UX +- Ensure section fallback UI doesn't disrupt page layout (use consistent sizing/spacing) +- Provide meaningful error titles and messages (`pageName` and `sectionName` props) +- Retry buttons allow users to recover from transient failures without page reload +- Consider logging errors via `onError` callback for debugging and monitoring + --- ## 13. Performance diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e8322e..5930440 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,11 @@ * - `/history` — event history (protected) * - `/blocklists` — blocklist management (protected) * All unmatched paths redirect to `/`. + * + * Error Boundaries: + * - Top-level ErrorBoundary wraps the entire app shell (rare full-page reload). + * - Each page route wrapped in PageErrorBoundary (page fails but nav persists). + * - Risky sections within pages wrapped in SectionErrorBoundary (graceful degradation). */ import { lazy, Suspense } from "react"; @@ -29,6 +34,7 @@ import { TimezoneProvider } from "./providers/TimezoneProvider"; import { RequireAuth } from "./components/RequireAuth"; import { SetupGuard } from "./components/SetupGuard"; import { ErrorBoundary } from "./components/ErrorBoundary"; +import { PageErrorBoundary } from "./components/PageErrorBoundary"; import { MainLayout } from "./layouts/MainLayout"; const SetupPage = lazy(() => import("./pages/SetupPage").then((m) => ({ default: m.SetupPage }))); @@ -50,21 +56,34 @@ function AppContents(): React.JSX.Element { return ( - + }> {/* Setup wizard — always accessible; redirects to /login if already done */} - } /> + + + + } + /> {/* Login — requires setup to be complete */} - - + + + + + } /> @@ -80,13 +99,62 @@ function AppContents(): React.JSX.Element { } > - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* Fallback — redirect unknown paths to dashboard */} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 0d2a6db..331a967 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -2,6 +2,8 @@ * React error boundary component. * * Catches render-time exceptions in child components and shows a fallback UI. + * This is the base component; use PageErrorBoundary or SectionErrorBoundary + * for page and section-level boundaries. */ import React from "react"; import { Button, makeStyles, Text, tokens } from "@fluentui/react-components"; @@ -13,14 +15,22 @@ interface ErrorBoundaryState { interface ErrorBoundaryProps { children: React.ReactNode; + title?: string; + message?: string; + showReloadButton?: boolean; + isFullPage?: boolean; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; } interface ErrorBoundaryFallbackProps { + title: string; message: string; + showReloadButton: boolean; + isFullPage: boolean; onReload: () => void; } -const useFallbackStyles = makeStyles({ +const useFullPageStyles = makeStyles({ root: { display: "flex", flexDirection: "column", @@ -36,20 +46,46 @@ const useFallbackStyles = makeStyles({ }, }); -function ErrorBoundaryFallback({ message, onReload }: ErrorBoundaryFallbackProps): React.ReactElement { - const styles = useFallbackStyles(); +const useSectionStyles = makeStyles({ + root: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + padding: tokens.spacingVerticalM, + backgroundColor: tokens.colorNeutralBackground3, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorStatusWarningForeground1}`, + gap: tokens.spacingVerticalM, + }, + message: { + color: tokens.colorNeutralForeground1, + }, +}); + +function ErrorBoundaryFallback({ + title, + message, + showReloadButton, + isFullPage, + onReload, +}: ErrorBoundaryFallbackProps): React.ReactElement { + const fullPageStyles = useFullPageStyles(); + const sectionStyles = useSectionStyles(); + const styles = isFullPage ? fullPageStyles : sectionStyles; return (
- - Something went wrong + + {title} - + {message} - + {showReloadButton && ( + + )}
); } @@ -65,18 +101,37 @@ export class ErrorBoundary extends React.Component { - window.location.reload(); + if (this.props.isFullPage) { + window.location.reload(); + } else { + this.setState({ hasError: false, errorMessage: null }); + } }; render(): React.ReactNode { if (this.state.hasError) { + const { + title = "Something went wrong", + message = "Please try again or contact support if the problem persists.", + showReloadButton = true, + isFullPage = true, + } = this.props; + return ( ); diff --git a/frontend/src/components/PageErrorBoundary.tsx b/frontend/src/components/PageErrorBoundary.tsx new file mode 100644 index 0000000..227df56 --- /dev/null +++ b/frontend/src/components/PageErrorBoundary.tsx @@ -0,0 +1,42 @@ +/** + * Page-level error boundary. + * + * Wraps entire page components to catch rendering errors while preserving + * the app shell (navigation, theme, auth). When an error occurs, shows a + * full-page fallback but the user can still navigate away via the sidebar. + * + * Use this for wrapping page components in App.tsx routes. + */ +import React from "react"; +import { ErrorBoundary } from "./ErrorBoundary"; + +interface PageErrorBoundaryProps { + children: React.ReactNode; + pageName?: string; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; +} + +/** + * Wraps a page component with error boundary protection. + * + * @param children - Page component to wrap + * @param pageName - Name of page for error message (default: "Page") + * @param onError - Optional callback for error logging + */ +export function PageErrorBoundary({ + children, + pageName = "Page", + onError, +}: PageErrorBoundaryProps): React.JSX.Element { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/SectionErrorBoundary.tsx b/frontend/src/components/SectionErrorBoundary.tsx new file mode 100644 index 0000000..59d2da5 --- /dev/null +++ b/frontend/src/components/SectionErrorBoundary.tsx @@ -0,0 +1,46 @@ +/** + * Section-level error boundary. + * + * Wraps individual data-heavy sections (charts, tables, forms) within a page + * to provide graceful degradation. When an error occurs, only that section + * fails to render; the rest of the page remains functional. + * + * Use this to wrap: + * - Charts (BanTrendChart, TopCountriesBarChart, etc.) + * - Data tables (BanTable, JailOverviewSection, etc.) + * - Forms with complex logic + * - Any component that fetches data or has risky side effects + */ +import React from "react"; +import { ErrorBoundary } from "./ErrorBoundary"; + +interface SectionErrorBoundaryProps { + children: React.ReactNode; + sectionName?: string; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; +} + +/** + * Wraps a section component with error boundary protection. + * + * @param children - Section component to wrap + * @param sectionName - Name of section for error message (default: "Section") + * @param onError - Optional callback for error logging + */ +export function SectionErrorBoundary({ + children, + sectionName = "Section", + onError, +}: SectionErrorBoundaryProps): React.JSX.Element { + return ( + + {children} + + ); +} diff --git a/frontend/src/pages/BlocklistsPage.tsx b/frontend/src/pages/BlocklistsPage.tsx index 1255579..92ef99c 100644 --- a/frontend/src/pages/BlocklistsPage.tsx +++ b/frontend/src/pages/BlocklistsPage.tsx @@ -2,10 +2,12 @@ * BlocklistsPage — external IP blocklist source management. * * Responsible for composition of sources, schedule, and import log sections. + * Sections are wrapped with SectionErrorBoundary for independent resilience. */ import { useCallback, useState } from "react"; import { MessageBar, MessageBarBody, Text } from "@fluentui/react-components"; +import { SectionErrorBoundary } from "../components/SectionErrorBoundary"; import { useBlocklistStyles } from "../components/blocklist/blocklistStyles"; import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection"; import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection"; @@ -36,9 +38,17 @@ export function BlocklistsPage(): React.JSX.Element { )} - - - + + + + + + + + + + + - + + + ); } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 543f536..7508a59 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -4,6 +4,9 @@ * Composes the fail2ban server status bar at the top, a shared time-range * selector, and the ban list showing aggregate bans from the fail2ban * database. The time-range selection controls how far back to look. + * + * Sections are wrapped with SectionErrorBoundary to provide graceful + * degradation if individual charts or tables fail to render. */ import { Text, makeStyles, tokens } from "@fluentui/react-components"; @@ -14,6 +17,7 @@ import { DashboardFilterBar } from "../components/DashboardFilterBar"; import { ServerStatusBar } from "../components/ServerStatusBar"; import { TopCountriesBarChart } from "../components/TopCountriesBarChart"; import { TopCountriesPieChart } from "../components/TopCountriesPieChart"; +import { SectionErrorBoundary } from "../components/SectionErrorBoundary"; import { useCommonSectionStyles } from "../components/commonStyles"; import { useDashboardCountryData } from "../hooks/useDashboardCountryData"; import { DashboardFilterProvider, useDashboardFilters } from "./DashboardFilterProvider"; @@ -69,7 +73,8 @@ const useStyles = makeStyles({ * Main dashboard landing page. * * Displays the fail2ban server status, a time-range selector, and the - * ban list table. + * ban list table. Each section is protected with a SectionErrorBoundary + * so that a failure in one section does not crash the entire page. */ function DashboardPageContent(): React.JSX.Element { const styles = useStyles(); @@ -85,7 +90,9 @@ function DashboardPageContent(): React.JSX.Element { {/* ------------------------------------------------------------------ */} {/* Server status bar */} {/* ------------------------------------------------------------------ */} - + + + {/* ------------------------------------------------------------------ */} {/* Global filter bar */} @@ -109,11 +116,13 @@ function DashboardPageContent(): React.JSX.Element {
- + + +
@@ -127,28 +136,30 @@ function DashboardPageContent(): React.JSX.Element {
- -
-
- + + +
+
+ +
+
+ +
-
- -
-
- + +
@@ -164,11 +175,13 @@ function DashboardPageContent(): React.JSX.Element { {/* Ban table */}
- + + +
@@ -183,3 +196,4 @@ export function DashboardPage(): React.JSX.Element { ); } + diff --git a/frontend/src/pages/HistoryPage.tsx b/frontend/src/pages/HistoryPage.tsx index c9aa70c..3670dc7 100644 --- a/frontend/src/pages/HistoryPage.tsx +++ b/frontend/src/pages/HistoryPage.tsx @@ -4,6 +4,8 @@ * Shows a paginated, filterable table of every ban ever recorded in the * fail2ban database. Clicking an IP address opens a per-IP timeline view. * Rows with repeatedly-banned IPs are highlighted in amber. + * + * The history table is wrapped with SectionErrorBoundary for resilience. */ import { useCallback, useEffect, useMemo, useState } from "react"; @@ -30,6 +32,7 @@ import { ChevronRightRegular, } from "@fluentui/react-icons"; import { DashboardFilterBar } from "../components/DashboardFilterBar"; +import { SectionErrorBoundary } from "../components/SectionErrorBoundary"; import { useHistory } from "../hooks/useHistory"; import { IpDetailView } from "./history/IpDetailView"; import { HISTORY_PAGE_SIZE } from "../utils/constants"; @@ -236,12 +239,14 @@ export function HistoryPage(): React.JSX.Element { if (selectedIp !== null) { return (
- { - setSelectedIp(null); - }} - /> + + { + setSelectedIp(null); + }} + /> +
); } @@ -306,38 +311,40 @@ export function HistoryPage(): React.JSX.Element { {/* DataGrid table */} {/* ---------------------------------------------------------------- */} {!loading && !error && ( -
- `${item.ip}-${item.banned_at}`} - focusMode="composite" - > - - - {({ renderHeaderCell }) => ( - {renderHeaderCell()} - )} - - - > - {({ item }) => ( - - key={`${item.ip}-${item.banned_at}`} - className={ - item.ban_count >= HIGH_BAN_THRESHOLD - ? styles.highBanRow - : undefined - } - > - {({ renderCell }) => ( - {renderCell(item)} + +
+ `${item.ip}-${item.banned_at}`} + focusMode="composite" + > + + + {({ renderHeaderCell }) => ( + {renderHeaderCell()} )} - )} - - -
+ + > + {({ item }) => ( + + key={`${item.ip}-${item.banned_at}`} + className={ + item.ban_count >= HIGH_BAN_THRESHOLD + ? styles.highBanRow + : undefined + } + > + {({ renderCell }) => ( + {renderCell(item)} + )} + + )} + +
+
+
)} {/* ---------------------------------------------------------------- */} diff --git a/frontend/src/pages/JailsPage.tsx b/frontend/src/pages/JailsPage.tsx index e4aaf7d..b60fb2c 100644 --- a/frontend/src/pages/JailsPage.tsx +++ b/frontend/src/pages/JailsPage.tsx @@ -1,4 +1,5 @@ import { Text } from "@fluentui/react-components"; +import { SectionErrorBoundary } from "../components/SectionErrorBoundary"; import { useJailsPageStyles } from "./jails/jailsPageStyles"; import { JailOverviewSection } from "./jails/JailOverviewSection"; import { BanUnbanForm } from "./jails/BanUnbanForm"; @@ -19,11 +20,17 @@ function JailsPageContent(): React.JSX.Element { Jails - + + + - + + + - + + + ); } diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index a14b1ce..9eeb92a 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -4,6 +4,8 @@ * Shows a clickable SVG world map coloured by ban density, a time-range * selector, and a companion table filtered by the selected country (or all * bans when no country is selected). + * + * Critical sections wrapped with SectionErrorBoundary for resilience. */ import { useState, useMemo, useEffect } from "react"; @@ -23,6 +25,7 @@ import { DismissRegular, } from "@fluentui/react-icons"; import { DashboardFilterBar } from "../components/DashboardFilterBar"; +import { SectionErrorBoundary } from "../components/SectionErrorBoundary"; import { WorldMap } from "../components/WorldMap"; import { useMapData } from "../hooks/useMapData"; import { useMapColorThresholds } from "../hooks/useMapColorThresholds"; @@ -250,15 +253,17 @@ export function MapPage(): React.JSX.Element { {/* immediate visual feedback before the filtered data arrives. */} {/* ---------------------------------------------------------------- */} {!error && hasLoadedOnce && ( - + + + )} {/* ---------------------------------------------------------------- */} @@ -302,19 +307,21 @@ export function MapPage(): React.JSX.Element { {/* Companion bans table */} {/* ---------------------------------------------------------------- */} {!error && hasLoadedOnce && ( -
- -
+ +
+ +
+
)} );