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))
+ }
/>
);
};