From 1694ac17f82a86e0db7f2cbc3b8cb289877f0239 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 20 Apr 2026 20:00:59 +0200 Subject: [PATCH] Add React.memo to heavy dashboard components --- frontend/src/components/BanTable.tsx | 6 +-- frontend/src/components/BanTrendChart.tsx | 6 +-- .../src/components/JailDistributionChart.tsx | 6 +-- frontend/src/components/ServerStatusBar.tsx | 5 ++- .../src/components/TopCountriesBarChart.tsx | 6 +-- .../src/components/TopCountriesPieChart.tsx | 6 +-- frontend/src/components/WorldMap.tsx | 41 ++++++++++++++++++- 7 files changed, 58 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/BanTable.tsx b/frontend/src/components/BanTable.tsx index 83a9242..0c69f14 100644 --- a/frontend/src/components/BanTable.tsx +++ b/frontend/src/components/BanTable.tsx @@ -8,7 +8,7 @@ * Columns: Time, IP, Service, Country, Jail, Ban Count. */ -import { useMemo } from "react"; +import { memo, useMemo } from "react"; import { Badge, Button, @@ -191,7 +191,7 @@ function buildBanColumns(styles: ReturnType): TableColumnDefin * @param props.timeRange - Active time-range preset from the parent page. * @param props.origin - Active origin filter from the parent page. */ -export function BanTable({ timeRange, origin = "all", source = "fail2ban" }: BanTableProps): React.JSX.Element { +export const BanTable = memo(function BanTable({ timeRange, origin = "all", source = "fail2ban" }: BanTableProps): React.JSX.Element { const styles = useStyles(); const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin, source); @@ -276,4 +276,4 @@ export function BanTable({ timeRange, origin = "all", source = "fail2ban" }: Ban ); -} +}); diff --git a/frontend/src/components/BanTrendChart.tsx b/frontend/src/components/BanTrendChart.tsx index 0b38783..ac4a834 100644 --- a/frontend/src/components/BanTrendChart.tsx +++ b/frontend/src/components/BanTrendChart.tsx @@ -4,7 +4,7 @@ * Calls `useBanTrend` internally and handles loading, error, and empty states. */ -import { useMemo } from "react"; +import { memo, useMemo } from "react"; import { Area, AreaChart, @@ -194,7 +194,7 @@ function TrendTooltip(props: TrendTooltipProps): React.JSX.Element | null { * * @param props - `timeRange` and `origin` filter props. */ -export function BanTrendChart({ +export const BanTrendChart = memo(function BanTrendChart({ timeRange, origin, source = "fail2ban", @@ -271,5 +271,5 @@ export function BanTrendChart({ ); -} +}); diff --git a/frontend/src/components/JailDistributionChart.tsx b/frontend/src/components/JailDistributionChart.tsx index 9464a83..10377e7 100644 --- a/frontend/src/components/JailDistributionChart.tsx +++ b/frontend/src/components/JailDistributionChart.tsx @@ -6,7 +6,7 @@ * empty states so the parent only needs to pass filter props. */ -import { useMemo } from "react"; +import { memo, useMemo } from "react"; import { Bar, BarChart, @@ -129,7 +129,7 @@ function JailTooltip(props: TooltipContentProps): React.JSX.Element | null { * * @param props - `timeRange` and `origin` filter props. */ -export function JailDistributionChart({ +export const JailDistributionChart = memo(function JailDistributionChart({ timeRange, origin, }: JailDistributionChartProps): React.JSX.Element { @@ -185,4 +185,4 @@ export function JailDistributionChart({ ); -} +}); diff --git a/frontend/src/components/ServerStatusBar.tsx b/frontend/src/components/ServerStatusBar.tsx index 074b25c..4db624f 100644 --- a/frontend/src/components/ServerStatusBar.tsx +++ b/frontend/src/components/ServerStatusBar.tsx @@ -9,6 +9,7 @@ * via the {@link useServerStatus} hook. */ +import { memo } from "react"; import { Badge, Button, @@ -68,7 +69,7 @@ const useStyles = makeStyles({ * Render this at the top of the dashboard page (and any page that should * show live server status). */ -export function ServerStatusBar(): React.JSX.Element { +export const ServerStatusBar = memo(function ServerStatusBar(): React.JSX.Element { const styles = useStyles(); const { status, loading, error, refresh } = useServerStatus(); @@ -165,4 +166,4 @@ export function ServerStatusBar(): React.JSX.Element { ); -} +}); diff --git a/frontend/src/components/TopCountriesBarChart.tsx b/frontend/src/components/TopCountriesBarChart.tsx index da65850..679f04d 100644 --- a/frontend/src/components/TopCountriesBarChart.tsx +++ b/frontend/src/components/TopCountriesBarChart.tsx @@ -3,7 +3,7 @@ * by ban count, sorted descending. */ -import { useMemo } from "react"; +import { memo, useMemo } from "react"; import { Bar, BarChart, @@ -137,7 +137,7 @@ function BarTooltip(props: TooltipContentProps): React.JSX.Element | null { * @param props - `countries` map and `countryNames` map from the * `/api/dashboard/bans/by-country` response. */ -export function TopCountriesBarChart({ +export const TopCountriesBarChart = memo(function TopCountriesBarChart({ countries, countryNames, }: TopCountriesBarChartProps): React.JSX.Element { @@ -193,4 +193,4 @@ export function TopCountriesBarChart({ ); -} +}); diff --git a/frontend/src/components/TopCountriesPieChart.tsx b/frontend/src/components/TopCountriesPieChart.tsx index a017a1a..945befe 100644 --- a/frontend/src/components/TopCountriesPieChart.tsx +++ b/frontend/src/components/TopCountriesPieChart.tsx @@ -3,7 +3,7 @@ * an "Other" slice aggregating all remaining countries. */ -import { useMemo } from "react"; +import { memo, useMemo } from "react"; import { Cell, Legend, @@ -129,7 +129,7 @@ function PieTooltip(props: TooltipContentProps): React.JSX.Element | null { * @param props - `countries` map and `countryNames` map from the * `/api/dashboard/bans/by-country` response. */ -export function TopCountriesPieChart({ +export const TopCountriesPieChart = memo(function TopCountriesPieChart({ countries, countryNames, }: TopCountriesPieChartProps): React.JSX.Element { @@ -194,4 +194,4 @@ export function TopCountriesPieChart({ ); -} +}); diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx index 71e2a4d..e927ec5 100644 --- a/frontend/src/components/WorldMap.tsx +++ b/frontend/src/components/WorldMap.tsx @@ -7,6 +7,7 @@ import { createPortal } from "react-dom"; import { + memo, useCallback, useEffect, useMemo, @@ -130,7 +131,43 @@ interface WorldMapProps { thresholdHigh?: number; } -export function WorldMap({ +function areStringRecordShallowEqual( + left: Record, + right: Record, +): boolean { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + return leftKeys.every((key) => left[key] === right[key]); +} + +function areNumberRecordShallowEqual( + left: Record, + right: Record, +): boolean { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + return leftKeys.every((key) => left[key] === right[key]); +} + +function areWorldMapPropsEqual(prev: WorldMapProps, next: WorldMapProps): boolean { + return ( + prev.selectedCountry === next.selectedCountry && + prev.onSelectCountry === next.onSelectCountry && + prev.thresholdLow === next.thresholdLow && + prev.thresholdMedium === next.thresholdMedium && + prev.thresholdHigh === next.thresholdHigh && + areNumberRecordShallowEqual(prev.countries, next.countries) && + areStringRecordShallowEqual(prev.countryNames ?? {}, next.countryNames ?? {}) + ); +} + +function WorldMapComponent({ countries, countryNames, selectedCountry, @@ -430,3 +467,5 @@ export function WorldMap({ ); } + +export const WorldMap = memo(WorldMapComponent, areWorldMapPropsEqual);