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
<img width="1240" height="684" alt="CleanShot 2026-03-18 at 16 04 26@2x"
src="https://github.com/user-attachments/assets/c1b1a5ac-86e0-4d6b-9bc4-c7837f30c28c"
/>

## After
<img width="1240" height="684" alt="CleanShot 2026-03-18 at 16 03 13@2x"
src="https://github.com/user-attachments/assets/419eea55-176d-46c4-8780-b8c372428047"
/>
<img width="934" height="700" alt="CleanShot 2026-03-18 at 16 03 41@2x"
src="https://github.com/user-attachments/assets/9b5ef61d-21f4-40e4-ab0c-1a11caadb1f8"
/>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jordi Enric
2026-03-23 15:28:16 +01:00
committed by GitHub
parent 6118eb823e
commit dc862370f6
6 changed files with 230 additions and 52 deletions

View File

@@ -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)
})
})

View File

@@ -174,6 +174,45 @@ export function computeYAxisDomain({
return [0, Math.max(maxRefValue, maxStackedTotal)]
}
export function normalizeStackedSeriesData<T extends Record<string, unknown>>({
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<string, unknown> = { ...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

View File

@@ -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<D = Datum> extends CommonChartProps<D> {
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<any>(null)
const [_showMaxValue, setShowMaxValue] = useState(showMaxValue)
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(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({
/>
<Container className="relative z-10">
<RechartComposedChart
data={data}
data={displayData}
syncId={syncId}
style={{ cursor: 'crosshair' }}
onMouseMove={({ activeLabel, activeTooltipIndex, activePayload }) => {
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}
/>
<XAxis
@@ -571,10 +611,12 @@ export function ComposedChart({
showTooltip && !showHighlightActions ? (
<CustomTooltip
{...props}
data={data}
format={format}
isPercentage={isPercentage}
label={resolvedHighlightedLabel}
attributes={attributes}
xAxisKey={xAxisKey}
valuePrecision={valuePrecision}
showTotal={showTotal}
isActiveHoveredChart={

View File

@@ -4,6 +4,7 @@ import dayjs from 'dayjs'
import { formatBytes } from 'lib/helpers'
import { useState } from 'react'
import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
import { CHART_COLORS, DateTimeFormats } from './Charts.constants'
import { formatPercentage, numberFormatter } from './Charts.utils'
@@ -30,7 +31,10 @@ export interface ReportAttributes {
YAxisProps?: {
width?: number
tickFormatter?: (value: any) => 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<string, unknown>[]
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 ? <MaxConnectionsIcon /> : <CustomIcon color={color} />
@@ -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}
</span>
<span className="ml-3.5 flex items-end gap-1">
{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 && (
<span className="text-[11px] text-foreground-light mb-0.5">({percentage}%)</span>
)}
</span>
@@ -229,7 +257,7 @@ export const CustomTooltip = ({
<p className="text-foreground-light text-xs">{localTimeZone}</p>
<p className="font-medium">{dayjs(timestamp).format(DateTimeFormats.FULL_SECONDS)}</p>
<div className="grid gap-0">
{payload.reverse().map((entry: any, index: number) => (
{[...payload].reverse().map((entry: any, index: number) => (
<LabelItem key={`${entry.name}-${index}`} entry={entry} />
))}
{active && showTotal && (
@@ -244,11 +272,12 @@ export const CustomTooltip = ({
{format === 'ms' ? 'ms' : ''}
</span>
{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) && (
<span className="text-[11px] text-foreground-light mb-0.5">
({(((total as number) / maxValueData?.value) * 100).toFixed(1)}%)
({(((total as number) / maxValue) * 100).toFixed(1)}%)
</span>
)}
</div>

View File

@@ -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

View File

@@ -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',