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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
42
frontend/src/components/PageErrorBoundary.tsx
Normal file
42
frontend/src/components/PageErrorBoundary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/SectionErrorBoundary.tsx
Normal file
46
frontend/src/components/SectionErrorBoundary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user