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

@@ -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 {
</MessageBar>
)}
<BlocklistSourcesSection onRunImport={handleRunImport} runImportRunning={running} />
<BlocklistScheduleSection onRunImport={handleRunImport} runImportRunning={running} importLastResult={lastResult} />
<BlocklistImportLogSection />
<SectionErrorBoundary sectionName="Blocklist Sources">
<BlocklistSourcesSection onRunImport={handleRunImport} runImportRunning={running} />
</SectionErrorBoundary>
<SectionErrorBoundary sectionName="Blocklist Schedule">
<BlocklistScheduleSection onRunImport={handleRunImport} runImportRunning={running} importLastResult={lastResult} />
</SectionErrorBoundary>
<SectionErrorBoundary sectionName="Blocklist Import Log">
<BlocklistImportLogSection />
</SectionErrorBoundary>
<ImportResultDialog
open={importResultOpen}

View File

@@ -10,9 +10,13 @@
* Actions — structured action.d form editor
* Server — server-level settings, map thresholds, service health + log viewer
* 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 { SectionErrorBoundary } from "../components/SectionErrorBoundary";
import { ConfigPageContainer } from "../components/config/ConfigPageContainer";
const useStyles = makeStyles({
@@ -44,7 +48,9 @@ export function ConfigPage(): React.JSX.Element {
</Text>
</div>
<ConfigPageContainer />
<SectionErrorBoundary sectionName="Configuration Editor">
<ConfigPageContainer />
</SectionErrorBoundary>
</div>
);
}

View File

@@ -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 */}
{/* ------------------------------------------------------------------ */}
<ServerStatusBar />
<SectionErrorBoundary sectionName="Server Status">
<ServerStatusBar />
</SectionErrorBoundary>
{/* ------------------------------------------------------------------ */}
{/* Global filter bar */}
@@ -109,11 +116,13 @@ function DashboardPageContent(): React.JSX.Element {
</Text>
</div>
<div className={styles.tabContent}>
<BanTrendChart
timeRange={timeRange}
origin={originFilter}
source={source}
/>
<SectionErrorBoundary sectionName="Ban Trend Chart">
<BanTrendChart
timeRange={timeRange}
origin={originFilter}
source={source}
/>
</SectionErrorBoundary>
</div>
</div>
@@ -127,28 +136,30 @@ function DashboardPageContent(): React.JSX.Element {
</Text>
</div>
<div className={styles.tabContent}>
<ChartStateWrapper
isLoading={countryLoading}
error={countryError}
onRetry={reloadCountry}
isEmpty={!countryLoading && Object.keys(countries).length === 0}
emptyMessage="No ban data for the selected period."
>
<div className={styles.chartsRow}>
<div className={styles.chartCard}>
<TopCountriesPieChart
countries={countries}
countryNames={countryNames}
/>
<SectionErrorBoundary sectionName="Country Charts">
<ChartStateWrapper
isLoading={countryLoading}
error={countryError}
onRetry={reloadCountry}
isEmpty={!countryLoading && Object.keys(countries).length === 0}
emptyMessage="No ban data for the selected period."
>
<div className={styles.chartsRow}>
<div className={styles.chartCard}>
<TopCountriesPieChart
countries={countries}
countryNames={countryNames}
/>
</div>
<div className={styles.chartCard}>
<TopCountriesBarChart
countries={countries}
countryNames={countryNames}
/>
</div>
</div>
<div className={styles.chartCard}>
<TopCountriesBarChart
countries={countries}
countryNames={countryNames}
/>
</div>
</div>
</ChartStateWrapper>
</ChartStateWrapper>
</SectionErrorBoundary>
</div>
</div>
@@ -164,11 +175,13 @@ function DashboardPageContent(): React.JSX.Element {
{/* Ban table */}
<div className={styles.tabContent}>
<BanTable
timeRange={timeRange}
origin={originFilter}
source={source}
/>
<SectionErrorBoundary sectionName="Ban List">
<BanTable
timeRange={timeRange}
origin={originFilter}
source={source}
/>
</SectionErrorBoundary>
</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
* 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 (
<div className={styles.root}>
<IpDetailView
ip={selectedIp}
onBack={(): void => {
setSelectedIp(null);
}}
/>
<SectionErrorBoundary sectionName="IP Detail View">
<IpDetailView
ip={selectedIp}
onBack={(): void => {
setSelectedIp(null);
}}
/>
</SectionErrorBoundary>
</div>
);
}
@@ -306,38 +311,40 @@ export function HistoryPage(): React.JSX.Element {
{/* DataGrid table */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && (
<div className={styles.tableWrapper}>
<DataGrid
items={items}
columns={columns}
getRowId={(item: HistoryBanItem) => `${item.ip}-${item.banned_at}`}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ 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>
<SectionErrorBoundary sectionName="History Table">
<div className={styles.tableWrapper}>
<DataGrid
items={items}
columns={columns}
getRowId={(item: HistoryBanItem) => `${item.ip}-${item.banned_at}`}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
</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>
)}
</DataGridBody>
</DataGrid>
</div>
</SectionErrorBoundary>
)}
{/* ---------------------------------------------------------------- */}

View File

@@ -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
</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>
);
}

View File

@@ -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 && (
<WorldMap
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={setSelectedCountry}
thresholdLow={thresholdLow}
thresholdMedium={thresholdMedium}
thresholdHigh={thresholdHigh}
/>
<SectionErrorBoundary sectionName="World Map">
<WorldMap
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={setSelectedCountry}
thresholdLow={thresholdLow}
thresholdMedium={thresholdMedium}
thresholdHigh={thresholdHigh}
/>
</SectionErrorBoundary>
)}
{/* ---------------------------------------------------------------- */}
@@ -302,19 +307,21 @@ export function MapPage(): React.JSX.Element {
{/* Companion bans table */}
{/* ---------------------------------------------------------------- */}
{!error && hasLoadedOnce && (
<div className={mergeClasses(styles.tableWrapper, loading && styles.tableWrapperLoading)}>
<MapBansTable
pageBans={pageBans}
visibleCount={visibleBans.length}
page={page}
pageSize={pageSize}
totalPages={totalPages}
hasPrev={hasPrev}
hasNext={hasNext}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
<SectionErrorBoundary sectionName="Map Ban Table">
<div className={mergeClasses(styles.tableWrapper, loading && styles.tableWrapperLoading)}>
<MapBansTable
pageBans={pageBans}
visibleCount={visibleBans.length}
page={page}
pageSize={pageSize}
totalPages={totalPages}
hasPrev={hasPrev}
hasNext={hasNext}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
</SectionErrorBoundary>
)}
</div>
);