Files
homarr/packages/widgets/src/beszel-system-stats/component.tsx
T
Thomas Camlong f0e20eb3da fix(beszel): correct unit scaling for system stats and container charts
Beszel API returns pre-scaled values, not raw bytes as the types claimed:
- Storage (du/mu/mb): GB — remove /BYTES_TO_GB division
- Disk I/O (dr/dw): MB/s — multiply by 1024² for formatters
- Container memory (m): MB — multiply by 1024² for formatters
- Container network (b/ns/nr): bytes/s — passthrough, unchanged

Also fix Recharts width(-1)/height(-1) warnings by adding w=100%
and minHeight:0 to BeszelAreaChart.
2026-06-14 17:05:34 +02:00

255 lines
9.5 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import { ScrollArea, Select, SimpleGrid, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors";
import {
BeszelAreaChart,
ChartPanel,
useContainerNames,
useDockerChartData,
useSystemChartData,
} from "../beszel/_shared/chart";
import {
chartAxisFormatters,
formatByteRate,
formatGB,
formatPercent,
formatStorageBytes,
} from "../beszel/_shared/format";
import { makeTooltipProps } from "../beszel/_shared/tooltip";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
const CHART_HEIGHT = 180;
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const { data: systemsResult = [] } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds },
{ staleTime: 30 * 1000 },
);
const systems = useMemo(
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
[systemsResult],
);
const [selectedSystem, setSelectedSystem] = useState<string | null>(systems[0]?.value ?? null);
const showDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const { data: statsResult } = clientApi.widget.beszel.getSystemStats.useQuery(
{
integrationIds,
systemId: selectedSystem ?? systems[0]?.value ?? "",
timePeriod: options.timePeriod as "1h" | "12h" | "24h" | "1w" | "30d",
includeDocker: showDocker,
},
{ staleTime: 30 * 1000 },
);
// 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 }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
[t],
);
const diskMap = useCallback((s: { du: number }) => ({ [t("chart.disk.series")]: s.du }), [t]);
const diskIOMap = useCallback(
(s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * (1024 * 1024),
[t("chart.diskIO.write")]: (s.dw ?? 0) * (1024 * 1024),
}),
[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,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
}),
[t],
);
const cpuData = useSystemChartData(statsResult?.systemStats, cpuMap);
const memoryData = useSystemChartData(statsResult?.systemStats, memoryMap);
const diskData = useSystemChartData(statsResult?.systemStats, diskMap);
const diskIOData = useSystemChartData(statsResult?.systemStats, diskIOMap);
const networkData = useSystemChartData(statsResult?.systemStats, networkMap);
const containerNames = useContainerNames(statsResult?.containerStats);
const dockerCpuData = useDockerChartData(statsResult?.containerStats, containerNames, "cpu");
const dockerMemData = useDockerChartData(statsResult?.containerStats, containerNames, "memory");
const dockerNetData = useDockerChartData(statsResult?.containerStats, containerNames, "network");
const containerSeries = containerNames.map((name, i) => ({
name,
color: containerColors[i % containerColors.length] as string,
}));
const cols = width > 600 ? 2 : 1;
return (
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="md" p="sm">
<Select
size="xs"
data={systems}
value={selectedSystem}
onChange={setSelectedSystem}
placeholder={t("selectSystem")}
/>
{statsResult && (
<SimpleGrid cols={cols} spacing="md">
{options.showCpu && cpuData.length > 0 && (
<ChartPanel title={t("chart.cpu.title")} subtitle={t("chart.cpu.subtitle")}>
<BeszelAreaChart
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}
/>
</ChartPanel>
)}
{options.showMemory && memoryData.length > 0 && (
<ChartPanel title={t("chart.memory.title")} subtitle={t("chart.memory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={memoryData}
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>
)}
{options.showDisk && diskData.length > 0 && (
<ChartPanel title={t("chart.disk.title")} subtitle={t("chart.disk.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskData}
series={[{ name: t("chart.disk.series"), color: "grape.6" }]}
fillOpacity={0.4}
yAxisFormatter={chartAxisFormatters.gb}
tooltipProps={tooltipGB}
/>
</ChartPanel>
)}
{options.showDiskIO && diskIOData.length > 0 && (
<ChartPanel title={t("chart.diskIO.title")} subtitle={t("chart.diskIO.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={diskIOData}
series={[
{ 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>
)}
{options.showNetwork && networkData.length > 0 && (
<ChartPanel title={t("chart.network.title")} subtitle={t("chart.network.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={networkData}
series={[
{ 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>
)}
{showDocker && containerSeries.length > 0 && (
<>
{options.showDockerCpu && dockerCpuData.length > 0 && (
<ChartPanel title={t("chart.dockerCpu.title")} subtitle={t("chart.dockerCpu.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerCpuData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.percent}
tooltipProps={tooltipPercentTotal}
/>
</ChartPanel>
)}
{options.showDockerMemory && dockerMemData.length > 0 && (
<ChartPanel title={t("chart.dockerMemory.title")} subtitle={t("chart.dockerMemory.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerMemData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.bytes}
tooltipProps={tooltipBytesTotal}
/>
</ChartPanel>
)}
{options.showDockerNetwork && dockerNetData.length > 0 && (
<ChartPanel title={t("chart.dockerNetwork.title")} subtitle={t("chart.dockerNetwork.subtitle")}>
<BeszelAreaChart
h={CHART_HEIGHT}
data={dockerNetData}
type="stacked"
series={containerSeries}
fillOpacity={0.6}
yAxisFormatter={chartAxisFormatters.rate}
tooltipProps={tooltipRate}
/>
</ChartPanel>
)}
</>
)}
</SimpleGrid>
)}
</Stack>
</ScrollArea>
);
}