Files
supabase/apps/studio/components/ui/Charts/ComposedChart.utils.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

404 lines
13 KiB
TypeScript

'use client'
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'
import { useFormatDateTime, useTimezone } from '@/lib/datetime'
import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
export interface ReportAttributes {
id?: string
titleTooltip?: string
label: string
attributes?: (MultiAttribute | false)[]
defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine'
hide?: boolean
entitlement?: string
requiredPlan?: string
hideChartType?: boolean
format?: string
className?: string
showTooltip?: boolean
showLegend?: boolean
showTotal?: boolean
showMaxValue?: boolean
valuePrecision?: number
docsUrl?: string
syncId?: string
showGrid?: boolean
YAxisProps?: {
width?: number
tickFormatter?: (value: any) => string
domain?: [number | string, number | string]
allowDataOverflow?: boolean
}
normalizeVisibleStackToPercent?: boolean
hideHighlightedValue?: boolean
}
export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-line' | 'logs'
export type MultiAttribute = {
attribute: string
provider?: Provider
label?: string
color?: {
light: string
dark: string
}
fill?: {
light?: string
dark?: string
}
statusCode?: string
grantType?: string
providerType?: string
stackId?: string
format?: string
description?: string
docsLink?: string
isMaxValue?: boolean
type?: 'line' | 'area-bar'
omitFromTotal?: boolean
tooltip?: string
customValue?: number
[key: string]: any
/**
* Manipulate the value of the attribute before it is displayed on the chart.
* @param value - The value of the attribute.
* @returns The manipulated value.
*/
manipulateValue?: (value: number) => number
/**
* Create a virtual attribute by combining values from other attributes.
* Expression should use attribute names and basic math operators (+, -, *, /).
* Example: 'disk_fs_used - pg_database_size - disk_fs_used_wal'
*/
combine?: string
id?: string
value?: number
isReferenceLine?: boolean
strokeDasharray?: string
className?: string
hide?: boolean
enabled?: boolean
}
interface CustomIconProps {
color: string
}
const CustomIcon = ({ color }: CustomIconProps) => (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="5" cy="5" r="3" fill={color} />
</svg>
)
const MaxConnectionsIcon = ({ color }: { color?: string }) => (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<line
x1="2"
y1="6"
x2="12"
y2="6"
stroke={color ?? CHART_COLORS.REFERENCE_LINE}
strokeWidth="2"
strokeDasharray="2 2"
/>
</svg>
)
interface TooltipProps {
active?: boolean
payload?: any[]
label?: string | number
attributes?: MultiAttribute[]
data?: Record<string, unknown>[]
xAxisKey?: string
isPercentage?: boolean
format?: string | ((value: unknown) => string)
valuePrecision?: number
showMaxValue?: boolean
showTotal?: boolean
isActiveHoveredChart?: boolean
}
const isMaxAttribute = (attributes?: MultiAttribute[]) => attributes?.find((a) => a.isMaxValue)
/**
* Calculate the total aggregate of the chart values
* by summing the values of the attributes
* that are not in the `ignoreAttributes` array
*/
export const calculateTotalChartAggregate = (
payload: { dataKey: string; value: number }[],
ignoreAttributes?: string[]
) =>
payload
?.filter((p) => !ignoreAttributes?.includes(p.dataKey))
.reduce((acc, curr) => acc + curr.value, 0)
export const CustomTooltip = ({
active,
payload,
label: _label,
attributes,
data,
xAxisKey = 'period_start',
isPercentage,
format,
valuePrecision,
showTotal,
isActiveHoveredChart,
}: TooltipProps) => {
const formatDateTime = useFormatDateTime()
const { timezone } = useTimezone()
if (active && payload && payload.length) {
/**
* Depending on the data source, the timestamp key could be 'timestamp' or 'period_start'
*/
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 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_'))
const isSwapChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('swap_'))
const isMemoryChart = isRamChart || isSwapChart
const isDBSizeChart =
payload?.some((p: any) => p.dataKey.toLowerCase().includes('disk_fs_')) ||
payload?.some((p: any) => p.dataKey.toLowerCase().includes('pg_database_size'))
const isNetworkChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('network_'))
const isBytesFormat = format === 'bytes' || format === 'bytes-per-second'
const shouldFormatBytes = isBytesFormat || isMemoryChart || isDBSizeChart || isNetworkChart
const byteUnitSuffix = format === 'bytes-per-second' ? '/s' : ''
const attributesToIgnore =
attributes?.filter((a) => a.omitFromTotal)?.map((a) => a.attribute) ?? []
const referenceLines =
attributes
?.filter((attribute: MultiAttribute) => attribute?.provider === 'reference-line')
?.map((a: MultiAttribute) => a.attribute) ?? []
const attributesToIgnoreFromTotal = [
...attributesToIgnore,
...referenceLines,
...(maxValueAttribute?.attribute ? [maxValueAttribute.attribute] : []),
]
const localTimeZone = timezone
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} />
const formatNumeric = (value: number) => {
if (!shouldFormatBytes && valuePrecision === 0 && value > 0 && value < 1) return '<1'
if (shouldFormatBytes) {
const val = isNetworkChart ? Math.abs(value) : value
if (isMemoryChart) return formatBytesMinMB(val, valuePrecision)
return formatBytes(val, valuePrecision)
}
const formatted = numberFormatter(value, valuePrecision)
if (
!isBytesFormat &&
format !== '%' &&
format !== 'ms' &&
typeof format === 'string' &&
format
) {
return `${formatted}${format}`
}
return formatted
}
const LabelItem = ({ entry }: { entry: any }) => {
const attribute = attributes?.find((a: MultiAttribute) => a?.attribute === entry.name)
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 (
<div key={entry.name} className="flex items-center w-full">
{getIcon(entry.color, isMax)}
<span className="text-foreground-lighter ml-1 grow cursor-default select-none">
{attribute?.label || entry.name}
</span>
<span className="ml-3.5 flex items-end gap-1">
{formatNumeric(rawValue) + (!isPercentage && format !== 'ms' ? byteUnitSuffix : '')}
{isPercentage ? '%' : ''}
{format === 'ms' ? 'ms' : ''}
{/* Show percentage if max value is set */}
{percentage !== null && !isMax && !isPercentage && (
<span className="text-[11px] text-foreground-light mb-0.5">({percentage}%)</span>
)}
</span>
</div>
)
}
return (
<div
className={cn(
'grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-default px-2.5 py-1.5 text-xs shadow-xl transition-opacity opacity-100',
!isActiveHoveredChart && 'opacity-0'
)}
>
<p className="text-foreground-light text-xs">{localTimeZone}</p>
<p className="font-medium">{formatDateTime(timestamp, DateTimeFormats.FULL_SECONDS)}</p>
<div className="grid gap-0">
{[...payload].reverse().map((entry: any, index: number) => (
<LabelItem key={`${entry.name}-${index}`} entry={entry} />
))}
{active && showTotal && (
<div className="flex md:flex-col gap-1 md:gap-0 text-foreground mt-1">
<span className="grow text-foreground-lighter">Total</span>
<div className="flex items-end gap-1">
<span className="text-base">
{isPercentage
? formatPercentage(total as number, valuePrecision)
: formatNumeric(total as number) +
(!isPercentage && format !== 'ms' ? byteUnitSuffix : '')}
{format === 'ms' ? 'ms' : ''}
</span>
{maxValueAttribute &&
hasFiniteMaxValue &&
!isPercentage &&
!isNaN((total as number) / maxValue) &&
isFinite((total as number) / maxValue) && (
<span className="text-[11px] text-foreground-light mb-0.5">
({(((total as number) / maxValue) * 100).toFixed(1)}%)
</span>
)}
</div>
</div>
)}
</div>
</div>
)
}
return null
}
interface CustomLabelProps {
payload?: any[]
attributes?: MultiAttribute[]
showMaxValue?: boolean
onLabelHover?: (label: string | null) => void
onToggleAttribute?: (attribute: string, options?: { exclusive?: boolean }) => void
hiddenAttributes?: Set<string>
}
export const CustomLabel = ({
payload,
attributes,
showMaxValue,
onLabelHover,
onToggleAttribute,
hiddenAttributes,
}: CustomLabelProps) => {
const items = payload ?? []
const maxValueAttribute = isMaxAttribute(attributes)
const [, setHoveredLabel] = useState<string | null>(null)
const handleMouseEnter = (label: string) => {
setHoveredLabel(label)
onLabelHover?.(label)
}
const handleMouseLeave = () => {
setHoveredLabel(null)
onLabelHover?.(null)
}
const getIcon = (name: string, color: string) => {
switch (name === maxValueAttribute?.attribute) {
case true:
return <MaxConnectionsIcon />
default:
return <CustomIcon color={color} />
}
}
const LabelItem = ({ entry }: { entry: any }) => {
const attribute = attributes?.find((a) => a.attribute === entry.name)
const isMax = entry.name === maxValueAttribute?.attribute
const isHidden = hiddenAttributes?.has(entry.name)
const color = isHidden ? 'gray' : entry.color
const Label = () => (
<div className="flex items-center gap-1">
{getIcon(entry.name, color)}
<span className={cn('text-nowrap text-foreground-lighter', isHidden && 'opacity-50')}>
{attribute?.label || entry.name}
</span>
</div>
)
if (!showMaxValue && isMax) return null
return (
<button
key={entry.name}
className="flex md:flex-col gap-1 md:gap-0 w-fit text-foreground rounded-lg hover:bg-background-overlay-hover"
onMouseOver={() => handleMouseEnter(entry.name)}
onMouseOutCapture={handleMouseLeave}
onClick={(e) => onToggleAttribute?.(entry.name, { exclusive: e.metaKey || e.ctrlKey })}
>
{!!attribute?.tooltip ? (
<Tooltip>
<TooltipTrigger className="p-1.5">
<Label />
</TooltipTrigger>
<TooltipContent sideOffset={6} side="bottom" align="center" className="max-w-[250px]">
{attribute.tooltip}
</TooltipContent>
</Tooltip>
) : (
<Label />
)}
</button>
)
}
return (
<div className="relative z-10 mx-auto flex flex-col items-center gap-1 text-xs w-full">
<div className="flex flex-wrap items-center justify-center gap-2">
<TooltipProvider delayDuration={800}>
{items?.map((entry, index) => (
<LabelItem key={`${entry.name}-${index}`} entry={entry} />
))}
</TooltipProvider>
</div>
</div>
)
}