fix(beszel): add reliable live stats and filesystem UI (#6289)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-11 17:55:04 +02:00
1 parent def3ede3bc
commit 576856d634
35 files changed
+1272 -690

No files matched your search

@@ -0,0 +1,87 @@
import { describe, expect, test } from "vitest";
import type { BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { buildDiskChartData, padLiveTimeGrid } from "./chart";
const record = (
created: string,
du: number,
efs: BeszelSystemStatsRecord["stats"]["efs"],
): BeszelSystemStatsRecord => ({
id: created,
system: "system-1",
type: "1m",
created,
updated: created,
stats: {
cpu: 0,
m: 0,
mu: 0,
mp: 0,
mb: 0,
s: 0,
su: 0,
d: 938.8,
du,
dp: 51.15,
efs,
},
});
describe("buildDiskChartData", () => {
test("keeps root and extra filesystem usage in GiB without stacking or byte conversion", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:46:00.000Z", 455.81, {
sda: { d: 1906.8, du: 1656.45, r: 0, w: 0 },
sdb: { d: 5587.04, du: 3936.86, r: 0, w: 0 },
sdc: { d: 3724.18, du: 1589.84, r: 0, w: 0 },
}),
],
["sda", "sdb", "sdc"],
"Root",
"1h",
);
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({ Root: 455.81, sda: 1656.45, sdb: 3936.86, sdc: 1589.84 });
});
test("uses zero for a filesystem missing from an individual sample and orders historical records oldest first", () => {
const data = buildDiskChartData(
[
record("2026-07-11T13:47:00.000Z", 2, { sda: { d: 10, du: 4, r: 0, w: 0 } }),
record("2026-07-11T13:46:00.000Z", 1, undefined),
],
["sda"],
"Root",
"1h",
);
expect(data.map((point) => ({ Root: point.Root, sda: point.sda }))).toEqual([
{ Root: 1, sda: 0 },
{ Root: 2, sda: 4 },
]);
});
});
describe("padLiveTimeGrid", () => {
test("keeps a fixed 60-second window with the newest sample at the right edge", () => {
const data = padLiveTimeGrid([{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.217Z", CPU: 18.07 }]);
expect(data).toHaveLength(60);
expect(data[0]).not.toHaveProperty("CPU");
expect(data[58]).not.toHaveProperty("CPU");
expect(data[59]).toMatchObject({ CPU: 18.07 });
});
test("leaves null gaps for missing seconds instead of stretching existing points", () => {
const data = padLiveTimeGrid([
{ time: "13:51:28", rawTime: "2026-07-11T13:51:28.000Z", CPU: 10 },
{ time: "13:51:30", rawTime: "2026-07-11T13:51:30.000Z", CPU: 20 },
]);
expect(data.slice(-3).map((point) => point.CPU)).toEqual([10, undefined, 20]);
});
});
+62 -14
View File
@@ -30,6 +30,7 @@ function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
if (timePeriod === "1m") return padLiveTimeGrid(data);
const days = periodDays[timePeriod];
if (!days) return data;
@@ -50,6 +51,38 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
}
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
if (data.length === 0) return data;
const end = dayjs(
data.reduce((latest, point) => {
const timestamp = new Date(point.rawTime as string).getTime();
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
}, 0),
).startOf("second");
const pointsBySecond = new Map(
data.map(
(point) =>
[
dayjs(point.rawTime as string)
.startOf("second")
.valueOf(),
point,
] as const,
),
);
return Array.from({ length: pointCount }, (_, index) => {
const timestamp = end.subtract(pointCount - index - 1, "second");
return (
pointsBySecond.get(timestamp.valueOf()) ?? {
time: timestamp.format(timeFormats["1m"]),
rawTime: timestamp.toISOString(),
}
);
});
}
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const chartStyle = { minWidth: 0, minHeight: 1 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
@@ -174,24 +207,39 @@ const defaultContainerExtractors: Record<string, ContainerExtractor> = {
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
};
export const useEfsChartData = (
export const useDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) =>
useMemo(() => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
}, [systemStats, efsPaths, timePeriod]);
useMemo(
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
[systemStats, efsPaths, rootSeriesName, timePeriod],
);
export const buildDiskChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
) => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
rawTime: record.created,
[rootSeriesName]: record.stats.du,
};
const efs = record.stats.efs ?? {};
for (const path of efsPaths) {
point[path] = efs[path]?.du ?? 0;
}
return point;
});
return padTimeGrid(mapped, timePeriod);
};
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
@@ -0,0 +1,105 @@
import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
import { formatPercent, getProgressTrackSize } from "./format";
interface DiskUsageProps {
system: BeszelSystemRow;
fontSize: string;
progressSize: "xs" | "sm";
valueMiw: number;
valueGap?: number;
}
const mountLabel = (path: string) =>
path === "/" ? "ROOT" : (path.split("/").filter(Boolean).at(-1) ?? path).toUpperCase();
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
const trackSize = getProgressTrackSize(progressSize);
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<HoverCard
position="right"
withArrow
shadow="md"
openDelay={200}
closeDelay={100}
disabled={filesystems.length === 0}
>
<HoverCard.Target>
{filesystems.length > 0 ? (
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
<Group
gap={dotGap}
wrap="nowrap"
aria-hidden
pos="absolute"
top="50%"
right={Math.max(2, dotGap)}
style={{ transform: "translateY(-50%)" }}
>
{filesystems.map(([path, value]) => (
<Box
key={path}
w={dotSize}
h={dotSize}
style={{
borderRadius: "50%",
backgroundColor: severityColor(value),
boxShadow: "0 0 0 0.5px var(--mantine-color-body)",
flex: "0 0 auto",
}}
/>
))}
</Group>
</Box>
</UnstyledButton>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
</HoverCard.Target>
<HoverCard.Dropdown p={8}>
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
</Group>
);
};
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { chartAxisFormatters, formatGB, getProgressTrackSize } from "./format";
describe("Beszel storage formatting", () => {
test("keeps smaller values in GB", () => {
expect(formatGB(455.81)).toBe("455.81 GB");
expect(chartAxisFormatters.gb(455.81)).toBe("456G");
});
test("promotes large GiB values to TB", () => {
expect(formatGB(3323)).toBe("3.25 TB");
expect(formatGB(3936.86)).toBe("3.84 TB");
expect(chartAxisFormatters.gb(3323)).toBe("3.2T");
});
test("maps progress sizes consistently", () => {
expect(getProgressTrackSize("xs")).toBe(6);
expect(getProgressTrackSize("sm")).toBe(9);
});
});
@@ -29,14 +29,22 @@ export const formatStorageBytes = (bytes: number): string => formatScaled(bytes,
export const formatGB = (value: number): string => {
if (!value || !Number.isFinite(value)) return "0 GB";
if (Math.abs(value) >= 1024) return `${(value / 1024).toFixed(2)} TB`;
return `${value.toFixed(2)} GB`;
};
export const formatPercent = (value: number): string => `${value.toFixed(1)}%`;
export const getProgressTrackSize = (size: "xs" | "sm"): number => (size === "xs" ? 6 : 9);
export const chartAxisFormatters = {
percent: formatPercent,
gb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}G`),
gb: (value: number) => {
const numericValue = Number(value);
if (!numericValue) return "0";
if (Math.abs(numericValue) >= 1024) return `${(numericValue / 1024).toFixed(1)}T`;
return `${numericValue.toFixed(0)}G`;
},
mb: (value: number) => (!value ? "0" : `${Number(value).toFixed(0)}M`),
bytes: (value: number) => formatScaledCompact(Number(value), byteUnits, "0"),
rate: (value: number) => formatScaledCompact(Number(value), rateUnits, "0"),
@@ -0,0 +1,339 @@
"use client";
import { useMemo } from "react";
import { Button, Center, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
BeszelChartPanel,
CPU_Y_AXIS_DOMAIN,
useContainerNames,
useDiskChartData,
useDockerChartData,
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
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 ChartSkeleton = () => (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs">
<Skeleton h={14} w={72} radius="sm" />
<Skeleton h={10} w={110} radius="sm" />
</Group>
<Skeleton h={CHART_HEIGHT} radius="sm" />
</Stack>
);
export interface BeszelStatsVisibility {
cpu: boolean;
memory: boolean;
disk: boolean;
diskIO: boolean;
network: boolean;
dockerCpu: boolean;
dockerMemory: boolean;
dockerNetwork: boolean;
}
interface BeszelStatsViewProps {
integrationIds: string[];
systemId: string;
timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({
integrationIds,
systemId,
timePeriod,
visibility,
columns,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
const activeStats:
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
| null
| undefined = isLive ? liveData : historicalData;
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (s: { ns?: number; nr?: number; b?: [number, number] }) => ({
[t("chart.network.sent")]: s.b?.[0] ?? s.ns ?? 0,
[t("chart.network.recv")]: s.b?.[1] ?? s.nr ?? 0,
}),
}),
[t],
);
const series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = activeStats?.systemStats;
const containerStats = activeStats?.containerStats;
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
const efsPaths = useMemo(() => {
const paths = new Set<string>();
for (const record of systemStats ?? []) {
for (const path of Object.keys(record.stats.efs ?? {})) paths.add(path);
}
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
...efsPaths.map((path, index) => ({
name: path,
color: containerColors[(index + 1) % containerColors.length] as string,
})),
],
[efsPaths, rootSeriesName],
);
const containerNames = useContainerNames(whenVisible(showDocker, containerStats));
const containerSeries = useMemo(
() =>
containerNames.map((name, index) => ({ name, color: containerColors[index % containerColors.length] as string })),
[containerNames],
);
const dockerCpuData = useDockerChartData(
whenVisible(visibility.dockerCpu, containerStats),
containerNames,
"cpu",
timePeriod,
);
const dockerMemoryData = useDockerChartData(
whenVisible(visibility.dockerMemory, containerStats),
containerNames,
"memory",
timePeriod,
);
const dockerNetworkData = useDockerChartData(
whenVisible(visibility.dockerNetwork, containerStats),
containerNames,
"network",
timePeriod,
);
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="sm" p="md">
<IconPlugConnectedX size={40} />
<Text size="sm" c="dimmed" ta="center">
{t("error.liveConnectionFailed")}
</Text>
{onSwitchToHistorical && (
<Button size="xs" variant="light" onClick={onSwitchToHistorical}>
{t("error.switchToHistorical")}
</Button>
)}
</Stack>
</Center>
);
}
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
</SimpleGrid>
);
}
if ("error" in activeStats && activeStats.error) {
return (
<Center h={CHART_HEIGHT}>
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
</Text>
</Stack>
</Center>
);
}
return (
<SimpleGrid cols={columns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{visibility.memory && memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.disk && diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{visibility.diskIO && diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{visibility.network && networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{showDocker && containerSeries.length > 0 && (
<>
{visibility.dockerCpu && dockerCpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
tooltipProps: tooltipPercentTotal,
}}
/>
)}
{visibility.dockerMemory && dockerMemoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerMemoryData,
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
tooltipProps: tooltipBytesTotal,
}}
/>
)}
{visibility.dockerNetwork && dockerNetworkData.length > 0 && (
<BeszelChartPanel
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</>
)}
</SimpleGrid>
);
}
@@ -1,25 +1,15 @@
"use client";
import { useMemo, useState } from "react";
import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core";
import { useState } from "react";
import { SegmentedControl, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelTimePeriod } from "./chart";
import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
import { makeTooltipProps } from "./tooltip";
const tooltipPercent = makeTooltipProps(formatPercent);
const tooltipGB = makeTooltipProps(formatGB, true);
const tooltipRate = makeTooltipProps(formatByteRate, true);
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
import { BeszelStatsView } from "./stats-view";
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "1m", label: "Live" },
{ value: "1h", label: "1H" },
{ value: "12h", label: "12H" },
{ value: "24h", label: "24H" },
@@ -27,141 +17,41 @@ const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "30d", label: "30D" },
];
const allCharts = {
cpu: true,
memory: true,
disk: true,
diskIO: true,
network: true,
dockerCpu: true,
dockerMemory: true,
dockerNetwork: true,
} as const;
interface BeszelSystemStatsModalProps {
integrationId: string;
systemId: string;
}
export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => {
const t = useScopedI18n("widget.beszelSystemStats");
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h");
const { data } = clientApi.widget.beszel.getSystemStats.useQuery({
integrationIds: [innerProps.integrationId],
systemId: innerProps.systemId,
timePeriod,
includeDocker: false,
});
const mappers = useMemo(
() => ({
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
memory: (s: { mu: number; mb: number }) => ({
[t("chart.memory.series")]: s.mu,
[t("chart.memory.cache")]: s.mb ?? 0,
}),
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
diskIO: (s: { dr?: number; dw?: number }) => ({
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
}),
network: (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 series = useMemo(
() => ({
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
memory: [
{ name: t("chart.memory.series"), color: "teal.5" },
{ name: t("chart.memory.cache"), color: "teal.8" },
],
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
diskIO: [
{ name: t("chart.diskIO.write"), color: "orange.6" },
{ name: t("chart.diskIO.read"), color: "blue.6" },
],
network: [
{ name: t("chart.network.sent"), color: "blue.6" },
{ name: t("chart.network.recv"), color: "teal.6" },
],
}),
[t],
);
const systemStats = data?.systemStats;
const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
return (
<Stack gap="md">
<SegmentedControl
size="xs"
value={timePeriod}
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)}
onChange={(value) => setTimePeriod(value as BeszelTimePeriod)}
data={timePeriodOptions}
/>
<SimpleGrid cols={2} spacing="md">
{cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
chartProps={{
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
tooltipProps: tooltipPercent,
}}
/>
)}
{memoryData.length > 0 && (
<BeszelChartPanel
title={t("chart.memory.title")}
chartProps={{
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskData.length > 0 && (
<BeszelChartPanel
title={t("chart.disk.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskData,
series: series.disk,
yAxisFormatter: chartAxisFormatters.gb,
tooltipProps: tooltipGB,
}}
/>
)}
{diskIOData.length > 0 && (
<BeszelChartPanel
title={t("chart.diskIO.title")}
chartProps={{
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
{networkData.length > 0 && (
<BeszelChartPanel
title={t("chart.network.title")}
chartProps={{
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
tooltipProps: tooltipRate,
}}
/>
)}
</SimpleGrid>
<BeszelStatsView
integrationIds={[innerProps.integrationId]}
systemId={innerProps.systemId}
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
);
}).withOptions({
@@ -1,12 +1,14 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { skipToken } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
// 120 records at ~1 per second = 2 minutes of rolling data for charts
const MAX_BUFFER = 120;
// Beszel emits roughly one record per second. Keep a sliding one-minute window
// without synthesizing points for missed updates.
const MAX_BUFFER = 60;
export const useLiveStats = (integrationIds: string[], systemId: string, enabled: boolean) => {
const [systemStats, setSystemStats] = useState<BeszelSystemStatsRecord[]>([]);
@@ -29,9 +31,8 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
}, []);
clientApi.widget.beszel.subscribeSystemStats.useSubscription(
{ integrationIds, systemId },
enabled && systemId !== "" ? { integrationIds, systemId } : skipToken,
{
enabled: enabled && systemId !== "",
onData(event) {
setError(null);
if (event.type === "system_stats") {
@@ -48,7 +49,7 @@ export const useLiveStats = (integrationIds: string[], systemId: string, enabled
// Reset buffers when system changes or integration changes
const prevKeyRef = useRef<string>("");
const currentKey = `${integrationIds.join(",")}:${systemId}`;
const currentKey = `${integrationIds.join(",")}:${systemId}:${enabled}`;
useEffect(() => {
if (prevKeyRef.current !== currentKey) {
prevKeyRef.current = currentKey;