Files
homarr/packages/widgets/src/beszel/_shared/chart.tsx
T
Thomas Camlong 4c36c9cff1 fix(beszel): prevent recharts negative dimension warnings
Add minWidth: 0 and overflow: hidden to ChartPanel (CSS Grid item)
and minWidth: 0 to BeszelAreaChart. Prevents ResponsiveContainer from
computing negative width/height in grid/flex layouts before first paint.
2026-06-08 16:16:18 +02:00

109 lines
3.3 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { Group, Stack, Text } from "@mantine/core";
import type { AreaChartProps } from "@mantine/charts";
import { AreaChart } from "@mantine/charts";
import dayjs from "dayjs";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
interface ChartPanelProps {
title: string;
subtitle?: string;
children: React.ReactNode;
}
export const ChartPanel = ({ title, subtitle, children }: ChartPanelProps) => (
<Stack gap={4} style={{ minWidth: 0, overflow: "hidden" }}>
<Group gap="xs">
<Text size="sm" fw={600}>
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</Group>
{children}
</Stack>
);
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) => (
<AreaChart
dataKey="time"
curveType="monotone"
withDots={false}
withXAxis
withYAxis
style={{ minWidth: 0, ...style }}
yAxisProps={{
...yAxisBase,
...(yAxisDomain ? { domain: yAxisDomain } : {}),
tickFormatter: yAxisFormatter,
...yAxisProps,
}}
{...props}
/>
);
export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
) =>
useMemo(() => {
if (!systemStats) return [];
return [...systemStats].toReversed().map((r) => ({
time: formatTime(r.created),
...mapFn(r.stats),
}));
}, [systemStats, mapFn]);
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) =>
useMemo(() => {
if (!containerStats?.length) return [];
const names = new Set<string>();
for (const record of containerStats) {
for (const c of record.stats) {
names.add(c.n);
}
}
return [...names].slice(0, max);
}, [containerStats, max]);
type ContainerExtractor = (container: BeszelContainerStatsRecord["stats"][number] | undefined) => number;
// c = CPU (%), m = memory (bytes), b = bandwidth [sent,recv] (bytes/s), ns/nr = legacy net (bytes/s)
const defaultContainerExtractors: Record<string, ContainerExtractor> = {
cpu: (c) => c?.c ?? 0,
memory: (c) => c?.m ?? 0,
network: (c) => (c?.b ? c.b[0] + c.b[1] : (c?.ns ?? 0) + (c?.nr ?? 0)),
};
export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined,
containerNames: string[],
metric: "cpu" | "memory" | "network",
) =>
useMemo(() => {
if (!containerStats?.length) return [];
const extract = defaultContainerExtractors[metric]!;
return [...containerStats].toReversed().map((record) => {
const point: Record<string, unknown> = { time: formatTime(record.created) };
for (const name of containerNames) {
point[name] = extract(record.stats.find((c) => c.n === name));
}
return point;
});
}, [containerStats, containerNames, metric]);