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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user