From 04b6067e64e19c9aa90c9dc8480db24afe07f61c Mon Sep 17 00:00:00 2001 From: henrygd Date: Tue, 4 Nov 2025 15:35:23 -0500 Subject: [PATCH] add a total line to the tooltip of charts with multiple values #1280 Co-authored-by: Titouan V --- .../site/src/components/charts/area-chart.tsx | 34 ++++--- .../src/components/charts/container-chart.tsx | 2 +- .../site/src/components/charts/mem-chart.tsx | 1 + .../site/src/components/routes/system.tsx | 2 + internal/site/src/components/ui/chart.tsx | 88 +++++++++++++++++++ 5 files changed, 114 insertions(+), 13 deletions(-) diff --git a/internal/site/src/components/charts/area-chart.tsx b/internal/site/src/components/charts/area-chart.tsx index 94f34003..2a1e1d69 100644 --- a/internal/site/src/components/charts/area-chart.tsx +++ b/internal/site/src/components/charts/area-chart.tsx @@ -30,6 +30,7 @@ export default function AreaChartDefault({ domain, legend, itemSorter, + showTotal = false, reverseStackOrder = false, hideYAxis = false, }: // logRender = false, @@ -42,6 +43,7 @@ export default function AreaChartDefault({ dataPoints?: DataPoint[] domain?: [number, number] legend?: boolean + showTotal?: boolean itemSorter?: (a: any, b: any) => number reverseStackOrder?: boolean hideYAxis?: boolean @@ -65,18 +67,25 @@ export default function AreaChartDefault({ "ps-4": hideYAxis, })} > - + - {!hideYAxis && updateYAxisWidth(tickFormatter(value, index))} - tickLine={false} - axisLine={false} - />} + {!hideYAxis && ( + updateYAxisWidth(tickFormatter(value, index))} + tickLine={false} + axisLine={false} + /> + )} {xAxis(chartData)} formatShortDate(data[0].payload.created)} contentFormatter={contentFormatter} + showTotal={showTotal} /> } /> @@ -114,5 +124,5 @@ export default function AreaChartDefault({ ) - }, [chartData.systemStats.at(-1), yAxisWidth, maxToggled]) + }, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal]) } diff --git a/internal/site/src/components/charts/container-chart.tsx b/internal/site/src/components/charts/container-chart.tsx index 2093fc27..0b301375 100644 --- a/internal/site/src/components/charts/container-chart.tsx +++ b/internal/site/src/components/charts/container-chart.tsx @@ -139,7 +139,7 @@ export default memo(function ContainerChart({ labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} // @ts-expect-error itemSorter={(a, b) => b.value - a.value} - content={} + content={} /> {Object.keys(chartConfig).map((key) => { const filtered = filteredKeys.has(key) diff --git a/internal/site/src/components/charts/mem-chart.tsx b/internal/site/src/components/charts/mem-chart.tsx index 90b65b25..98dd05a3 100644 --- a/internal/site/src/components/charts/mem-chart.tsx +++ b/internal/site/src/components/charts/mem-chart.tsx @@ -61,6 +61,7 @@ export default memo(function MemChart({ chartData, showMax }: { chartData: Chart const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit }} + showTotal={true} /> } /> diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index c46e6427..a4996bde 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -699,6 +699,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false) return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}` }} + showTotal={true} /> @@ -752,6 +753,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false) return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}` }} + showTotal={true} /> diff --git a/internal/site/src/components/ui/chart.tsx b/internal/site/src/components/ui/chart.tsx index 614d6d12..3e3d120e 100644 --- a/internal/site/src/components/ui/chart.tsx +++ b/internal/site/src/components/ui/chart.tsx @@ -1,8 +1,10 @@ import type { JSX } from "react" +import { useLingui } from "@lingui/react/macro" import * as React from "react" import * as RechartsPrimitive from "recharts" import { chartTimeData, cn } from "@/lib/utils" import type { ChartData } from "@/types" +import { Separator } from "./separator" // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: "", dark: ".dark" } as const @@ -100,6 +102,8 @@ const ChartTooltipContent = React.forwardRef< filter?: string contentFormatter?: (item: any, key: string) => React.ReactNode | string truncate?: boolean + showTotal?: boolean + totalLabel?: React.ReactNode } >( ( @@ -121,11 +125,16 @@ const ChartTooltipContent = React.forwardRef< itemSorter, contentFormatter: content = undefined, truncate = false, + showTotal = false, + totalLabel, }, ref ) => { // const { config } = useChart() const config = {} + const { t } = useLingui() + const totalLabelNode = totalLabel ?? t`Total` + const totalName = typeof totalLabelNode === "string" ? totalLabelNode : t`Total` React.useMemo(() => { if (filter) { @@ -141,6 +150,76 @@ const ChartTooltipContent = React.forwardRef< } }, [itemSorter, payload]) + const totalValueDisplay = React.useMemo(() => { + if (!showTotal || !payload?.length) { + return null + } + + let totalValue = 0 + let hasNumericValue = false + const aggregatedNestedValues: Record = {} + + for (const item of payload) { + const numericValue = typeof item.value === "number" ? item.value : Number(item.value) + if (Number.isFinite(numericValue)) { + totalValue += numericValue + hasNumericValue = true + } + + if (content && item?.payload) { + const payloadKey = `${nameKey || item.name || item.dataKey || "value"}` + const nestedPayload = (item.payload as Record | undefined)?.[payloadKey] + + if (nestedPayload && typeof nestedPayload === "object") { + for (const [nestedKey, nestedValue] of Object.entries(nestedPayload)) { + if (typeof nestedValue === "number" && Number.isFinite(nestedValue)) { + aggregatedNestedValues[nestedKey] = (aggregatedNestedValues[nestedKey] ?? 0) + nestedValue + } + } + } + } + } + + if (!hasNumericValue) { + return null + } + + const totalKey = "__total__" + const totalItem: any = { + value: totalValue, + name: totalName, + dataKey: totalKey, + color, + } + + if (content) { + const basePayload = + payload[0]?.payload && typeof payload[0].payload === "object" + ? { ...(payload[0].payload as Record) } + : {} + totalItem.payload = { + ...basePayload, + [totalKey]: aggregatedNestedValues, + } + } + + if (typeof formatter === "function") { + return formatter( + totalValue, + totalName, + totalItem, + payload.length, + totalItem.payload ?? payload[0]?.payload + ) + } + + if (content) { + return content(totalItem, totalKey) + } + + return `${totalValue.toLocaleString()}${unit ?? ""}` + }, [color, content, formatter, nameKey, payload, showTotal, totalName, unit]) + const tooltipLabel = React.useMemo(() => { if (hideLabel || !payload?.length) { return null @@ -242,6 +321,15 @@ const ChartTooltipContent = React.forwardRef< ) })} + {totalValueDisplay ? ( + <> + +
+ {totalLabelNode} + {totalValueDisplay} +
+ + ) : null} )