From c18fa8cf7794d9a4d1646aff55bed63d36f958a3 Mon Sep 17 00:00:00 2001 From: Bergasha Date: Fri, 28 Aug 2026 15:08:22 +1000 Subject: [PATCH] fix(widgets): replace system-resources chart tooltip with Mantine (#6729) * fix(widgets): replace system-resources chart tooltip with Mantine Tooltip.Floating --- packages/translation/src/lang/en.json | 3 +- .../chart/combined-network-traffic.tsx | 61 +++-- .../system-resources/chart/common-chart.tsx | 244 ++++++++++++------ .../src/system-resources/chart/cpu-chart.tsx | 20 +- .../src/system-resources/chart/gpu-chart.tsx | 20 +- .../system-resources/chart/memory-chart.tsx | 44 ++-- .../chart/network-traffic.tsx | 16 +- 7 files changed, 238 insertions(+), 170 deletions(-) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a52328c0d..4dc3b1783 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4851,7 +4851,8 @@ "memory": "MEM", "network": "NET", "up": "UP", - "down": "DOWN" + "down": "DOWN", + "memoryTooltip": "{used} / {available} ({percent}%)" } }, "systemDisks": { diff --git a/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx b/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx index 756c7e65b..194a87fe9 100644 --- a/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx +++ b/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx @@ -1,4 +1,4 @@ -import { Box, Group, Paper, Stack, Text } from "@mantine/core"; +import { Box, Group, Stack, Text } from "@mantine/core"; import { IconNetwork } from "@tabler/icons-react"; import { formatByteRate } from "@homarr/common"; @@ -21,9 +21,43 @@ export const CombinedNetworkTrafficChart = ({ labelDisplayMode: LabelDisplayModeOption; advanced?: boolean; }) => { - const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down })); + const chartData = usageOverTime.map((usage, index) => ({ + index, + up: usage.up, + down: usage.down, + })); const t = useI18n("widget.systemResources.card"); + const tooltipLabel = (index: number) => { + const point = usageOverTime[index]; + return ( + + + + + {t("up")}: {formatByteRate(Math.round(point?.up ?? 0))} + + + + + + {t("down")}: {formatByteRate(Math.round(point?.down ?? 0))} + + + + ); + }; + return ( { - return ( - - - {payload.map((payloadData) => ( - - - - {payloadData.value === undefined ? ( - <>N/A - ) : ( - formatByteRate(Math.round(Number(payloadData.value))) - )} - - - ))} - - - ); - }, - }} + tooltipLabel={tooltipLabel} /> ); }; diff --git a/packages/widgets/src/system-resources/chart/common-chart.tsx b/packages/widgets/src/system-resources/chart/common-chart.tsx index 7902b8705..42535611a 100644 --- a/packages/widgets/src/system-resources/chart/common-chart.tsx +++ b/packages/widgets/src/system-resources/chart/common-chart.tsx @@ -1,10 +1,24 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { CSSProperties, ReactNode } from "react"; +import type { + CSSProperties, + PointerEvent as ReactPointerEvent, + ReactNode, +} from "react"; +import { useState } from "react"; import type { AreaChartSeries } from "@mantine/charts"; import { AreaChart, LineChart } from "@mantine/charts"; -import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core"; +import { + Card, + Center, + Group, + Stack, + Text, + Tooltip, + useComputedColorScheme, + useMantineTheme, +} from "@mantine/core"; import { useElementSize, useHover, useMergedRef } from "@mantine/hooks"; -import type { TooltipProps, YAxisProps } from "recharts"; +import type { YAxisProps } from "recharts"; import { useRequiredBoard } from "@homarr/boards/context"; import { zoomCompensatedSize } from "@homarr/ui"; @@ -19,7 +33,7 @@ export const CommonChart = ({ title, icon: Icon, labelDisplayMode, - tooltipProps, + tooltipLabel, yAxisProps, lastValue, chartType = "line", @@ -31,7 +45,10 @@ export const CommonChart = ({ title: ReactNode; icon: TablerIcon; labelDisplayMode: LabelDisplayModeOption; - tooltipProps?: TooltipProps; + // What to show on hover. Defaults to lastValue when omitted. Pass a function to reflect + // the datum under the cursor as it moves across multi-point data (e.g. memory's + // used/available at that point), instead of always showing the latest value. + tooltipLabel?: ReactNode | ((index: number) => ReactNode); yAxisProps?: Omit; lastValue?: string; chartType?: "line" | "area"; @@ -45,63 +62,80 @@ export const CommonChart = ({ const ref = useMergedRef(elementSizeRef, hoverRef); const opacity = board.opacity / 100; - let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`; + let backgroundColor = + colorScheme === "dark" + ? `rgba(57, 57, 57, ${opacity})` + : `rgba(246, 247, 248, ${opacity})`; const cardStyle: CSSProperties = { overflow: "hidden" }; - const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] }; + const chartRootStyle: CSSProperties = { + padding: 5, + borderRadius: theme.radius[board.itemRadius], + }; if (advanced) { - backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; + backgroundColor = + "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; cardStyle.border = "1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; chartRootStyle.backgroundColor = backgroundColor; } const ChartComponent = chartType === "line" ? LineChart : AreaChart; - const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; - const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon"; + const showIcon = + labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; + const showText = + labelDisplayMode === "text" || labelDisplayMode === "textWithIcon"; + + // Track which data point the cursor is over so the tooltip can reflect that point + // instead of always showing the latest value while scrubbing across the chart. + const [hoveredIndex, setHoveredIndex] = useState(data.length - 1); + const handlePointerMove = (event: ReactPointerEvent) => { + if (data.length <= 1) return; + const rect = event.currentTarget.getBoundingClientRect(); + if (rect.width === 0) return; + const ratio = (event.clientX - rect.left) / rect.width; + const index = Math.round(ratio * (data.length - 1)); + setHoveredIndex(Math.min(data.length - 1, Math.max(0, index))); + }; + + // Clamp on read rather than syncing via an effect - handles data growing/shrinking + // between renders (e.g. history resetting on integration change) without ever + // handing a stale or out-of-range index to tooltipLabel. + const clampedHoveredIndex = Math.max( + 0, + Math.min(hoveredIndex, data.length - 1), + ); + const resolvedTooltipLabel = + typeof tooltipLabel === "function" + ? tooltipLabel(clampedHoveredIndex) + : (tooltipLabel ?? lastValue); return ( - - {data.length > 1 && height > 40 && !hovered && ( - - {showIcon && ( - 100 ? 20 : 14)} - stroke={1.5} - /> - )} - {showText && ( - 100 ? "md" : "xs"} fw={"bold"}> - {title} - - )} - {lastValue && ( - 100 ? "md" : "xs"} lineClamp={1}> - {lastValue} - - )} - - )} - {advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && ( -
- - {lastValue} - -
- )} - {data.length <= 1 ? ( -
- + + + {data.length > 1 && height > 40 && !hovered && ( + {showIcon && ( )} {showText && ( - 100 ? "md" : "xs"} fw={"bold"} ta="center"> + 100 ? "md" : "xs"} fw={"bold"}> {title} )} - {advanced && lastValue && ( - 100 ? "md" : "xs"} fw={600} ta="center"> + {lastValue && ( + 100 ? "md" : "xs"} + lineClamp={1} + > {lastValue} )} - -
- ) : ( - = 64} - yAxisProps={yAxisProps} - fillOpacity={chartType === "area" ? 0.3 : undefined} - /> - )} -
+ + )} + {advanced && + data.length > 1 && + height > 0 && + height <= 40 && + lastValue && + !hovered && ( +
+ + {lastValue} + +
+ )} + {data.length <= 1 ? ( +
+ + {showIcon && ( + 100 ? 20 : 14)} + stroke={1.5} + /> + )} + {showText && ( + 100 ? "md" : "xs"} + fw={"bold"} + ta="center" + > + {title} + + )} + {advanced && lastValue && ( + 100 ? "md" : "xs"} fw={600} ta="center"> + {lastValue} + + )} + +
+ ) : ( + + )} + + ); }; diff --git a/packages/widgets/src/system-resources/chart/cpu-chart.tsx b/packages/widgets/src/system-resources/chart/cpu-chart.tsx index 2922b737a..33ff0cc86 100644 --- a/packages/widgets/src/system-resources/chart/cpu-chart.tsx +++ b/packages/widgets/src/system-resources/chart/cpu-chart.tsx @@ -1,4 +1,3 @@ -import { Paper, Text } from "@mantine/core"; import { IconCpu } from "@tabler/icons-react"; import { invariantTechnicalLabels } from "@homarr/definitions"; @@ -27,25 +26,16 @@ export const SystemResourceCPUChart = ({ title={invariantTechnicalLabels.cpu} icon={IconCpu} lastValue={ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined + cpuUsageOverTime.length > 0 + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` + : undefined } chartType={hasShadow ? "area" : "line"} yAxisProps={{ domain: [0, 100] }} labelDisplayMode={labelDisplayMode} advanced={advanced} - tooltipProps={{ - content: ({ payload }) => { - const value = payload[0] ? Number(payload[0].value) : 0; - return ( - - - {value.toFixed(0)}% - - - ); - }, - }} + tooltipLabel={(index) => `${Math.round(cpuUsageOverTime[index] ?? 0)}%`} /> ); }; diff --git a/packages/widgets/src/system-resources/chart/gpu-chart.tsx b/packages/widgets/src/system-resources/chart/gpu-chart.tsx index 51d7ea9e5..14d5ad59c 100644 --- a/packages/widgets/src/system-resources/chart/gpu-chart.tsx +++ b/packages/widgets/src/system-resources/chart/gpu-chart.tsx @@ -1,4 +1,3 @@ -import { Paper, Text } from "@mantine/core"; import { IconDeviceDesktop } from "@tabler/icons-react"; import { invariantTechnicalLabels } from "@homarr/definitions"; @@ -27,25 +26,16 @@ export const SystemResourceGPUChart = ({ title={invariantTechnicalLabels.gpu} icon={IconDeviceDesktop} lastValue={ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - gpuUsageOverTime.length > 0 ? `${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%` : undefined + gpuUsageOverTime.length > 0 + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + `${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%` + : undefined } chartType={hasShadow ? "area" : "line"} yAxisProps={{ domain: [0, 100] }} labelDisplayMode={labelDisplayMode} advanced={advanced} - tooltipProps={{ - content: ({ payload }) => { - const value = payload[0] ? Number(payload[0].value) : 0; - return ( - - - {value.toFixed(0)}% - - - ); - }, - }} + tooltipLabel={(index) => `${Math.round(gpuUsageOverTime[index] ?? 0)}%`} /> ); }; diff --git a/packages/widgets/src/system-resources/chart/memory-chart.tsx b/packages/widgets/src/system-resources/chart/memory-chart.tsx index 187ecb670..4b7c040c2 100644 --- a/packages/widgets/src/system-resources/chart/memory-chart.tsx +++ b/packages/widgets/src/system-resources/chart/memory-chart.tsx @@ -1,4 +1,3 @@ -import { Paper, Text } from "@mantine/core"; import { IconBrain } from "@tabler/icons-react"; import { formatBytesPair } from "@homarr/common"; @@ -20,15 +19,33 @@ export const SystemResourceMemoryChart = ({ labelDisplayMode: LabelDisplayModeOption; advanced?: boolean; }) => { - const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage })); + const chartData = memoryUsageOverTime.map((usage, index) => ({ + index, + usage, + })); const t = useI18n("widget.systemResources.card"); const percentageUsed = memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0 ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes + memoryUsageOverTime[memoryUsageOverTime.length - 1]! / + totalCapacityInBytes : undefined; + const tooltipLabel = (index: number) => { + const used = memoryUsageOverTime[index] ?? 0; + const memory = formatBytesPair(Math.round(used), totalCapacityInBytes); + const percent = + totalCapacityInBytes > 0 + ? Math.round((used / totalCapacityInBytes) * 100) + : 0; + return t("memoryTooltip", { + used: memory.used, + available: memory.available, + percent, + }); + }; + return ( { - const value = payload[0] ? Number(payload[0].value) : 0; - const memory = formatBytesPair(Math.round(value), totalCapacityInBytes); - return ( - - - {memory.used} / {memory.available} ( - {totalCapacityInBytes > 0 ? Math.round((value / totalCapacityInBytes) * 100) : 0}%) - - - ); - }, - }} + tooltipLabel={tooltipLabel} /> ); }; diff --git a/packages/widgets/src/system-resources/chart/network-traffic.tsx b/packages/widgets/src/system-resources/chart/network-traffic.tsx index 44d81b17e..a8513c7d9 100644 --- a/packages/widgets/src/system-resources/chart/network-traffic.tsx +++ b/packages/widgets/src/system-resources/chart/network-traffic.tsx @@ -1,4 +1,3 @@ -import { Paper, Text } from "@mantine/core"; import { IconArrowDown, IconArrowUp } from "@tabler/icons-react"; import { formatByteRate } from "@homarr/common"; @@ -39,18 +38,9 @@ export const NetworkTrafficChart = ({ chartType={hasShadow ? "area" : "line"} labelDisplayMode={labelDisplayMode} advanced={advanced} - tooltipProps={{ - content: ({ payload }) => { - const value = payload[0] ? Number(payload[0].value) : 0; - return ( - - - {formatByteRate(Math.round(value))} - - - ); - }, - }} + tooltipLabel={(index) => + formatByteRate(Math.round(usageOverTime[index] ?? 0)) + } /> ); };