Files
supabase/apps/studio/components/ui/Charts/ChartHeader.tsx
Jordi EnricandClaude Opus 4.7 420d4d0b5a feat(studio): add Disk IO Burst Balance chart to DB Report (#45516)
## Problem

When users hit a disk IO exhaustion notification, they open the Database
Observability report and find IOPS and throughput charts, but no view of
the metric that actually fired the warning: the EBS burst credit
balance. That signal is currently only available behind Custom Reports,
so the warning feels disconnected from the visible data.

> "The Dashboard actually shows them their burst budget, not their
normal disk usage. It leads to a lot of confusion." — support
> "Customers may receive an 'exhausting multiple resources' warning and
it's confusing because all the charts are 'fine', but it's the burst
credits exhaustion that fails them." — Maksym

Reported in [Linear
DEBUG-60](https://linear.app/supabase/issue/DEBUG-60).

## Fix

Add a clearly-labeled "Disk IO Burst Balance" chart to
[getReportAttributesV2](apps/studio/data/reports/database-charts.ts)
plotting `disk_io_budget` (% remaining) on a 0-100 axis. Tooltip and
titleTooltip explain the 5 MB/s throttle floor so users can correlate
this chart with the exhaustion banner copy (also being updated in
[supabase#45514](https://github.com/supabase/supabase/pull/45514)).

The chart is hidden for compute variants that have dedicated I/O
resources (4XL and above) since the burst credit pool does not apply
there. Burstable variants (nano through 2XL) see the chart inline next
to the existing IOPS and throughput charts.

DEBUG-61 ("replace Disk IO Usage % chart with disk throughput") was
already addressed in the V2 migration (the report only renders
`disk-iops` and `disk-throughput`; the old burst-percentage chart is
gone). I'll close that issue as completed once this lands.

## Test plan

- [ ] Visit `/project/{ref}/observability/database` on a project running
`nano`/`micro`/`small`/`medium`/`large`/`xlarge`/`2xlarge` compute.
Confirm the new "Disk IO Burst Balance" chart appears between throughput
and connections.
- [ ] On a project running `4xlarge` or larger compute, confirm the
chart is hidden (chart is omitted by `hide: !hasBurstableIO`).
- [ ] Hover the chart and a data point. Confirm the title tooltip and
inline attribute tooltip both surface the 5 MB/s throttle behaviour.
- [ ] Series renders 0-100 on the Y-axis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a Disk IO Burst Balance chart to monitor remaining burst
credits; displays percentage (0–100%) with tooltip referencing baseline
throughput. Shown only for supported compute variants and when the
feature flag is enabled.

* **Bug Fixes / UI Improvements**
* Improved chart header tooltip presentation: uses a unified tooltip,
includes inline docs links when available, and avoids duplicate docs
icons.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45516)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 18:02:55 +02:00

330 lines
9.8 KiB
TypeScript

import { useParams } from 'common'
import {
Activity,
BarChartIcon,
GitCommitHorizontalIcon,
InfoIcon,
SquareTerminal,
} from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { formatPercentage, numberFormatter } from './Charts.utils'
import { useChartHoverState } from './useChartHoverState'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { formatDateTime, useFormatDateTime } from '@/lib/datetime'
import { formatBytes } from '@/lib/helpers'
export interface ChartHeaderProps {
title?: string
format?: string | ((value: unknown) => string)
customDateFormat?: string
minimalHeader?: boolean
displayDateInUtc?: boolean
highlightedLabel?: number | string | any | null
highlightedValue?: number | string | any | null
hideHighlightedValue?: boolean
hideHighlightedLabel?: boolean
hideHighlightArea?: boolean
hideChartType?: boolean
chartStyle?: string
onChartStyleChange?: (style: string) => void
showMaxValue?: boolean
setShowMaxValue?: (value: boolean) => void
docsUrl?: string
syncId?: string
data?: any[]
xAxisKey?: string
yAxisKey?: string
xAxisIsDate?: boolean
valuePrecision?: number
shouldFormatBytes?: boolean
isNetworkChart?: boolean
attributes?: any[]
sql?: string
titleTooltip?: string
showNewBadge?: boolean
}
export const ChartHeader = ({
format,
highlightedValue,
highlightedLabel,
hideHighlightedValue = false,
hideHighlightedLabel = false,
hideHighlightArea = false,
title,
minimalHeader = false,
hideChartType = false,
chartStyle = 'bar',
onChartStyleChange,
showMaxValue = false,
setShowMaxValue,
docsUrl,
syncId,
data,
xAxisKey,
yAxisKey,
xAxisIsDate = true,
displayDateInUtc,
customDateFormat,
valuePrecision = 2,
shouldFormatBytes = false,
isNetworkChart = false,
attributes,
sql,
titleTooltip,
showNewBadge,
}: ChartHeaderProps) => {
const { ref } = useParams()
const { hoveredIndex, isHovered } = useChartHoverState(syncId || 'default')
const [localHighlightedValue, setLocalHighlightedValue] = useState(highlightedValue)
const [localHighlightedLabel, setLocalHighlightedLabel] = useState(highlightedLabel)
// When `displayDateInUtc` is set the chart explicitly wants UTC labels.
// Otherwise honour the user's selected timezone via the picker.
const formatPickerDate = useFormatDateTime()
const formatHighlightedValue = (value: any) => {
if (typeof value !== 'number') {
return value
}
if (typeof format === 'function') {
return format(value)
}
if (shouldFormatBytes) {
const bytesValue = isNetworkChart ? Math.abs(value) : value
return formatBytes(bytesValue, valuePrecision)
}
if (format === '%') {
return formatPercentage(value, valuePrecision)
}
const formattedValue = numberFormatter(value, valuePrecision)
if (typeof format === 'string' && format) {
return `${formattedValue} ${format}`
}
return formattedValue
}
useEffect(() => {
if (syncId && hoveredIndex !== null && isHovered && data && xAxisKey && yAxisKey) {
const activeDataPoint = data[hoveredIndex]
if (activeDataPoint) {
// For stacked charts, we need to calculate the total of all attributes
// that should be included in the total (excluding reference lines, max values, etc.)
let newValue = activeDataPoint[yAxisKey]
// If this is a stacked chart with multiple attributes, calculate the total
if (attributes && attributes.length > 1) {
const attributesToIgnore =
attributes
?.filter((a) => a.omitFromTotal || a.isMaxValue || a.provider === 'reference-line')
?.map((a) => a.attribute) ?? []
const totalValue = Object.entries(activeDataPoint)
.filter(([key, value]) => {
// Include only numeric values that are not in the ignore list
return (
typeof value === 'number' &&
key !== 'timestamp' &&
key !== 'period_start' &&
!attributesToIgnore.includes(key) &&
attributes.some((attr) => attr.attribute === key && attr.enabled !== false)
)
})
.reduce((sum, [_, value]) => sum + (value as number), 0)
newValue = totalValue
}
setLocalHighlightedValue(newValue)
// Update highlighted label based on sync state
let newLabel = highlightedLabel
if (xAxisIsDate && activeDataPoint[xAxisKey]) {
const value = activeDataPoint[xAxisKey] as number | string
const fmt = customDateFormat || 'YYYY-MM-DD HH:mm:ss'
newLabel = displayDateInUtc
? formatDateTime(value, { tz: 'UTC', format: fmt })
: formatPickerDate(value, fmt)
} else if (activeDataPoint[xAxisKey]) {
newLabel = activeDataPoint[xAxisKey]
}
setLocalHighlightedLabel(newLabel)
}
} else {
// Reset to original values when not syncing
setLocalHighlightedValue(highlightedValue)
setLocalHighlightedLabel(highlightedLabel)
}
}, [
hoveredIndex,
isHovered,
syncId,
data,
xAxisKey,
yAxisKey,
xAxisIsDate,
displayDateInUtc,
customDateFormat,
highlightedValue,
highlightedLabel,
attributes,
formatPickerDate,
])
const chartTitle = (
<div className="flex flex-row items-center gap-x-2">
<div className="flex flex-row items-center gap-x-2">
<h3 className={'text-foreground-lighter ' + (minimalHeader ? 'text-xs' : 'text-sm')}>
{title}
</h3>
{titleTooltip && (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="w-4 h-4 text-foreground-lighter" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{titleTooltip}
{docsUrl && (
<>
{' '}
<Link
href={docsUrl}
target="_blank"
className="underline text-foreground hover:text-foreground-light"
>
Read docs
</Link>
</>
)}
</TooltipContent>
</Tooltip>
)}
</div>
{!titleTooltip && docsUrl && (
<ButtonTooltip
type="text"
className="px-1"
asChild
tooltip={{
content: {
side: 'top',
text: 'Read docs',
},
}}
>
<Link href={docsUrl} target="_blank">
<InfoIcon className="w-4 h-4 text-foreground-lighter" />
</Link>
</ButtonTooltip>
)}
</div>
)
const highlighted = (
<h4
className={`text-foreground text-xl font-normal ${minimalHeader ? 'text-base' : 'text-2xl'}`}
>
{localHighlightedValue !== undefined && formatHighlightedValue(localHighlightedValue)}
</h4>
)
const label = <h4 className="text-foreground-lighter text-xs">{localHighlightedLabel}</h4>
if (minimalHeader) {
return (
<div
className={cn('flex flex-row items-center gap-x-4', hideHighlightArea && 'hidden')}
style={{ minHeight: '1.8rem' }}
>
{title && chartTitle}
<div className="flex flex-row items-baseline gap-x-2">
{highlightedValue !== undefined && !hideHighlightedValue && highlighted}
{!hideHighlightedLabel && label}
</div>
</div>
)
}
const hasHighlightedValue = highlightedValue !== undefined && !hideHighlightedValue
return (
<div
className={cn(
'grow flex justify-between items-start min-h-16',
hideHighlightArea && 'hidden'
)}
>
<div className="flex flex-col">
<div className="flex items-center gap-2">
{title && chartTitle}
{showNewBadge && <Badge variant="success">New</Badge>}
</div>
<div className="h-4">
{hasHighlightedValue && highlighted}
{!hideHighlightedLabel && label}
</div>
</div>
<div className="flex items-center gap-2">
{sql ? (
<ButtonTooltip
type="default"
className="px-1.5"
asChild
tooltip={{
content: {
side: 'top',
text: 'Open in Log Explorer',
},
}}
>
<Link href={`/project/${ref}/logs/explorer?q=${encodeURIComponent(sql)}`}>
<SquareTerminal className="w-4 h-4 text-foreground-lighter" />
</Link>
</ButtonTooltip>
) : null}
{!hideChartType && onChartStyleChange && (
<ButtonTooltip
type="default"
className="px-1.5"
icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
onClick={() => onChartStyleChange(chartStyle === 'bar' ? 'line' : 'bar')}
tooltip={{
content: {
side: 'top',
text: `View as ${chartStyle === 'bar' ? 'line chart' : 'bar chart'}`,
},
}}
/>
)}
{setShowMaxValue && (
<ButtonTooltip
type={showMaxValue ? 'default' : 'dashed'}
className="px-1.5"
icon={
<GitCommitHorizontalIcon
className={showMaxValue ? 'text-foreground-light' : 'text-foreground-lighter'}
/>
}
onClick={() => setShowMaxValue(!showMaxValue)}
tooltip={{
content: {
side: 'top',
text: `${showMaxValue ? 'Hide' : 'Show'} limit`,
},
}}
/>
)}
</div>
</div>
)
}