Improve error boundary granularity with page and section level boundaries

Implement three-level error boundary strategy:
- Top-level (app shell): catches critical failures
- Page-level: preserves navigation when page crashes
- Section-level: graceful degradation for charts/tables

Create new components:
- PageErrorBoundary: wraps page routes
- SectionErrorBoundary: wraps data-heavy sections

Enhance ErrorBoundary with customizable titles, messages, and reload behavior.

Apply page boundaries to all route handlers in App.tsx.

Apply section boundaries to:
- DashboardPage: server status, ban trend, country charts, ban list
- JailsPage: jail overview, ban/unban form, IP lookup
- MapPage: world map, ban table
- ConfigPage: configuration editor
- HistoryPage: history table, IP detail view
- BlocklistsPage: sources, schedule, import log

Update Web-Development.md with error boundary strategy documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 08:33:39 +02:00
parent 42beb9cf3b
commit da6433b2cf
12 changed files with 453 additions and 143 deletions

View File

@@ -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 ## 14) Error boundary granularity is too coarse
- Where found: - Where found:
- [frontend/src/App.tsx](frontend/src/App.tsx) - [frontend/src/App.tsx](frontend/src/App.tsx)

View File

@@ -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. - 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")`. - **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. - 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. - 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. - 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 (`<ErrorBoundary>`)
- 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 (`<PageErrorBoundary>`)
- 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
<Route
path="/jails"
element={
<PageErrorBoundary pageName="Jails">
<JailsPage />
</PageErrorBoundary>
}
/>
```
#### Section-Level Boundary (`<SectionErrorBoundary>`)
- 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
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">Ban Trend</Text>
</div>
<SectionErrorBoundary sectionName="Ban Trend Chart">
<BanTrendChart timeRange={timeRange} origin={originFilter} source={source} />
</SectionErrorBoundary>
</div>
```
### 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 ## 13. Performance

View File

@@ -17,6 +17,11 @@
* - `/history` — event history (protected) * - `/history` — event history (protected)
* - `/blocklists` — blocklist management (protected) * - `/blocklists` — blocklist management (protected)
* All unmatched paths redirect to `/`. * 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"; import { lazy, Suspense } from "react";
@@ -29,6 +34,7 @@ import { TimezoneProvider } from "./providers/TimezoneProvider";
import { RequireAuth } from "./components/RequireAuth"; import { RequireAuth } from "./components/RequireAuth";
import { SetupGuard } from "./components/SetupGuard"; import { SetupGuard } from "./components/SetupGuard";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { PageErrorBoundary } from "./components/PageErrorBoundary";
import { MainLayout } from "./layouts/MainLayout"; import { MainLayout } from "./layouts/MainLayout";
const SetupPage = lazy(() => import("./pages/SetupPage").then((m) => ({ default: m.SetupPage }))); const SetupPage = lazy(() => import("./pages/SetupPage").then((m) => ({ default: m.SetupPage })));
@@ -50,21 +56,34 @@ function AppContents(): React.JSX.Element {
return ( return (
<FluentProvider theme={theme}> <FluentProvider theme={theme}>
<ErrorBoundary> <ErrorBoundary
title="Application Error"
message="The application encountered a critical error. Reloading may help."
isFullPage={true}
>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Suspense fallback={<Spinner size="large" label="Loading…" />}> <Suspense fallback={<Spinner size="large" label="Loading…" />}>
<AuthProvider> <AuthProvider>
<Routes> <Routes>
{/* Setup wizard — always accessible; redirects to /login if already done */} {/* Setup wizard — always accessible; redirects to /login if already done */}
<Route path="/setup" element={<SetupPage />} /> <Route
path="/setup"
element={
<PageErrorBoundary pageName="Setup">
<SetupPage />
</PageErrorBoundary>
}
/>
{/* Login — requires setup to be complete */} {/* Login — requires setup to be complete */}
<Route <Route
path="/login" path="/login"
element={ element={
<SetupGuard> <PageErrorBoundary pageName="Login">
<LoginPage /> <SetupGuard>
</SetupGuard> <LoginPage />
</SetupGuard>
</PageErrorBoundary>
} }
/> />
@@ -80,13 +99,62 @@ function AppContents(): React.JSX.Element {
</SetupGuard> </SetupGuard>
} }
> >
<Route index element={<DashboardPage />} /> <Route
<Route path="/map" element={<MapPage />} /> index
<Route path="/jails" element={<JailsPage />} /> element={
<Route path="/jails/:name" element={<JailDetailPage />} /> <PageErrorBoundary pageName="Dashboard">
<Route path="/config" element={<ConfigPage />} /> <DashboardPage />
<Route path="/history" element={<HistoryPage />} /> </PageErrorBoundary>
<Route path="/blocklists" element={<BlocklistsPage />} /> }
/>
<Route
path="/map"
element={
<PageErrorBoundary pageName="Map">
<MapPage />
</PageErrorBoundary>
}
/>
<Route
path="/jails"
element={
<PageErrorBoundary pageName="Jails">
<JailsPage />
</PageErrorBoundary>
}
/>
<Route
path="/jails/:name"
element={
<PageErrorBoundary pageName="Jail Details">
<JailDetailPage />
</PageErrorBoundary>
}
/>
<Route
path="/config"
element={
<PageErrorBoundary pageName="Configuration">
<ConfigPage />
</PageErrorBoundary>
}
/>
<Route
path="/history"
element={
<PageErrorBoundary pageName="History">
<HistoryPage />
</PageErrorBoundary>
}
/>
<Route
path="/blocklists"
element={
<PageErrorBoundary pageName="Blocklists">
<BlocklistsPage />
</PageErrorBoundary>
}
/>
</Route> </Route>
{/* Fallback — redirect unknown paths to dashboard */} {/* Fallback — redirect unknown paths to dashboard */}

View File

@@ -2,6 +2,8 @@
* React error boundary component. * React error boundary component.
* *
* Catches render-time exceptions in child components and shows a fallback UI. * 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 React from "react";
import { Button, makeStyles, Text, tokens } from "@fluentui/react-components"; import { Button, makeStyles, Text, tokens } from "@fluentui/react-components";
@@ -13,14 +15,22 @@ interface ErrorBoundaryState {
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: React.ReactNode; children: React.ReactNode;
title?: string;
message?: string;
showReloadButton?: boolean;
isFullPage?: boolean;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
} }
interface ErrorBoundaryFallbackProps { interface ErrorBoundaryFallbackProps {
title: string;
message: string; message: string;
showReloadButton: boolean;
isFullPage: boolean;
onReload: () => void; onReload: () => void;
} }
const useFallbackStyles = makeStyles({ const useFullPageStyles = makeStyles({
root: { root: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@@ -36,20 +46,46 @@ const useFallbackStyles = makeStyles({
}, },
}); });
function ErrorBoundaryFallback({ message, onReload }: ErrorBoundaryFallbackProps): React.ReactElement { const useSectionStyles = makeStyles({
const styles = useFallbackStyles(); 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 ( return (
<div className={styles.root} role="alert"> <div className={styles.root} role="alert">
<Text as="h1" size={700} weight="semibold"> <Text as={isFullPage ? "h1" : "h2"} size={isFullPage ? 700 : 500} weight="semibold">
Something went wrong {title}
</Text> </Text>
<Text size={300} className={styles.message}> <Text size={isFullPage ? 300 : 200} className={isFullPage ? fullPageStyles.message : sectionStyles.message}>
{message} {message}
</Text> </Text>
<Button appearance="primary" onClick={onReload}> {showReloadButton && (
Reload <Button appearance="primary" onClick={onReload}>
</Button> {isFullPage ? "Reload Page" : "Retry"}
</Button>
)}
</div> </div>
); );
} }
@@ -65,18 +101,37 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
} }
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error("ErrorBoundary caught an error", { error, errorInfo }); const { onError } = this.props;
if (onError) {
onError(error, errorInfo);
} else {
console.error("ErrorBoundary caught an error", { error, errorInfo });
}
} }
handleReload = (): void => { handleReload = (): void => {
window.location.reload(); if (this.props.isFullPage) {
window.location.reload();
} else {
this.setState({ hasError: false, errorMessage: null });
}
}; };
render(): React.ReactNode { render(): React.ReactNode {
if (this.state.hasError) { 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 ( return (
<ErrorBoundaryFallback <ErrorBoundaryFallback
message={this.state.errorMessage ?? "Please try reloading the page."} title={title}
message={message}
showReloadButton={showReloadButton}
isFullPage={isFullPage}
onReload={this.handleReload} onReload={this.handleReload}
/> />
); );

View File

@@ -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 (
<ErrorBoundary
title={`${pageName} Error`}
message={`The ${pageName.toLowerCase()} encountered an error and could not load. Please try navigating to another page or reloading.`}
showReloadButton={true}
isFullPage={false}
onError={onError}
>
{children}
</ErrorBoundary>
);
}

View File

@@ -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 (
<ErrorBoundary
title={`${sectionName} Unavailable`}
message={`Could not load ${sectionName.toLowerCase()}. The rest of the page is still functional.`}
showReloadButton={true}
isFullPage={false}
onError={onError}
>
{children}
</ErrorBoundary>
);
}

View File

@@ -2,10 +2,12 @@
* BlocklistsPage — external IP blocklist source management. * BlocklistsPage — external IP blocklist source management.
* *
* Responsible for composition of sources, schedule, and import log sections. * Responsible for composition of sources, schedule, and import log sections.
* Sections are wrapped with SectionErrorBoundary for independent resilience.
*/ */
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { MessageBar, MessageBarBody, Text } from "@fluentui/react-components"; import { MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
import { useBlocklistStyles } from "../components/blocklist/blocklistStyles"; import { useBlocklistStyles } from "../components/blocklist/blocklistStyles";
import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection"; import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection";
import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection"; import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection";
@@ -36,9 +38,17 @@ export function BlocklistsPage(): React.JSX.Element {
</MessageBar> </MessageBar>
)} )}
<BlocklistSourcesSection onRunImport={handleRunImport} runImportRunning={running} /> <SectionErrorBoundary sectionName="Blocklist Sources">
<BlocklistScheduleSection onRunImport={handleRunImport} runImportRunning={running} importLastResult={lastResult} /> <BlocklistSourcesSection onRunImport={handleRunImport} runImportRunning={running} />
<BlocklistImportLogSection /> </SectionErrorBoundary>
<SectionErrorBoundary sectionName="Blocklist Schedule">
<BlocklistScheduleSection onRunImport={handleRunImport} runImportRunning={running} importLastResult={lastResult} />
</SectionErrorBoundary>
<SectionErrorBoundary sectionName="Blocklist Import Log">
<BlocklistImportLogSection />
</SectionErrorBoundary>
<ImportResultDialog <ImportResultDialog
open={importResultOpen} open={importResultOpen}

View File

@@ -10,9 +10,13 @@
* Actions — structured action.d form editor * Actions — structured action.d form editor
* Server — server-level settings, map thresholds, service health + log viewer * Server — server-level settings, map thresholds, service health + log viewer
* Regex Tester — live pattern tester * Regex Tester — live pattern tester
*
* Configuration content is wrapped with SectionErrorBoundary to prevent
* one tab or editor from crashing the entire page.
*/ */
import { Text, makeStyles, tokens } from "@fluentui/react-components"; import { Text, makeStyles, tokens } from "@fluentui/react-components";
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
import { ConfigPageContainer } from "../components/config/ConfigPageContainer"; import { ConfigPageContainer } from "../components/config/ConfigPageContainer";
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -44,7 +48,9 @@ export function ConfigPage(): React.JSX.Element {
</Text> </Text>
</div> </div>
<ConfigPageContainer /> <SectionErrorBoundary sectionName="Configuration Editor">
<ConfigPageContainer />
</SectionErrorBoundary>
</div> </div>
); );
} }

View File

@@ -4,6 +4,9 @@
* Composes the fail2ban server status bar at the top, a shared time-range * Composes the fail2ban server status bar at the top, a shared time-range
* selector, and the ban list showing aggregate bans from the fail2ban * selector, and the ban list showing aggregate bans from the fail2ban
* database. The time-range selection controls how far back to look. * 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"; import { Text, makeStyles, tokens } from "@fluentui/react-components";
@@ -14,6 +17,7 @@ import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { ServerStatusBar } from "../components/ServerStatusBar"; import { ServerStatusBar } from "../components/ServerStatusBar";
import { TopCountriesBarChart } from "../components/TopCountriesBarChart"; import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
import { TopCountriesPieChart } from "../components/TopCountriesPieChart"; import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
import { useCommonSectionStyles } from "../components/commonStyles"; import { useCommonSectionStyles } from "../components/commonStyles";
import { useDashboardCountryData } from "../hooks/useDashboardCountryData"; import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
import { DashboardFilterProvider, useDashboardFilters } from "./DashboardFilterProvider"; import { DashboardFilterProvider, useDashboardFilters } from "./DashboardFilterProvider";
@@ -69,7 +73,8 @@ const useStyles = makeStyles({
* Main dashboard landing page. * Main dashboard landing page.
* *
* Displays the fail2ban server status, a time-range selector, and the * 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 { function DashboardPageContent(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
@@ -85,7 +90,9 @@ function DashboardPageContent(): React.JSX.Element {
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Server status bar */} {/* Server status bar */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<ServerStatusBar /> <SectionErrorBoundary sectionName="Server Status">
<ServerStatusBar />
</SectionErrorBoundary>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Global filter bar */} {/* Global filter bar */}
@@ -109,11 +116,13 @@ function DashboardPageContent(): React.JSX.Element {
</Text> </Text>
</div> </div>
<div className={styles.tabContent}> <div className={styles.tabContent}>
<BanTrendChart <SectionErrorBoundary sectionName="Ban Trend Chart">
timeRange={timeRange} <BanTrendChart
origin={originFilter} timeRange={timeRange}
source={source} origin={originFilter}
/> source={source}
/>
</SectionErrorBoundary>
</div> </div>
</div> </div>
@@ -127,28 +136,30 @@ function DashboardPageContent(): React.JSX.Element {
</Text> </Text>
</div> </div>
<div className={styles.tabContent}> <div className={styles.tabContent}>
<ChartStateWrapper <SectionErrorBoundary sectionName="Country Charts">
isLoading={countryLoading} <ChartStateWrapper
error={countryError} isLoading={countryLoading}
onRetry={reloadCountry} error={countryError}
isEmpty={!countryLoading && Object.keys(countries).length === 0} onRetry={reloadCountry}
emptyMessage="No ban data for the selected period." isEmpty={!countryLoading && Object.keys(countries).length === 0}
> emptyMessage="No ban data for the selected period."
<div className={styles.chartsRow}> >
<div className={styles.chartCard}> <div className={styles.chartsRow}>
<TopCountriesPieChart <div className={styles.chartCard}>
countries={countries} <TopCountriesPieChart
countryNames={countryNames} countries={countries}
/> countryNames={countryNames}
/>
</div>
<div className={styles.chartCard}>
<TopCountriesBarChart
countries={countries}
countryNames={countryNames}
/>
</div>
</div> </div>
<div className={styles.chartCard}> </ChartStateWrapper>
<TopCountriesBarChart </SectionErrorBoundary>
countries={countries}
countryNames={countryNames}
/>
</div>
</div>
</ChartStateWrapper>
</div> </div>
</div> </div>
@@ -164,11 +175,13 @@ function DashboardPageContent(): React.JSX.Element {
{/* Ban table */} {/* Ban table */}
<div className={styles.tabContent}> <div className={styles.tabContent}>
<BanTable <SectionErrorBoundary sectionName="Ban List">
timeRange={timeRange} <BanTable
origin={originFilter} timeRange={timeRange}
source={source} origin={originFilter}
/> source={source}
/>
</SectionErrorBoundary>
</div> </div>
</div> </div>
</div> </div>
@@ -183,3 +196,4 @@ export function DashboardPage(): React.JSX.Element {
); );
} }

View File

@@ -4,6 +4,8 @@
* Shows a paginated, filterable table of every ban ever recorded in the * Shows a paginated, filterable table of every ban ever recorded in the
* fail2ban database. Clicking an IP address opens a per-IP timeline view. * fail2ban database. Clicking an IP address opens a per-IP timeline view.
* Rows with repeatedly-banned IPs are highlighted in amber. * 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"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -30,6 +32,7 @@ import {
ChevronRightRegular, ChevronRightRegular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { DashboardFilterBar } from "../components/DashboardFilterBar"; import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
import { useHistory } from "../hooks/useHistory"; import { useHistory } from "../hooks/useHistory";
import { IpDetailView } from "./history/IpDetailView"; import { IpDetailView } from "./history/IpDetailView";
import { HISTORY_PAGE_SIZE } from "../utils/constants"; import { HISTORY_PAGE_SIZE } from "../utils/constants";
@@ -236,12 +239,14 @@ export function HistoryPage(): React.JSX.Element {
if (selectedIp !== null) { if (selectedIp !== null) {
return ( return (
<div className={styles.root}> <div className={styles.root}>
<IpDetailView <SectionErrorBoundary sectionName="IP Detail View">
ip={selectedIp} <IpDetailView
onBack={(): void => { ip={selectedIp}
setSelectedIp(null); onBack={(): void => {
}} setSelectedIp(null);
/> }}
/>
</SectionErrorBoundary>
</div> </div>
); );
} }
@@ -306,38 +311,40 @@ export function HistoryPage(): React.JSX.Element {
{/* DataGrid table */} {/* DataGrid table */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{!loading && !error && ( {!loading && !error && (
<div className={styles.tableWrapper}> <SectionErrorBoundary sectionName="History Table">
<DataGrid <div className={styles.tableWrapper}>
items={items} <DataGrid
columns={columns} items={items}
getRowId={(item: HistoryBanItem) => `${item.ip}-${item.banned_at}`} columns={columns}
focusMode="composite" getRowId={(item: HistoryBanItem) => `${item.ip}-${item.banned_at}`}
> focusMode="composite"
<DataGridHeader> >
<DataGridRow> <DataGridHeader>
{({ renderHeaderCell }) => ( <DataGridRow>
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell> {({ renderHeaderCell }) => (
)} <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
</DataGridRow>
</DataGridHeader>
<DataGridBody<HistoryBanItem>>
{({ item }) => (
<DataGridRow<HistoryBanItem>
key={`${item.ip}-${item.banned_at}`}
className={
item.ban_count >= HIGH_BAN_THRESHOLD
? styles.highBanRow
: undefined
}
>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)} )}
</DataGridRow> </DataGridRow>
)} </DataGridHeader>
</DataGridBody> <DataGridBody<HistoryBanItem>>
</DataGrid> {({ item }) => (
</div> <DataGridRow<HistoryBanItem>
key={`${item.ip}-${item.banned_at}`}
className={
item.ban_count >= HIGH_BAN_THRESHOLD
? styles.highBanRow
: undefined
}
>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
</SectionErrorBoundary>
)} )}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}

View File

@@ -1,4 +1,5 @@
import { Text } from "@fluentui/react-components"; import { Text } from "@fluentui/react-components";
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
import { useJailsPageStyles } from "./jails/jailsPageStyles"; import { useJailsPageStyles } from "./jails/jailsPageStyles";
import { JailOverviewSection } from "./jails/JailOverviewSection"; import { JailOverviewSection } from "./jails/JailOverviewSection";
import { BanUnbanForm } from "./jails/BanUnbanForm"; import { BanUnbanForm } from "./jails/BanUnbanForm";
@@ -19,11 +20,17 @@ function JailsPageContent(): React.JSX.Element {
Jails Jails
</Text> </Text>
<JailOverviewSection /> <SectionErrorBoundary sectionName="Jail Overview">
<JailOverviewSection />
</SectionErrorBoundary>
<BanUnbanForm jailNames={jailNames} onBan={banIp} onUnban={unbanIp} /> <SectionErrorBoundary sectionName="Ban/Unban Form">
<BanUnbanForm jailNames={jailNames} onBan={banIp} onUnban={unbanIp} />
</SectionErrorBoundary>
<IpLookupSection /> <SectionErrorBoundary sectionName="IP Lookup">
<IpLookupSection />
</SectionErrorBoundary>
</div> </div>
); );
} }

View File

@@ -4,6 +4,8 @@
* Shows a clickable SVG world map coloured by ban density, a time-range * 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 * selector, and a companion table filtered by the selected country (or all
* bans when no country is selected). * bans when no country is selected).
*
* Critical sections wrapped with SectionErrorBoundary for resilience.
*/ */
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect } from "react";
@@ -23,6 +25,7 @@ import {
DismissRegular, DismissRegular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { DashboardFilterBar } from "../components/DashboardFilterBar"; import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
import { WorldMap } from "../components/WorldMap"; import { WorldMap } from "../components/WorldMap";
import { useMapData } from "../hooks/useMapData"; import { useMapData } from "../hooks/useMapData";
import { useMapColorThresholds } from "../hooks/useMapColorThresholds"; import { useMapColorThresholds } from "../hooks/useMapColorThresholds";
@@ -250,15 +253,17 @@ export function MapPage(): React.JSX.Element {
{/* immediate visual feedback before the filtered data arrives. */} {/* immediate visual feedback before the filtered data arrives. */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{!error && hasLoadedOnce && ( {!error && hasLoadedOnce && (
<WorldMap <SectionErrorBoundary sectionName="World Map">
countries={countries} <WorldMap
countryNames={countryNames} countries={countries}
selectedCountry={selectedCountry} countryNames={countryNames}
onSelectCountry={setSelectedCountry} selectedCountry={selectedCountry}
thresholdLow={thresholdLow} onSelectCountry={setSelectedCountry}
thresholdMedium={thresholdMedium} thresholdLow={thresholdLow}
thresholdHigh={thresholdHigh} thresholdMedium={thresholdMedium}
/> thresholdHigh={thresholdHigh}
/>
</SectionErrorBoundary>
)} )}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
@@ -302,19 +307,21 @@ export function MapPage(): React.JSX.Element {
{/* Companion bans table */} {/* Companion bans table */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{!error && hasLoadedOnce && ( {!error && hasLoadedOnce && (
<div className={mergeClasses(styles.tableWrapper, loading && styles.tableWrapperLoading)}> <SectionErrorBoundary sectionName="Map Ban Table">
<MapBansTable <div className={mergeClasses(styles.tableWrapper, loading && styles.tableWrapperLoading)}>
pageBans={pageBans} <MapBansTable
visibleCount={visibleBans.length} pageBans={pageBans}
page={page} visibleCount={visibleBans.length}
pageSize={pageSize} page={page}
totalPages={totalPages} pageSize={pageSize}
hasPrev={hasPrev} totalPages={totalPages}
hasNext={hasNext} hasPrev={hasPrev}
onPageChange={setPage} hasNext={hasNext}
onPageSizeChange={setPageSize} onPageChange={setPage}
/> onPageSizeChange={setPageSize}
</div> />
</div>
</SectionErrorBoundary>
)} )}
</div> </div>
); );