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
This commit is contained in:
Thomas Camlong committed 2026-06-15 16:07:37 +02:00
1 parent 569293275d
commit 6189daf6a4
2 files changed
+52 -29

No files matched your search

@@ -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({
<BeszelAreaChart
h={CHART_HEIGHT}
data={cpuData}
series={[{ name: t("chart.cpu.series"), color: "teal.6" }]}
series={cpuSeries}
yAxisFormatter={chartAxisFormatters.percent}
yAxisDomain={[0, "auto"]}
tooltipProps={tooltipPercent}
@@ -223,10 +247,7 @@ export default function BeszelSystemStatsWidget({
h={CHART_HEIGHT}
data={memoryData}
type="stacked"
series={[
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
]}
series={memorySeries}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
/>
@@ -238,7 +259,7 @@ export default function BeszelSystemStatsWidget({
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskData}
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
series={diskSeries}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
/>
@@ -250,10 +271,7 @@ export default function BeszelSystemStatsWidget({
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskIOData}
series={[
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
]}
series={diskIOSeries}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
/>
@@ -265,10 +283,7 @@ export default function BeszelSystemStatsWidget({
<BeszelAreaChart
h={CHART_HEIGHT}
data={networkData}
series={[
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
]}
series={networkSeries}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
/>
+19 -11
View File
@@ -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<T>(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) => (
<Stack gap={4} style={{ minWidth: 0, overflow: "hidden" }}>
<Group gap="xs">
<Text size="sm" fw={600}>
@@ -40,14 +42,14 @@ export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
</Group>
{children}
</Stack>
);
));
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis"> & {
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<string[]>([]);
return useMemo(() => {
if (!containerStats?.length) return prevRef.current.length === 0 ? prevRef.current : (prevRef.current = []);
const names = new Set<string>();
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;