Files
supabase/apps/studio/components/ui/Charts/ComposedChart.tsx
Jordi Enric 5d9cf3971f fix(studio): improve memory and swap units in DB reports (#45889)
Fixes [DEBUG-94](https://linear.app/supabase/issue/DEBUG-94)

## Summary

- **Min MB units**: Added \`formatBytesMinMB\` helper that always
formats byte values in at least MB. Applied to RAM and swap y-axis tick
formatters, tooltips, and chart headers.
- **Swap chart scale**: Every Supabase compute instance is provisioned
with **1 GB of swap regardless of size** (per
[docs](https://supabase.com/docs/guides/troubleshooting/memory-and-swap-usage-explained-aPNgm0)),
so the swap y-axis always shows at least 0 to 1 GB. Low swap usage no
longer fills the full chart height. Removed the show/hide limit toggle
since the limit is implicit in the y-axis scale.
- **Swap units in header/tooltip**: Fixed all three formatters (initial
header, hover-sync header, tooltip) to use \`formatBytesMinMB\` for swap
so the value always shows a unit like "2.00 MB".
- **Reference-line defensive fix**: Added a \`customValue\` fallback in
\`useAttributeQueries\` so future reference-line attributes that use
\`customValue\` instead of \`value\` are not silently overwritten to 0.

## Test plan

- [ ] Open DB Reports and verify RAM/Swap y-axis labels show MB (or GB
for large values), never KB or bytes
- [ ] Hover a bar in the swap chart and verify tooltip and header show
the same value with a unit (e.g. "2.00 MB")
- [ ] With low or zero swap usage, the chart bars are flat and the
y-axis goes 0 to 1 GB
- [ ] CPU chart limit toggle still renders at 100% (regression check on
the \`useAttributeQueries\` fallback change)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:12:25 +00:00

736 lines
23 KiB
TypeScript

import { useTheme } from 'next-themes'
import { ComponentProps, useEffect, useMemo, useState } from 'react'
import {
Area,
Bar,
CartesianGrid,
Customized,
Label,
Line,
ComposedChart as RechartComposedChart,
ReferenceArea,
ReferenceLine,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { CategoricalChartState } from 'recharts/types/chart/types'
import { cn } from 'ui'
import { ChartHeader } from './ChartHeader'
import { ChartHighlightAction, ChartHighlightActions } from './ChartHighlightActions'
import {
CHART_COLORS,
DateTimeFormats,
STACKED_CHART_COLORS,
STACKED_CHART_FILLS,
updateStackedChartColors,
} from './Charts.constants'
import { CommonChartProps, Datum } from './Charts.types'
import {
computeYAxisDomain,
formatPercentage,
normalizeStackedSeriesData,
numberFormatter,
useChartSize,
} from './Charts.utils'
import {
calculateTotalChartAggregate,
CustomLabel,
CustomTooltip,
MultiAttribute,
} from './ComposedChart.utils'
import NoDataPlaceholder from './NoDataPlaceholder'
import { ChartHighlight } from './useChartHighlight'
import { useChartHoverState } from './useChartHoverState'
import { formatDateTime, useFormatDateTime } from '@/lib/datetime'
import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
export interface ComposedChartProps<D = Datum> extends CommonChartProps<D> {
chartId?: string
attributes: MultiAttribute[]
yAxisKey: string
xAxisKey: string
displayDateInUtc?: boolean
onBarClick?: (datum: Datum, tooltipData?: CategoricalChartState) => void
emptyStateMessage?: string
showLegend?: boolean
xAxisIsDate?: boolean
XAxisProps?: ComponentProps<typeof XAxis>
YAxisProps?: ComponentProps<typeof YAxis>
showGrid?: boolean
showTooltip?: boolean
showTotal?: boolean
showMaxValue?: boolean
chartHighlight?: ChartHighlight
hideChartType?: boolean
chartStyle?: string
onChartStyleChange?: (style: string) => void
updateDateRange: any
titleTooltip?: string
hideYAxis?: boolean
hideHighlightedValue?: boolean
hideHighlightedLabel?: boolean
hideHighlightArea?: boolean
syncId?: string
docsUrl?: string
sql?: string
highlightActions?: ChartHighlightAction[]
showNewBadge?: boolean
normalizeVisibleStackToPercent?: boolean
}
interface CustomizedDotProps {
formattedGraphicalItems?: Array<{
props?: {
points?: Array<{ x: number; y: number }>
dataKey?: string
}
item?: {
props?: {
points?: Array<{ x: number; y: number }>
dataKey?: string
}
}
points?: Array<{ x: number; y: number }>
}>
}
export function ComposedChart({
chartId,
data,
attributes,
yAxisKey,
xAxisKey,
format,
customDateFormat = DateTimeFormats.FULL,
title,
highlightedValue,
highlightedLabel,
displayDateInUtc,
minimalHeader,
valuePrecision,
className = '',
size = 'normal',
emptyStateMessage,
onBarClick,
showLegend = false,
xAxisIsDate = true,
XAxisProps,
YAxisProps,
showGrid = false,
showTooltip = false,
showTotal = true,
showMaxValue = false,
chartHighlight,
hideChartType,
chartStyle,
onChartStyleChange,
updateDateRange,
hideYAxis,
hideHighlightedValue,
hideHighlightedLabel = false,
hideHighlightArea = false,
syncId,
docsUrl,
sql,
highlightActions,
titleTooltip,
showNewBadge,
normalizeVisibleStackToPercent = false,
}: ComposedChartProps) {
const { resolvedTheme } = useTheme()
const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState(
syncId || 'default'
)
const [_showMaxValue, setShowMaxValue] = useState(showMaxValue)
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false)
const [hiddenAttributes, setHiddenAttributes] = useState<Set<string>>(new Set())
const isDarkMode = resolvedTheme?.includes('dark')
useEffect(() => {
updateStackedChartColors(isDarkMode ?? false)
}, [isDarkMode])
const { Container } = useChartSize(size)
// When `displayDateInUtc` is set the chart explicitly wants UTC labels.
// Otherwise honour the user's selected timezone via the picker.
const formatPickerDate = useFormatDateTime()
const formatChartDate = (value: number | string) =>
displayDateInUtc
? formatDateTime(value, { tz: 'UTC', format: customDateFormat })
: formatPickerDate(value, customDateFormat)
const formatTimestamp = (ts: unknown) => {
if (typeof ts !== 'number' && typeof ts !== 'string') {
return ''
}
if (typeof ts === 'number' && ts > 1e14) {
// Microsecond timestamp; convert to milliseconds before formatting.
return formatChartDate(ts / 1000)
}
return formatChartDate(ts)
}
const _XAxisProps = XAxisProps || {
interval: data.length - 2,
angle: 0,
tick: false,
}
const _YAxisProps = YAxisProps || {
tickFormatter: (value) => numberFormatter(value, valuePrecision),
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) {
if (!focusDataIndex) return highlightedLabel
return data[focusDataIndex]?.[xAxisKey]
}
return (
(focusDataIndex !== null &&
data &&
data[focusDataIndex] !== undefined &&
(() => {
const ts = data[focusDataIndex][xAxisKey]
return formatTimestamp(ts)
})()) ||
highlightedLabel
)
}
function formatHighlightedValue(value: any) {
if (typeof value !== 'number') {
return value
}
if (shouldFormatBytes) {
const bytesValue = isNetworkChart ? Math.abs(value) : value
const formatted = isMemoryChart
? formatBytesMinMB(bytesValue, valuePrecision)
: formatBytes(bytesValue, valuePrecision)
return format === 'bytes-per-second' ? `${formatted}/s` : formatted
}
if (format === '%') {
return formatPercentage(value, valuePrecision)
}
if (valuePrecision === 0 && value > 0 && value < 1) {
return '<1'
}
const formatted = numberFormatter(value, valuePrecision)
if (typeof format === 'string' && format) {
return `${formatted}${format}`
}
return formatted
}
function computeHighlightedValue() {
const referenceLines = attributes.filter(
(attribute) => attribute?.provider === 'reference-line'
)
const attributesToIgnore =
attributes?.filter((a) => a.omitFromTotal)?.map((a) => a.attribute) ?? []
const attributesToIgnoreFromTotal = [
...attributesToIgnore,
...(referenceLines?.map((a: MultiAttribute) => a.attribute) ?? []),
...(maxAttribute?.attribute ? [maxAttribute?.attribute] : []),
...Array.from(hiddenAttributes),
]
const lastDataPoint = data[data.length - 1]
? Object.entries(data[data.length - 1])
.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
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(focusedDataPoint ?? [], attributesToIgnoreFromTotal)
: data[focusDataIndex]?.[yAxisKey]
}
if (showTotal && lastDataPoint) {
return calculateTotalChartAggregate(lastDataPoint, attributesToIgnoreFromTotal)
}
return highlightedValue
}
const maxAttribute = attributes.find((a) => a.isMaxValue)
const maxAttributeData = {
name: maxAttribute?.attribute,
color: CHART_COLORS.REFERENCE_LINE,
}
const referenceLines = attributes.filter((attribute) => {
return attribute?.provider === 'reference-line'
})
const resolvedHighlightedLabel = getHeaderLabel()
const resolvedHighlightedValue = computeHighlightedValue()
const showHighlightActions =
chartHighlight?.coordinates.left &&
chartHighlight?.coordinates.right &&
chartHighlight?.coordinates.left !== chartHighlight?.coordinates.right
const chartData =
data && !!data[0]
? Object.entries(data[0])
?.map(([key, value]) => ({
name: key,
value: value,
}))
.filter(
(att) =>
att.name !== 'timestamp' &&
att.name !== 'period_start' &&
att.name !== maxAttribute?.attribute &&
!referenceLines.map((a) => a.attribute).includes(att.name) &&
attributes.some((attr) => attr.attribute === att.name && attr.enabled !== false)
)
.map((att, index) => {
const attribute = attributes.find((attr) => attr.attribute === att.name)
return {
...att,
color: attribute?.color
? isDarkMode
? attribute.color.dark
: attribute.color.light
: STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length],
fill: attribute?.fill
? isDarkMode
? attribute.fill.dark
: attribute.fill.light
: STACKED_CHART_FILLS[index % STACKED_CHART_FILLS.length],
}
})
: []
const stackedAttributes = chartData.filter((att) => {
const attribute = attributes.find((attr) => attr.attribute === att.name)
return !attribute?.isMaxValue
})
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 =
!chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') &&
chartData?.some((att: any) => att.name.toLowerCase().includes('ram_'))
const isSwapChart = chartData?.some((att: any) => att.name.toLowerCase().includes('swap_'))
const isMemoryChart = isRamChart || isSwapChart
const isDiskSpaceChart = chartData?.some((att: any) =>
att.name.toLowerCase().includes('disk_space_')
)
const isDBSizeChart = chartData?.some((att: any) =>
att.name.toLowerCase().includes('pg_database_size')
)
const isNetworkChart = chartData?.some((att: any) => att.name.toLowerCase().includes('network_'))
const isBytesFormat = format === 'bytes' || format === 'bytes-per-second'
const shouldFormatBytes =
isBytesFormat || isMemoryChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart
const yMaxFromVisible = Math.max(
0,
...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0))
)
const yAxisDomain = useMemo(
() =>
computeYAxisDomain({
isPercentage,
showMaxValue,
yMaxFromVisible,
maxAttributeKey: maxAttribute?.attribute,
showMaxLine: _showMaxValue,
data,
visibleAttributeNames: visibleAttributes.map((a) => a.name),
}),
[
isPercentage,
showMaxValue,
yMaxFromVisible,
maxAttribute,
_showMaxValue,
data,
visibleAttributes,
]
)
if (data.length === 0) {
return (
<NoDataPlaceholder
hideTotalPlaceholder={highlightedValue === undefined}
message={emptyStateMessage}
description="It may take up to 24 hours for data to refresh"
size={size}
className={className}
attribute={title}
format={format}
titleTooltip={titleTooltip}
/>
)
}
return (
<div className={cn('flex flex-col gap-y-3', className)}>
<ChartHeader
hideHighlightedValue={hideHighlightedValue}
title={title}
showNewBadge={showNewBadge}
format={format}
hideHighlightedLabel={hideHighlightedLabel}
hideHighlightArea={hideHighlightArea}
titleTooltip={titleTooltip}
customDateFormat={customDateFormat}
highlightedValue={formatHighlightedValue(resolvedHighlightedValue)}
highlightedLabel={resolvedHighlightedLabel}
minimalHeader={minimalHeader}
hideChartType={hideChartType}
chartStyle={chartStyle}
onChartStyleChange={onChartStyleChange}
showMaxValue={_showMaxValue}
setShowMaxValue={maxAttribute ? setShowMaxValue : undefined}
docsUrl={docsUrl}
syncId={syncId}
data={data}
xAxisKey={xAxisKey}
yAxisKey={yAxisKey}
xAxisIsDate={xAxisIsDate}
displayDateInUtc={displayDateInUtc}
valuePrecision={valuePrecision}
shouldFormatBytes={shouldFormatBytes}
isNetworkChart={isNetworkChart}
isMemoryChart={isMemoryChart}
attributes={attributes}
sql={sql}
/>
<Container className="relative z-10">
<RechartComposedChart
data={displayData}
syncId={syncId}
style={{ cursor: 'crosshair' }}
onMouseMove={({ activeLabel, activeTooltipIndex }) => {
if (activeTooltipIndex === undefined || activeTooltipIndex === null) return
setIsActiveHoveredChart(true)
if (activeTooltipIndex !== focusDataIndex) {
setFocusDataIndex(activeTooltipIndex)
}
setHover(activeTooltipIndex)
const activeTimestamp =
data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp
chartHighlight?.handleMouseMove({
activeLabel: activeTimestamp?.toString(),
coordinates: activeLabel,
})
}}
onMouseDown={({ activeLabel, activeTooltipIndex }) => {
if (activeTooltipIndex === undefined || activeTooltipIndex === null) return
const activeTimestamp =
data[activeTooltipIndex]?.[xAxisKey] ?? data[activeTooltipIndex]?.timestamp
chartHighlight?.handleMouseDown({
activeLabel: activeTimestamp?.toString(),
coordinates: activeLabel,
})
}}
onMouseUp={chartHighlight?.handleMouseUp}
onMouseLeave={() => {
setIsActiveHoveredChart(false)
setFocusDataIndex(null)
clearHover()
}}
onClick={(tooltipData) => {
const datum = tooltipData?.activePayload?.[0]?.payload
if (onBarClick) onBarClick(datum, tooltipData)
}}
>
{showGrid && <CartesianGrid stroke={CHART_COLORS.AXIS} />}
<YAxis
{..._YAxisProps}
hide={hideYAxis}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
domain={_YAxisProps.domain ?? yAxisDomain}
padding={yAxisPadding}
key={yAxisKey}
/>
<XAxis
{..._XAxisProps}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
tickMargin={8}
minTickGap={3}
key={xAxisKey}
/>
<defs>
{visibleAttributes.map((attribute) => (
<linearGradient
key={`gradient-${attribute.name}`}
id={`gradient-${attribute.name}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={attribute.color} stopOpacity={0.15} />
<stop offset="95%" stopColor={isDarkMode ? '#131313' : '#FFFFFF'} stopOpacity={0} />
</linearGradient>
))}
</defs>
{chartStyle === 'bar'
? visibleAttributes.map((attribute) => (
<Bar
key={attribute.name}
dataKey={attribute.name}
stackId={attributes?.find((a) => a.attribute === attribute?.name)?.stackId ?? '1'}
fill={attribute.color}
radius={0.75}
opacity={1}
name={
attributes?.find((a) => a.attribute === attribute?.name)?.label ||
attribute?.name
}
maxBarSize={24}
/>
))
: visibleAttributes.map((attribute) => (
<Area
key={attribute.name}
type="linear"
dataKey={attribute.name}
stackId="1"
fill={`url(#gradient-${attribute.name})`}
fillOpacity={1}
stroke={attribute.color}
radius={20}
animationDuration={375}
name={
attributes?.find((a) => a.attribute === attribute.name)?.label || attribute.name
}
dot={false}
activeDot={false}
/>
))}
{/* Max value, if available */}
{maxAttribute && _showMaxValue && (
<Line
key={maxAttribute.attribute}
type="linear"
dataKey={maxAttribute.attribute}
stroke={CHART_COLORS.REFERENCE_LINE}
strokeWidth={2}
strokeDasharray={maxAttribute.strokeDasharray ?? '3 3'}
dot={false}
name={maxAttribute.label}
/>
)}
{referenceLines
.filter((line) => {
return line.isReferenceLine
})
.map((line) => (
<ReferenceLine
key={line.attribute}
y={line.value}
strokeWidth={1}
stroke={isDarkMode ? line.color?.dark : line.color?.light}
strokeDasharray={line.strokeDasharray ?? '3 3'}
label={undefined}
>
<Label
value={line.label}
position="insideTopRight"
fill={CHART_COLORS.REFERENCE_LINE_TEXT}
className="text-xs"
style={{ fill: CHART_COLORS.REFERENCE_LINE_TEXT }}
/>
</ReferenceLine>
))}
{/* Selection highlight */}
{showHighlightActions && (
<ReferenceArea
x1={chartHighlight?.coordinates.left}
x2={chartHighlight?.coordinates.right}
strokeOpacity={0.5}
stroke={isDarkMode ? '#FFFFFF' : '#0C3925'}
fill={isDarkMode ? '#FFFFFF' : '#0C3925'}
fillOpacity={0.2}
/>
)}
<Tooltip
content={(props) =>
showTooltip && !showHighlightActions ? (
<CustomTooltip
{...props}
data={data}
format={format}
isPercentage={isPercentage}
label={resolvedHighlightedLabel}
attributes={attributes}
xAxisKey={xAxisKey}
valuePrecision={valuePrecision}
showTotal={showTotal}
isActiveHoveredChart={
isActiveHoveredChart || (!!syncId && syncTooltip && hoveredIndex !== null)
}
/>
) : null
}
cursor={{
stroke: isDarkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)',
strokeWidth: 1,
}}
/>
<Customized
component={(props: CustomizedDotProps) => {
const { formattedGraphicalItems } = props
if (!formattedGraphicalItems || focusDataIndex === null) return null
return (
<g>
{formattedGraphicalItems.map((item, index: number) => {
const points = item.props?.points || item.item?.props?.points || item.points
const dataKey = item.props?.dataKey || item.item?.props?.dataKey
if (!points || !points[focusDataIndex]) return null
const point = points[focusDataIndex]
const attribute = visibleAttributes.find((a) => a.name === dataKey)
if (!attribute) return null
return (
<circle
key={`custom-dot-${dataKey}-${index}`}
cx={point.x}
cy={point.y}
r={4}
fill={attribute.fill}
stroke={attribute.color}
strokeWidth={1}
/>
)
})}
</g>
)
}}
/>
</RechartComposedChart>
</Container>
<ChartHighlightActions
chartHighlight={chartHighlight}
updateDateRange={updateDateRange}
actions={highlightActions}
chartId={chartId}
/>
{data && (
<div
className="text-foreground-lighter -mt-9 flex items-center justify-between text-xs"
style={{ marginLeft: YAxisProps?.width }}
>
<span>{xAxisIsDate ? formatTimestamp(data[0]?.[xAxisKey]) : data[0]?.[xAxisKey]}</span>
<span>
{xAxisIsDate
? formatTimestamp(data[data.length - 1]?.[xAxisKey])
: data[data.length - 1]?.[xAxisKey]}
</span>
</div>
)}
{showLegend && (
<div className="relative z-0">
<CustomLabel
payload={[maxAttributeData, ...chartData]}
attributes={attributes}
showMaxValue={_showMaxValue}
onToggleAttribute={(attribute, options) => {
setHiddenAttributes((prev) => {
if (options?.exclusive) {
// Hide every attribute except the selected one. If all but one are hidden, clicking again will reset to all visible.
const allNames = chartData.map((c) => c.name)
const allHiddenExcept = allNames.filter((n) => n !== attribute)
const isAlreadyExclusive =
allHiddenExcept.every((n) => prev.has(n)) && !prev.has(attribute)
return isAlreadyExclusive ? new Set() : new Set(allHiddenExcept)
}
const next = new Set(prev)
if (next.has(attribute)) {
next.delete(attribute)
} else {
next.add(attribute)
}
return next
})
}}
hiddenAttributes={hiddenAttributes}
/>
</div>
)}
</div>
)
}