From dc862370f67b792dbcd965ca7d0ca1fe4e828f4c Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:28:16 +0100 Subject: [PATCH] feat: improve readability of CPU chart O11Y-1290 (#43822) ## Problem The CPU usage chart in the Observability dashboard could show values exceeding 100%, differing from the internal Grafana panel that support uses for debugging. ## Solution Add a stackedPercent prop to the chart system that locks the Y-axis to [0, 100]. When enabled, the chart never visually overflows 100% while tooltip values remain completely untouched. We fill the rest of the unused with an "Idle" prop. # Before CleanShot 2026-03-18 at 16 04 26@2x ## After CleanShot 2026-03-18 at 16 03 13@2x CleanShot 2026-03-18 at 16 03 41@2x --------- Co-authored-by: Claude Opus 4.6 --- .../components/ui/Charts/Charts.utils.test.ts | 52 +++++++++++- .../components/ui/Charts/Charts.utils.tsx | 39 +++++++++ .../components/ui/Charts/ComposedChart.tsx | 80 ++++++++++++++----- .../ui/Charts/ComposedChart.utils.tsx | 51 +++++++++--- .../ui/Charts/ComposedChartHandler.tsx | 27 +++---- apps/studio/data/reports/database-charts.ts | 33 ++++++-- 6 files changed, 230 insertions(+), 52 deletions(-) diff --git a/apps/studio/components/ui/Charts/Charts.utils.test.ts b/apps/studio/components/ui/Charts/Charts.utils.test.ts index 7ff56eddc37..0a5a3da8f49 100644 --- a/apps/studio/components/ui/Charts/Charts.utils.test.ts +++ b/apps/studio/components/ui/Charts/Charts.utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { computeYAxisDomain } from './Charts.utils' +import { computeYAxisDomain, normalizeStackedSeriesData } from './Charts.utils' const IOPS_DATA = [ { timestamp: 1, disk_iops_write: 1200, disk_iops_read: 24203, disk_iops_max: 25000 }, @@ -172,3 +172,53 @@ describe('computeYAxisDomain', () => { }) }) }) + +describe('normalizeStackedSeriesData', () => { + it('normalizes each stacked point to 100%', () => { + const normalized = normalizeStackedSeriesData({ + data: [{ system: 25, user: 25, idle: 100 }], + attributeNames: ['system', 'user', 'idle'], + }) + + expect(normalized[0].system).toBeCloseTo(16.6666666667) + expect(normalized[0].user).toBeCloseTo(16.6666666667) + expect(normalized[0].idle).toBeCloseTo(66.6666666666) + expect( + Number(normalized[0].system) + Number(normalized[0].user) + Number(normalized[0].idle) + ).toBeCloseTo(100) + }) + + it('leaves empty stacks unchanged', () => { + const empty = [{ system: 0, user: 0, idle: 0 }] + + expect( + normalizeStackedSeriesData({ + data: empty, + attributeNames: ['system', 'user', 'idle'], + }) + ).toEqual(empty) + }) + + it('leaves data unchanged when attributeNames is empty', () => { + const data = [{ system: 50, user: 30, timestamp: 1000 }] + + expect( + normalizeStackedSeriesData({ + data, + attributeNames: [], + }) + ).toEqual(data) + }) + + it('preserves non-stacked keys (timestamps, metadata) unchanged', () => { + const data = [{ system: 60, user: 80, period_start: '2024-01-01', timestamp: 1000 }] + const normalized = normalizeStackedSeriesData({ + data, + attributeNames: ['system', 'user'], + }) + + expect(normalized[0].period_start).toBe('2024-01-01') + expect(normalized[0].timestamp).toBe(1000) + expect(Number(normalized[0].system) + Number(normalized[0].user)).toBeCloseTo(100) + }) +}) diff --git a/apps/studio/components/ui/Charts/Charts.utils.tsx b/apps/studio/components/ui/Charts/Charts.utils.tsx index c3645cca40e..3b412a289c6 100644 --- a/apps/studio/components/ui/Charts/Charts.utils.tsx +++ b/apps/studio/components/ui/Charts/Charts.utils.tsx @@ -174,6 +174,45 @@ export function computeYAxisDomain({ return [0, Math.max(maxRefValue, maxStackedTotal)] } +export function normalizeStackedSeriesData>({ + data, + attributeNames, + totalTarget = 100, +}: { + data: T[] + attributeNames: string[] + totalTarget?: number +}): T[] { + return data.map((point) => { + const values = attributeNames.map((name) => ({ + name, + value: typeof point[name] === 'number' ? point[name] : 0, + })) + const total = values.reduce((sum, entry) => sum + entry.value, 0) + + if (total <= 0) return point + + const largestEntry = values.reduce((largest, entry) => + entry.value > largest.value ? entry : largest + ) + + let normalizedTotal = 0 + const nextPoint: Record = { ...point } + + values.forEach(({ name, value }) => { + if (name === largestEntry.name) return + + const normalizedValue = (value / total) * totalTarget + nextPoint[name] = normalizedValue + normalizedTotal += normalizedValue + }) + + nextPoint[largestEntry.name] = Math.max(0, totalTarget - normalizedTotal) + + return nextPoint as T + }) +} + /** * Hook to create common wrapping components, perform data transformations * returns a Container component and the minHeight set diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index c2d9872dabe..22ebeb58b8c 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -29,7 +29,13 @@ import { updateStackedChartColors, } from './Charts.constants' import { CommonChartProps, Datum } from './Charts.types' -import { computeYAxisDomain, formatPercentage, numberFormatter, useChartSize } from './Charts.utils' +import { + computeYAxisDomain, + formatPercentage, + normalizeStackedSeriesData, + numberFormatter, + useChartSize, +} from './Charts.utils' import { calculateTotalChartAggregate, CustomLabel, @@ -71,6 +77,7 @@ export interface ComposedChartProps extends CommonChartProps { sql?: string highlightActions?: ChartHighlightAction[] showNewBadge?: boolean + normalizeVisibleStackToPercent?: boolean } interface CustomizedDotProps { @@ -130,12 +137,12 @@ export function ComposedChart({ highlightActions, titleTooltip, showNewBadge, + normalizeVisibleStackToPercent = false, }: ComposedChartProps) { const { resolvedTheme } = useTheme() const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( syncId || 'default' ) - const [_activePayload, setActivePayload] = useState(null) const [_showMaxValue, setShowMaxValue] = useState(showMaxValue) const [focusDataIndex, setFocusDataIndex] = useState(null) const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false) @@ -173,6 +180,15 @@ export function ComposedChart({ tick: false, width: 0, } + const yAxisPadding = useMemo(() => { + const needsTopPadding = normalizeVisibleStackToPercent && chartStyle !== 'bar' + if (!needsTopPadding) return _YAxisProps.padding + + return { + ..._YAxisProps.padding, + top: Math.max(8, _YAxisProps.padding?.top ?? 0), + } + }, [_YAxisProps.padding, chartStyle, normalizeVisibleStackToPercent]) function getHeaderLabel() { if (!xAxisIsDate) { @@ -242,8 +258,24 @@ export function ComposedChart({ : undefined if (focusDataIndex !== null) { + const focusedDataPoint = data[focusDataIndex] + ? Object.entries(data[focusDataIndex]) + .map(([key, value]) => ({ + dataKey: key, + value: value as number, + })) + .filter( + (entry) => + entry.dataKey !== 'timestamp' && + entry.dataKey !== 'period_start' && + attributes.some( + (attr) => attr.attribute === entry.dataKey && attr.enabled !== false + ) + ) + : undefined + return showTotal - ? calculateTotalChartAggregate(_activePayload, attributesToIgnoreFromTotal) + ? calculateTotalChartAggregate(focusedDataPoint ?? [], attributesToIgnoreFromTotal) : data[focusDataIndex]?.[yAxisKey] } @@ -311,7 +343,20 @@ export function ComposedChart({ return !attribute?.isMaxValue }) - const visibleAttributes = stackedAttributes.filter((att) => !hiddenAttributes.has(att.name)) + const visibleAttributes = useMemo( + () => stackedAttributes.filter((att) => !hiddenAttributes.has(att.name)), + [stackedAttributes, hiddenAttributes] + ) + const displayData = useMemo( + () => + normalizeVisibleStackToPercent + ? normalizeStackedSeriesData({ + data, + attributeNames: visibleAttributes.map((attribute) => attribute.name), + }) + : data, + [data, normalizeVisibleStackToPercent, visibleAttributes] + ) const isPercentage = format === '%' const isRamChart = @@ -327,16 +372,10 @@ export function ComposedChart({ const isBytesFormat = format === 'bytes' || format === 'bytes-per-second' const shouldFormatBytes = isBytesFormat || isRamChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart - //* - // Set the y-axis domain - // to the highest value in the chart data for percentage charts - // to vertically zoom in on the data - // */ const yMaxFromVisible = Math.max( 0, ...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0)) ) - const yDomain = [0, yMaxFromVisible] const yAxisDomain = useMemo( () => @@ -409,30 +448,31 @@ export function ComposedChart({ /> { - if (!activeTooltipIndex) return + onMouseMove={({ activeLabel, activeTooltipIndex }) => { + if (activeTooltipIndex === undefined || activeTooltipIndex === null) return setIsActiveHoveredChart(true) if (activeTooltipIndex !== focusDataIndex) { setFocusDataIndex(activeTooltipIndex) - setActivePayload(activePayload ?? []) } setHover(activeTooltipIndex) - const activeTimestamp = data[activeTooltipIndex]?.timestamp + const activeTimestamp = + data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp chartHighlight?.handleMouseMove({ activeLabel: activeTimestamp?.toString(), coordinates: activeLabel, }) }} onMouseDown={({ activeLabel, activeTooltipIndex }) => { - if (!activeTooltipIndex) return + if (activeTooltipIndex === undefined || activeTooltipIndex === null) return - const activeTimestamp = data[activeTooltipIndex]?.timestamp + const activeTimestamp = + data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp chartHighlight?.handleMouseDown({ activeLabel: activeTimestamp?.toString(), coordinates: activeLabel, @@ -442,7 +482,6 @@ export function ComposedChart({ onMouseLeave={() => { setIsActiveHoveredChart(false) setFocusDataIndex(null) - setActivePayload(null) clearHover() }} @@ -457,7 +496,8 @@ export function ComposedChart({ hide={hideYAxis} axisLine={{ stroke: CHART_COLORS.AXIS }} tickLine={{ stroke: CHART_COLORS.AXIS }} - domain={yAxisDomain} + domain={_YAxisProps.domain ?? yAxisDomain} + padding={yAxisPadding} key={yAxisKey} /> string + domain?: [number | string, number | string] + allowDataOverflow?: boolean } + normalizeVisibleStackToPercent?: boolean hideHighlightedValue?: boolean } @@ -111,6 +115,8 @@ interface TooltipProps { payload?: any[] label?: string | number attributes?: MultiAttribute[] + data?: Record[] + xAxisKey?: string isPercentage?: boolean format?: string | ((value: unknown) => string) valuePrecision?: number @@ -139,6 +145,8 @@ export const CustomTooltip = ({ payload, label, attributes, + data, + xAxisKey = 'period_start', isPercentage, format, valuePrecision, @@ -152,10 +160,15 @@ export const CustomTooltip = ({ const firstItem = payload[0].payload const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start' const timestamp = payload[0].payload[timestampKey] + const rawDataPoint = data?.find( + (point) => point[xAxisKey] === timestamp || point[timestampKey] === timestamp + ) const maxValueAttribute = isMaxAttribute(attributes) - const maxValueData = - maxValueAttribute && payload?.find((p: any) => p.dataKey === maxValueAttribute.attribute) - const maxValue = maxValueData?.value + const maxValue = + maxValueAttribute && rawDataPoint + ? Number(rawDataPoint[maxValueAttribute.attribute]) + : undefined + const hasFiniteMaxValue = typeof maxValue === 'number' && Number.isFinite(maxValue) const isRamChart = !payload?.some((p: any) => p.dataKey.toLowerCase() === 'ram_usage') && payload?.some((p: any) => p.dataKey.toLowerCase().includes('ram_')) @@ -182,7 +195,15 @@ export const CustomTooltip = ({ const localTimeZone = dayjs.tz.guess() - const total = showTotal && calculateTotalChartAggregate(payload, attributesToIgnoreFromTotal) + const rawPayload = payload.map((entry: any) => ({ + ...entry, + value: + rawDataPoint && typeof rawDataPoint[entry.dataKey] === 'number' + ? Number(rawDataPoint[entry.dataKey]) + : entry.value, + })) + + const total = showTotal && calculateTotalChartAggregate(rawPayload, attributesToIgnoreFromTotal) const getIcon = (color: string, isMax: boolean) => isMax ? : @@ -196,7 +217,14 @@ export const CustomTooltip = ({ const LabelItem = ({ entry }: { entry: any }) => { const attribute = attributes?.find((a: MultiAttribute) => a?.attribute === entry.name) - const percentage = ((entry.value / maxValue) * 100).toFixed(valuePrecision) + const rawValue = + rawDataPoint && typeof rawDataPoint[entry.dataKey] === 'number' + ? Number(rawDataPoint[entry.dataKey]) + : entry.value + const percentage = + hasFiniteMaxValue && maxValue > 0 + ? ((rawValue / maxValue) * 100).toFixed(valuePrecision) + : null const isMax = entry.dataKey === maxValueAttribute?.attribute return ( @@ -206,12 +234,12 @@ export const CustomTooltip = ({ {attribute?.label || entry.name} - {formatNumeric(entry.value) + (!isPercentage && format !== 'ms' ? byteUnitSuffix : '')} + {formatNumeric(rawValue) + (!isPercentage && format !== 'ms' ? byteUnitSuffix : '')} {isPercentage ? '%' : ''} {format === 'ms' ? 'ms' : ''} {/* Show percentage if max value is set */} - {!!maxValueData && !isMax && !isPercentage && ( + {percentage !== null && !isMax && !isPercentage && ( ({percentage}%) )} @@ -229,7 +257,7 @@ export const CustomTooltip = ({

{localTimeZone}

{dayjs(timestamp).format(DateTimeFormats.FULL_SECONDS)}

- {payload.reverse().map((entry: any, index: number) => ( + {[...payload].reverse().map((entry: any, index: number) => ( ))} {active && showTotal && ( @@ -244,11 +272,12 @@ export const CustomTooltip = ({ {format === 'ms' ? 'ms' : ''} {maxValueAttribute && + hasFiniteMaxValue && !isPercentage && - !isNaN((total as number) / maxValueData?.value) && - isFinite((total as number) / maxValueData?.value) && ( + !isNaN((total as number) / maxValue) && + isFinite((total as number) / maxValue) && ( - ({(((total as number) / maxValueData?.value) * 100).toFixed(1)}%) + ({(((total as number) / maxValue) * 100).toFixed(1)}%) )}
diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx index 85799e033e6..8f6efde584d 100644 --- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx @@ -1,24 +1,22 @@ -import { List, Loader2 } from 'lucide-react' -import { useRouter } from 'next/router' -import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react' -import { Card, cn, WarningIcon } from 'ui' - import Panel from 'components/ui/Panel' -import type { ChartHighlightAction } from './ChartHighlightActions' -import { ComposedChart } from './ComposedChart' - import { AnalyticsInterval, DataPoint } from 'data/analytics/constants' import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries' import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query' import { useProjectDailyStatsQueries } from 'data/analytics/project-daily-stats-queries' import { ProjectDailyStatsAttribute } from 'data/analytics/project-daily-stats-query' -import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' -import { useChartHighlight } from './useChartHighlight' - import dayjs from 'dayjs' +import { List, Loader2 } from 'lucide-react' +import { useRouter } from 'next/router' import type { UpdateDateRange } from 'pages/project/[ref]/observability/database' +import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react' +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' +import { Card, cn, WarningIcon } from 'ui' + +import type { ChartHighlightAction } from './ChartHighlightActions' import type { ChartData } from './Charts.types' +import { ComposedChart } from './ComposedChart' import { MultiAttribute } from './ComposedChart.utils' +import { useChartHighlight } from './useChartHighlight' export interface ComposedChartHandlerProps { id?: string @@ -30,7 +28,7 @@ export interface ComposedChartHandlerProps { customDateFormat?: string defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine' hideChartType?: boolean - data?: ChartData + data?: ChartData | DataPoint[] isLoading?: boolean format?: string highlightedValue?: string | number @@ -39,6 +37,7 @@ export interface ComposedChartHandlerProps { showLegend?: boolean showTotal?: boolean showMaxValue?: boolean + normalizeVisibleStackToPercent?: boolean updateDateRange?: UpdateDateRange valuePrecision?: number isVisible?: boolean @@ -134,12 +133,12 @@ const ComposedChartHandler = ({ endDate, interval as AnalyticsInterval, databaseIdentifier, - data, + Array.isArray(data) ? undefined : data, isVisible ) const combinedData = useMemo(() => { - if (data) return data + if (data) return Array.isArray(data) ? data : data.data const isLoading = attributeQueries.some((query: any) => query.isLoading) if (isLoading) return undefined diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index d37aea3a05b..8c3cdbea739 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -1,4 +1,4 @@ -import { compactNumberFormatter, numberFormatter } from 'components/ui/Charts/Charts.utils' +import { compactNumberFormatter } from 'components/ui/Charts/Charts.utils' import { ReportAttributes } from 'components/ui/Charts/ComposedChart.utils' import { DOCS_URL } from 'lib/constants' import { formatBytes } from 'lib/helpers' @@ -76,13 +76,12 @@ export const getReportAttributesV2: ( showLegend: true, showMaxValue: false, showGrid: true, + normalizeVisibleStackToPercent: true, YAxisProps: { - width: 45, - tickFormatter: (value: any) => { - // avoid displaying 100.00% - if (value === 100) return '100%' - return `${numberFormatter(value, 2)}%` - }, + width: 55, + domain: [0, 100] as [number, number], + allowDataOverflow: true, + tickFormatter: (v: number) => `${Math.round(v)}%`, }, hideChartType: false, defaultChartStyle: 'bar', @@ -92,6 +91,8 @@ export const getReportAttributesV2: ( provider: 'infra-monitoring', label: 'System', format: '%', + color: { light: '#EDC35E', dark: '#EDD35E' }, + fill: { light: '#F6D99F', dark: '#5C5230' }, tooltip: 'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead', }, @@ -100,6 +101,8 @@ export const getReportAttributesV2: ( provider: 'infra-monitoring', label: 'User', format: '%', + color: { light: '#0063E8', dark: '#65BCD9' }, + fill: { light: '#80B1F4', dark: '#2A3D45' }, tooltip: 'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries', }, @@ -108,6 +111,8 @@ export const getReportAttributesV2: ( provider: 'infra-monitoring', label: 'IOwait', format: '%', + color: { light: '#DB3A34', dark: '#FF6B6B' }, + fill: { light: '#F2A7A3', dark: '#5C2A2A' }, tooltip: 'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks', }, @@ -116,6 +121,8 @@ export const getReportAttributesV2: ( provider: 'infra-monitoring', label: 'IRQs', format: '%', + color: { light: '#DA760B', dark: '#DA760B' }, + fill: { light: '#FFB885', dark: '#5C3D0A' }, tooltip: 'CPU time handling hardware interrupt requests (IRQ)', }, { @@ -123,9 +130,21 @@ export const getReportAttributesV2: ( provider: 'infra-monitoring', label: 'Other', format: '%', + color: { light: '#B616A6', dark: '#DB8DF9' }, + fill: { light: '#DB8BD3', dark: '#4A3D5C' }, tooltip: 'CPU time spent on other tasks (e.g., background processes, software interrupts)', }, + { + attribute: 'cpu_usage_busy_idle', + provider: 'infra-monitoring', + label: 'Idle', + format: '%', + omitFromTotal: true, + color: { light: '#6EA85F', dark: '#A3FFC2' }, + fill: { light: '#A6D8AE', dark: '#2A5C3F' }, + tooltip: 'CPU time spent idle and available for new work', + }, { attribute: 'cpu_usage_max', provider: 'reference-line',