From 6189daf6a42aa7d0a270d7efa4d2be6f4106b71e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 16:07:30 +0200 Subject: [PATCH] perf(beszel): fix live mode re-render cascade for Docker charts - Stabilize useContainerNames with ref comparison so it returns the same array reference when container names haven't changed - Memoize containerSeries (was computed inline every render) - Hoist all system chart series arrays into useMemo - Hoist activeDotProps/dotProps to module-level constants - Wrap BeszelAreaChart and ChartPanel in React.memo --- .../src/beszel-system-stats/component.tsx | 51 ++++++++++++------- packages/widgets/src/beszel/_shared/chart.tsx | 30 +++++++---- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 012abaff3..cb17fcc3f 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -105,6 +105,30 @@ export default function BeszelSystemStatsWidget({ [t], ); + const cpuSeries = useMemo(() => [{ name: t("chart.cpu.series"), color: "teal.6" }], [t]); + const memorySeries = useMemo( + () => [ + { name: t("chart.memory.series"), color: "teal.5" }, + { name: t("chart.memory.cache"), color: "teal.8" }, + ], + [t], + ); + const diskSeries = useMemo(() => [{ name: t("chart.disk.series"), color: "grape.6" }], [t]); + const diskIOSeries = useMemo( + () => [ + { name: t("chart.diskIO.write"), color: "orange.6" }, + { name: t("chart.diskIO.read"), color: "blue.6" }, + ], + [t], + ); + const networkSeries = useMemo( + () => [ + { name: t("chart.network.sent"), color: "blue.6" }, + { name: t("chart.network.recv"), color: "teal.6" }, + ], + [t], + ); + const cpuData = useSystemChartData(activeStats?.systemStats, cpuMap, isLive); const memoryData = useSystemChartData(activeStats?.systemStats, memoryMap, isLive); const diskData = useSystemChartData(activeStats?.systemStats, diskMap, isLive); @@ -116,10 +140,10 @@ export default function BeszelSystemStatsWidget({ const dockerMemData = useDockerChartData(activeStats?.containerStats, containerNames, "memory", isLive); const dockerNetData = useDockerChartData(activeStats?.containerStats, containerNames, "network", isLive); - const containerSeries = containerNames.map((name, i) => ({ - name, - color: containerColors[i % containerColors.length] as string, - })); + const containerSeries = useMemo( + () => containerNames.map((name, i) => ({ name, color: containerColors[i % containerColors.length] as string })), + [containerNames], + ); const cols = width > 600 ? 2 : 1; @@ -209,7 +233,7 @@ export default function BeszelSystemStatsWidget({ @@ -238,7 +259,7 @@ export default function BeszelSystemStatsWidget({ @@ -250,10 +271,7 @@ export default function BeszelSystemStatsWidget({ @@ -265,10 +283,7 @@ export default function BeszelSystemStatsWidget({ diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index 67b8957da..6db2b30f5 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { memo, useMemo, useRef } from "react"; import { Group, Stack, Text } from "@mantine/core"; import type { AreaChartProps } from "@mantine/charts"; import { AreaChart } from "@mantine/charts"; @@ -19,6 +19,8 @@ function prepareRecords(records: T[], live: boolean) { } const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; +const activeDot = { r: 1, strokeWidth: 3 } as const; +const hiddenDot = { r: 0, strokeWidth: 0 } as const; interface ChartPanelProps { title: string; @@ -26,7 +28,7 @@ interface ChartPanelProps { children: React.ReactNode; } -export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => ( +export const ChartPanel = memo(({ title, subtitle, children }: ChartPanelProps) => ( @@ -40,14 +42,14 @@ export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => ( {children} -); +)); type BeszelAreaChartProps = Omit & { yAxisFormatter: (value: number) => string; yAxisDomain?: [number, string]; }; -export const BeszelAreaChart = ({ +export const BeszelAreaChart = memo(({ yAxisFormatter, yAxisDomain, yAxisProps, @@ -64,8 +66,8 @@ export const BeszelAreaChart = ({ type={type} strokeWidth={1} fillOpacity={0.2} - activeDotProps={{ r: 1, strokeWidth: 3 }} - dotProps={{ r: 0, strokeWidth: 0 }} + activeDotProps={activeDot} + dotProps={hiddenDot} withXAxis withYAxis w="100%" @@ -80,7 +82,7 @@ export const BeszelAreaChart = ({ }} {...props} /> -); +)); export const useSystemChartData = ( systemStats: BeszelSystemStatsRecord[] | undefined, @@ -96,17 +98,23 @@ export const useSystemChartData = ( })); }, [systemStats, mapFn, live]); -export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => - useMemo(() => { - if (!containerStats?.length) return []; +export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => { + const prevRef = useRef([]); + return useMemo(() => { + if (!containerStats?.length) return prevRef.current.length === 0 ? prevRef.current : (prevRef.current = []); const names = new Set(); for (const record of containerStats) { for (const c of record.stats) { names.add(c.n); } } - return [...names].slice(0, max); + const next = [...names].slice(0, max); + const prev = prevRef.current; + if (prev.length === next.length && prev.every((n, i) => n === next[i])) return prev; + prevRef.current = next; + return next; }, [containerStats, max]); +}; type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;