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:
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"));
|
||||
@@ -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]);
|
||||
Reference in new issue
Block a user