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)
This commit is contained in:
Thomas Camlong committed 2026-06-15 14:58:14 +02:00
1 parent ab41b76a66
commit 82eac2dcc1
3 files changed
+132 -42

No files matched your search

@@ -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<string | null>(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 (
<Center h="100%">
<Stack align="center" gap="sm" p="md">
<IconServerQuestion size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.systemNotFound")}
</Text>
<Select
size="xs"
data={systems}
placeholder={t("selectSystem")}
onChange={(value) => value && handleSelectSystem(value)}
/>
</Stack>
</Center>
);
}
return (
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<ScrollArea
h="100%"
className={classes.beszelStatsWrapper}
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
<Select
size="xs"
data={systems}
value={selectedSystem}
onChange={setSelectedSystem}
placeholder={t("selectSystem")}
/>
{!isEditMode && systems.length > 1 && (
<Menu
trigger="click-hover"
openDelay={100}
closeDelay={300}
position="bottom-start"
withArrow
shadow="md"
withinPortal
>
<Menu.Target>
<ActionIcon
variant="subtle"
size="sm"
className={classes.beszelStatsSystemToggle}
style={{ position: "absolute", top: 6, left: 6, zIndex: 1 }}
title={selectedLabel}
>
<IconServer size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{systems.map((s) => (
<Menu.Item
key={s.value}
fz="xs"
fw={s.value === selectedSystem ? 600 : 400}
c={s.value === selectedSystem ? undefined : "dimmed"}
onClick={() => handleSelectSystem(s.value)}
>
{s.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{statsResult && (
<SimpleGrid cols={cols} spacing="md">
@@ -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({
<BeszelAreaChart
h={CHART_HEIGHT}
data={memoryData}
type="stacked"
series={[
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
withLegend
/>
</ChartPanel>
)}
@@ -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
/>
</ChartPanel>
)}
@@ -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
/>
</ChartPanel>
)}
@@ -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({
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerNetData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
/>
@@ -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"));
+43 -10
View File
@@ -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) => (
</Stack>
);
type BeszelAreaChartProps = Omit<AreaChartProps, "dataKey" | "curveType" | "withDots" | "withXAxis" | "withYAxis"> & {
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) => (
<AreaChart
dataKey="time"
curveType="monotone"
withDots={false}
withGradient={false}
connectNulls
withDots={true}
type={type}
strokeWidth={1}
fillOpacity={0.2}
activeDotProps={{ r: 1, strokeWidth: 3 }}
dotProps={{ r: 0, strokeWidth: 0 }}
withXAxis
withYAxis
w="100%"
style={{ minWidth: 0, minHeight: 0, ...style }}
yAxisProps={{
...yAxisBase,
width: 32,
tickMargin: 2,
...(yAxisDomain ? { domain: yAxisDomain } : {}),
tickFormatter: yAxisFormatter,
...yAxisProps,
@@ -61,16 +85,21 @@ export const BeszelAreaChart = ({ yAxisFormatter, yAxisDomain, yAxisProps, style
export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
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<string>();
@@ -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<string, unknown> = { time: formatTime(record.created) };
const point: Record<string, unknown> = { 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]);