Compare commits

...
Author SHA1 Message Date
Thomas Camlong 35cac9b46b fix(beszel): improve stats date display, add click-to-stats modal, fix grid ordering
- X axis shows MMM D for 1w/30d periods, HH:mm for shorter ones
- Tooltip shows full date+time via portal with viewport clamping
- connectNulls=false to stop gap-filling in charts
- Fixed time grid for 1w/30d ensures full period always shown
- Click server in grid/table widgets to open stats modal with charts
- Grid/table systems sorted alphabetically for stable ordering
- Table header frosted-glass blur, subtle hover on both widgets
- Daily aggregation (1440m) for 1w (9pts) and 30d (32pts)
2026-06-20 14:37:26 +02:00
10 changed files with 330 additions and 47 deletions

No files matched your search

+2 -2
View File
@@ -95,8 +95,8 @@ const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSec
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 }, "1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 }, "12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 }, "24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 }, "1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 }, "30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
}; };
export interface BeszelStatsData { export interface BeszelStatsData {
@@ -0,0 +1,7 @@
.clickableCard {
transition: background-color 150ms;
}
.clickableCard:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
@@ -18,13 +18,17 @@ import {
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import type { BeszelSystemRow } from "../beszel/_shared/types"; import type { BeszelSystemRow } from "../beszel/_shared/types";
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
interface SizeConfig { interface SizeConfig {
iconSize: number; iconSize: number;
@@ -154,6 +158,7 @@ interface SystemCardProps {
size: SizeConfig; size: SizeConfig;
maxMetrics: number; maxMetrics: number;
itemRadius: MantineSize; itemRadius: MantineSize;
onClick?: () => void;
} }
const metricRenderers = [ const metricRenderers = [
@@ -310,7 +315,7 @@ const metricRenderers = [
}, },
] as const; ] as const;
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: SystemCardProps) => { const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics); const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
return ( return (
@@ -319,11 +324,14 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: System
radius={itemRadius} radius={itemRadius}
bg="transparent" bg="transparent"
h="100%" h="100%"
onClick={onClick}
className={onClick ? classes.clickableCard : undefined}
style={{ style={{
overflow: "hidden", overflow: "hidden",
display: "flex", display: "flex",
flexDirection: "column" as const, flexDirection: "column" as const,
border: "0.0625rem solid var(--border-color)", border: "0.0625rem solid var(--border-color)",
cursor: onClick ? "pointer" : undefined,
}} }}
> >
<Group gap="xs" mb={2}> <Group gap="xs" mb={2}>
@@ -353,6 +361,7 @@ export default function BeszelSystemGridWidget({
}: WidgetComponentProps<"beszelSystemGrid">) { }: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid"); const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard(); const board = useRequiredBoard();
const { openModal } = useModalAction(BeszelSystemStatsModal);
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery( const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds }, { integrationIds },
@@ -385,17 +394,24 @@ export default function BeszelSystemGridWidget({
overflow: scrollEnabled ? "auto" : "hidden", overflow: scrollEnabled ? "auto" : "hidden",
}} }}
> >
{filteredSystems.map((system) => ( {filteredSystems.map((system) => {
<SystemCard const integrationId = system._key.split(":")[0] ?? "";
key={system._key} const handleClick = isEditMode
system={system} ? undefined
options={options} : () => openModal({ integrationId, systemId: system.id }, { title: system.name });
t={t} return (
size={size} <SystemCard
maxMetrics={maxMetrics} key={system._key}
itemRadius={board.itemRadius} system={system}
/> options={options}
))} t={t}
size={size}
maxMetrics={maxMetrics}
itemRadius={board.itemRadius}
onClick={handleClick}
/>
);
})}
</Box> </Box>
); );
} }
@@ -15,6 +15,7 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { containerColors } from "../beszel/_shared/colors"; import { containerColors } from "../beszel/_shared/colors";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { import {
BeszelChartPanel, BeszelChartPanel,
CPU_Y_AXIS_DOMAIN, CPU_Y_AXIS_DOMAIN,
@@ -153,30 +154,31 @@ export default function BeszelSystemStatsWidget({
const systemStats = activeStats?.systemStats; const systemStats = activeStats?.systemStats;
const containerStatsRaw = activeStats?.containerStats; const containerStatsRaw = activeStats?.containerStats;
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, isLive); const timePeriod = options.timePeriod as BeszelTimePeriod;
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, isLive); const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, isLive); const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, isLive); const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, isLive); const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw)); const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
const dockerCpuData = useDockerChartData( const dockerCpuData = useDockerChartData(
statsWhenShown(options.showDockerCpu, containerStatsRaw), statsWhenShown(options.showDockerCpu, containerStatsRaw),
containerNames, containerNames,
"cpu", "cpu",
isLive, timePeriod,
); );
const dockerMemData = useDockerChartData( const dockerMemData = useDockerChartData(
statsWhenShown(options.showDockerMemory, containerStatsRaw), statsWhenShown(options.showDockerMemory, containerStatsRaw),
containerNames, containerNames,
"memory", "memory",
isLive, timePeriod,
); );
const dockerNetData = useDockerChartData( const dockerNetData = useDockerChartData(
statsWhenShown(options.showDockerNetwork, containerStatsRaw), statsWhenShown(options.showDockerNetwork, containerStatsRaw),
containerNames, containerNames,
"network", "network",
isLive, timePeriod,
); );
const containerSeries = useMemo( const containerSeries = useMemo(
@@ -20,6 +20,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -27,6 +28,7 @@ import type { BeszelSystemRow } from "../beszel/_shared/types";
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format"; import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks"; import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 }; const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
@@ -66,6 +68,7 @@ export default function BeszelSystemTableWidget({
width, width,
}: WidgetComponentProps<"beszelSystemTable">) { }: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable"); const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal);
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery( const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
{ integrationIds }, { integrationIds },
{ staleTime: 5_000, refetchInterval: 5_000, retry: false }, { staleTime: 5_000, refetchInterval: 5_000, retry: false },
@@ -255,6 +258,11 @@ export default function BeszelSystemTableWidget({
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[]; return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [options, t, size]); }, [options, t, size]);
const handleRowClick = ({ record }: { record: SystemRowWithKey }) => {
const integrationId = record._key.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
};
if (systemsError) throw systemsError; if (systemsError) throw systemsError;
return ( return (
@@ -272,6 +280,7 @@ export default function BeszelSystemTableWidget({
idAccessor="_key" idAccessor="_key"
height="100%" height="100%"
className="beszel-table" className="beszel-table"
onRowClick={isEditMode ? undefined : handleRowClick}
/> />
); );
} }
@@ -6,14 +6,23 @@
.beszel-table .mantine-datatable-table thead, .beszel-table .mantine-datatable-table thead,
.beszel-table .mantine-datatable-table tbody, .beszel-table .mantine-datatable-table tbody,
.beszel-table .mantine-datatable-table tfoot, .beszel-table .mantine-datatable-table tfoot,
.beszel-table .mantine-datatable-table tr,
.beszel-table th, .beszel-table th,
.beszel-table td { .beszel-table td {
background-color: transparent !important; background-color: transparent !important;
} }
.beszel-table .mantine-datatable-table tr {
background-color: transparent;
}
.beszel-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.beszel-table th { .beszel-table th {
white-space: nowrap; white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
} }
.beszel-table td { .beszel-table td {
+51 -16
View File
@@ -8,14 +8,46 @@ 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"); export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss");
function prepareRecords<T>(records: T[], live: boolean) { const timeFormats: Record<BeszelTimePeriod, string> = {
if (live) { "1m": "HH:mm:ss",
return { fmt: formatTimeLive, ordered: records }; "1h": "HH:mm",
"12h": "HH:mm",
"24h": "HH:mm",
"1w": "MMM D",
"30d": "MMM D",
};
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
if (timePeriod === "1m") {
return { fmt, ordered: records };
} }
return { fmt: formatTime, ordered: [...records].toReversed() }; return { fmt, ordered: [...records].toReversed() };
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
const days = periodDays[timePeriod];
if (!days) return data;
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
const now = dayjs();
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
const result = [...data];
for (let i = days; i >= 0; i--) {
const d = now.subtract(i, "day").startOf("day");
const key = d.format("YYYY-MM-DD");
if (!existingDays.has(key)) {
const iso = d.toISOString();
result.push({ time: fmt(iso), rawTime: iso });
}
}
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
} }
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const; const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
@@ -77,7 +109,7 @@ const BeszelAreaChart = memo(
dataKey="time" dataKey="time"
curveType="monotone" curveType="monotone"
withGradient={false} withGradient={false}
connectNulls connectNulls={false}
withDots={false} withDots={false}
type={type} type={type}
strokeWidth={1} strokeWidth={1}
@@ -96,16 +128,18 @@ const BeszelAreaChart = memo(
export const useSystemChartData = ( export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined, systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>, mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
live = false, timePeriod: BeszelTimePeriod = "1h",
) => ) =>
useMemo(() => { useMemo(() => {
if (!systemStats) return []; if (!systemStats) return [];
const { fmt, ordered } = prepareRecords(systemStats, live); const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
return ordered.map((r) => ({ const mapped = ordered.map((r) => ({
time: fmt(r.created), time: fmt(r.created),
rawTime: r.created,
...mapFn(r.stats), ...mapFn(r.stats),
})); }));
}, [systemStats, mapFn, live]); return padTimeGrid(mapped, timePeriod);
}, [systemStats, mapFn, timePeriod]);
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => { export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
const prevRef = useRef<string[]>([]); const prevRef = useRef<string[]>([]);
@@ -144,19 +178,20 @@ export const useDockerChartData = (
containerStats: BeszelContainerStatsRecord[] | undefined, containerStats: BeszelContainerStatsRecord[] | undefined,
containerNames: string[], containerNames: string[],
metric: "cpu" | "memory" | "network", metric: "cpu" | "memory" | "network",
live = false, timePeriod: BeszelTimePeriod = "1h",
) => ) =>
useMemo(() => { useMemo(() => {
if (!containerStats?.length) return []; if (!containerStats?.length) return [];
const extract = defaultContainerExtractors[metric]; const extract = defaultContainerExtractors[metric];
if (!extract) return []; if (!extract) return [];
const { fmt, ordered } = prepareRecords(containerStats, live); const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
return ordered.map((record) => { const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created) }; const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const byName = new Map(record.stats.map((c) => [c.n, c])); const byName = new Map(record.stats.map((c) => [c.n, c]));
for (const name of containerNames) { for (const name of containerNames) {
point[name] = extract(byName.get(name)); point[name] = extract(byName.get(name));
} }
return point; return point;
}); });
}, [containerStats, containerNames, metric, live]); return padTimeGrid(mapped, timePeriod);
}, [containerStats, containerNames, metric, timePeriod]);
+4 -1
View File
@@ -31,7 +31,10 @@ export const useBeszelFilteredSystems = (
statusFilter: string, statusFilter: string,
): SystemRowWithKey[] => { ): SystemRowWithKey[] => {
const allSystems = useMemo( const allSystems = useMemo(
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))), () =>
results
.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` })))
.toSorted((a, b) => a.name.localeCompare(b.name)),
[results], [results],
); );
@@ -0,0 +1,173 @@
"use client";
import { useMemo, useState } from "react";
import { SegmentedControl, SimpleGrid, 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;
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
{ value: "1h", label: "1H" },
{ value: "12h", label: "12H" },
{ value: "24h", label: "24H" },
{ value: "1w", label: "1W" },
{ value: "30d", label: "30D" },
];
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,
},
{ staleTime: 30_000 },
);
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)}
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>
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("widget.beszelSystemStats.name"),
size: 1200,
});
@@ -3,6 +3,10 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
dayjs.extend(localizedFormat);
const styles: Record<string, CSSProperties> = { const styles: Record<string, CSSProperties> = {
wrapper: { wrapper: {
@@ -40,6 +44,7 @@ export interface TooltipPayloadItem {
value: number; value: number;
color: string; color: string;
dataKey: string; dataKey: string;
payload?: Record<string, unknown>;
} }
interface PortalTooltipProps { interface PortalTooltipProps {
@@ -50,16 +55,39 @@ interface PortalTooltipProps {
showTotal?: boolean; showTotal?: boolean;
} }
const MARGIN = 14;
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => { const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
const mouseRef = useRef({ x: 0, y: 0 }); const mouseRef = useRef({ x: 0, y: 0 });
const [mouse, setMouse] = useState({ x: 0, y: 0 }); const [pos, setPos] = useState({ x: 0, y: 0 });
const rafRef = useRef(0); const rafRef = useRef(0);
const tooltipRef = useRef<HTMLDivElement>(null);
const handleMouse = useCallback((e: MouseEvent) => { const handleMouse = useCallback((e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY }; mouseRef.current = { x: e.clientX, y: e.clientY };
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
setMouse(mouseRef.current); const el = tooltipRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
if (!el) {
setPos({ x: mx + MARGIN, y: my });
return;
}
const { width, height } = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = mx + MARGIN;
let y = my - height / 2;
if (x + width > vw - MARGIN) x = mx - width - MARGIN;
if (y < MARGIN) y = MARGIN;
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
setPos({ x, y });
}); });
}, []); }, []);
@@ -84,21 +112,22 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
if (!sorted.length) return null; if (!sorted.length) return null;
const rawTime = sorted[0]?.payload?.rawTime as string | undefined;
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
const total = sorted.reduce((sum, p) => sum + p.value, 0); const total = sorted.reduce((sum, p) => sum + p.value, 0);
const portalStyle: CSSProperties = { const portalStyle: CSSProperties = {
position: "fixed", position: "fixed",
left: mouse.x + 14, left: pos.x,
top: mouse.y, top: pos.y,
transform: "translateY(-50%)",
zIndex: 10000, zIndex: 10000,
pointerEvents: "none", pointerEvents: "none",
}; };
return createPortal( return createPortal(
<div style={portalStyle}> <div ref={tooltipRef} style={portalStyle}>
<div style={styles.wrapper}> <div style={styles.wrapper}>
<div style={styles.header}>{label}</div> <div style={styles.header}>{tooltipLabel}</div>
{sorted.map((item) => ( {sorted.map((item) => (
<div key={item.dataKey} style={styles.row}> <div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} /> <div style={{ ...styles.indicator, background: item.color }} />