From 82eac2dcc10530ad4aff2e993beee6c9b4273aa6 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 15 Jun 2026 14:58:14 +0200 Subject: [PATCH] fix(beszel): make chart stacking configurable and fix color mismatch BeszelAreaChart hardcoded type="stacked" for all charts, causing Docker Network I/O to render blended colors that didn't match the tooltip. - Make `type` prop configurable (default "default") - Only stack Memory, Docker CPU, and Docker Memory charts - Add systemId as a persisted widget option with Menu-based selector - Replace Popover with Menu trigger="click-hover" for proper hover UX - Show error state with Select when selected system is not found - Remove 1-minute time period (live SSE not wired yet) --- .../src/beszel-system-stats/component.tsx | 107 +++++++++++++----- .../widgets/src/beszel-system-stats/index.ts | 14 ++- packages/widgets/src/beszel/_shared/chart.tsx | 53 +++++++-- 3 files changed, 132 insertions(+), 42 deletions(-) diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 2612ee81a..f502c5598 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -1,11 +1,14 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; -import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core"; +import { useCallback, useMemo } from "react"; +import { ActionIcon, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; +import { IconServer, IconServerQuestion } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; +import classes from "./component.module.css"; + import type { WidgetComponentProps } from "../definition"; import { containerColors } from "../beszel/_shared/colors"; import { @@ -37,6 +40,7 @@ export default function BeszelSystemStatsWidget({ integrationIds, isEditMode, width, + setOptions, }: WidgetComponentProps<"beszelSystemStats">) { const t = useScopedI18n("widget.beszelSystemStats"); const { data: systemsResult = [] } = clientApi.widget.beszel.getSystems.useQuery( @@ -49,24 +53,27 @@ export default function BeszelSystemStatsWidget({ [systemsResult], ); - const [selectedSystem, setSelectedSystem] = useState(systems[0]?.value ?? null); + const selectedSystem = options.systemId || systems[0]?.value || ""; + const selectedLabel = systems.find((s) => s.value === selectedSystem)?.label; + const systemExists = systems.length === 0 || systems.some((s) => s.value === selectedSystem); + + const handleSelectSystem = useCallback( + (value: string) => setOptions({ newOptions: { systemId: value } }), + [setOptions], + ); const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery( { integrationIds, - systemId: selectedSystem ?? systems[0]?.value ?? "", + systemId: selectedSystem, timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d", includeDocker: showDocker, }, - { staleTime: 30 * 1000 }, + { staleTime: 30 * 1000, enabled: systemExists && selectedSystem !== "" }, ); - // BeszelSystemStats fields: storage values (m, mu, mb, d, du, s, su) are already in GB. - // Disk I/O (dr, dw) is in MB/s — scale to bytes/s for display formatters. - // Network (ns, nr, b) is in bytes/s. Container memory (m) is in MB. - const cpuMap = useCallback((s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }), [t]); const memoryMap = useCallback( (s: { mu: number; mb: number }) => ({ @@ -83,7 +90,6 @@ export default function BeszelSystemStatsWidget({ }), [t], ); - // b = public interface bandwidth (preferred), ns/nr = all-interface traffic (fallback) const networkMap = useCallback( (s: { ns: number; nr: number; b?: [number, number] }) => ({ [t("chart.network.sent")]: s.b?.[0] ?? s.ns, @@ -110,16 +116,68 @@ export default function BeszelSystemStatsWidget({ const cols = width > 600 ? 2 : 1; + if (!systemExists && systems.length > 0) { + return ( +
+ + + + {t("error.systemNotFound")} + + + {!isEditMode && systems.length > 1 && ( + + + + + + + + {systems.map((s) => ( + handleSelectSystem(s.value)} + > + {s.label} + + ))} + + + )} {statsResult && ( @@ -129,7 +187,6 @@ export default function BeszelSystemStatsWidget({ h={CHART_HEIGHT} data={cpuData} series={[{ name: t("chart.cpu.series"), color: "teal.6" }]} - fillOpacity={0.4} yAxisFormatter={chartAxisFormatters.percent} yAxisDomain={[0, "auto"]} tooltipProps={tooltipPercent} @@ -142,14 +199,13 @@ export default function BeszelSystemStatsWidget({ )} @@ -160,7 +216,6 @@ export default function BeszelSystemStatsWidget({ h={CHART_HEIGHT} data={diskData} series={[{ name: t("chart.disk.series"), color: "grape.6" }]} - fillOpacity={0.4} yAxisFormatter={chartAxisFormatters.gb} tooltipProps={tooltipGB} /> @@ -176,10 +231,8 @@ export default function BeszelSystemStatsWidget({ { name: t("chart.diskIO.write"), color: "orange.6" }, { name: t("chart.diskIO.read"), color: "blue.6" }, ]} - fillOpacity={0.3} yAxisFormatter={chartAxisFormatters.rate} tooltipProps={tooltipRate} - withLegend /> )} @@ -193,10 +246,8 @@ export default function BeszelSystemStatsWidget({ { name: t("chart.network.sent"), color: "blue.6" }, { name: t("chart.network.recv"), color: "teal.6" }, ]} - fillOpacity={0.3} yAxisFormatter={chartAxisFormatters.rate} tooltipProps={tooltipRate} - withLegend /> )} @@ -210,7 +261,6 @@ export default function BeszelSystemStatsWidget({ data={dockerCpuData} type="stacked" series={containerSeries} - fillOpacity={0.6} yAxisFormatter={chartAxisFormatters.percent} tooltipProps={tooltipPercentTotal} /> @@ -224,7 +274,6 @@ export default function BeszelSystemStatsWidget({ data={dockerMemData} type="stacked" series={containerSeries} - fillOpacity={0.6} yAxisFormatter={chartAxisFormatters.bytes} tooltipProps={tooltipBytesTotal} /> @@ -236,9 +285,7 @@ export default function BeszelSystemStatsWidget({ diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index 1afff7465..674c41d4c 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -1,4 +1,4 @@ -import { IconChartAreaLine } from "@tabler/icons-react"; +import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -17,7 +17,11 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys integrationsRequired: true, createOptions() { return optionsBuilder.from((factory) => ({ - timePeriod: factory.select({ defaultValue: "1h", options: timePeriodOptions }), + systemId: factory.text({ defaultValue: "" }), + timePeriod: factory.select({ + defaultValue: "1h", + options: timePeriodOptions, + }), showCpu: factory.switch({ defaultValue: true }), showMemory: factory.switch({ defaultValue: true }), showDisk: factory.switch({ defaultValue: true }), @@ -28,4 +32,10 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys showDockerNetwork: factory.switch({ defaultValue: true }), })); }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.beszelSystemStats.error.internalServerError"), + }, + }, }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index f7b735255..14c6a64f0 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -6,9 +6,14 @@ import type { AreaChartProps } from "@mantine/charts"; import { AreaChart } from "@mantine/charts"; import dayjs from "dayjs"; -import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types"; +import type { + BeszelContainerStatsRecord, + BeszelSystemStatsRecord, +} from "@homarr/integrations/types"; const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm"); +const formatTimeLive = (timestamp: string) => + dayjs(timestamp).format("HH:mm:ss"); const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; @@ -34,22 +39,41 @@ export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => ( ); -type BeszelAreaChartProps = Omit & { +type BeszelAreaChartProps = Omit< + AreaChartProps, + "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis" +> & { yAxisFormatter: (value: number) => string; yAxisDomain?: [number, string]; }; -export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style, ...props }: BeszelAreaChartProps) => ( +export const BeszelAreaChart = ({ + yAxisFormatter, + yAxisDomain, + yAxisProps, + style, + type = "default", + ...props +}: BeszelAreaChartProps) => ( Record, + live = false, ) => useMemo(() => { if (!systemStats) return []; + const fmt = live ? formatTimeLive : formatTime; return [...systemStats].toReversed().map((r) => ({ - time: formatTime(r.created), + time: fmt(r.created), ...mapFn(r.stats), })); - }, [systemStats, mapFn]); + }, [systemStats, mapFn, live]); -export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => +export const useContainerNames = ( + containerStats: BeszelContainerStatsRecord[] | undefined, + max = 15, +) => useMemo(() => { if (!containerStats?.length) return []; const names = new Set(); @@ -82,7 +111,9 @@ export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | return [...names].slice(0, max); }, [containerStats, max]); -type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number; +type ContainerExtractor = ( + container: BeszelContainerStatsRecord["stats"][number] | undefined, +) => number; // c = CPU (%), m = memory (MB), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s) const MB = 1024 * 1024; @@ -97,16 +128,18 @@ export const useDockerChartData = ( containerStats: BeszelContainerStatsRecord[] | undefined, containerNames: string[], metric: "cpu" | "memory" | "network", + live = false, ) => useMemo(() => { if (!containerStats?.length) return []; const extract = defaultContainerExtractors[metric]; if (!extract) return []; + const fmt = live ? formatTimeLive : formatTime; return [...containerStats].toReversed().map((record) => { - const point: Record = { time: formatTime(record.created) }; + const point: Record = { time: fmt(record.created) }; for (const name of containerNames) { point[name] = extract(record.stats.find((c) => c.n === name)); } return point; }); - }, [containerStats, containerNames, metric]); + }, [containerStats, containerNames, metric, live]);