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

334 lines
9.9 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, formatBytesMinMB } 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
isMemoryChart?: 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,
isMemoryChart,
}: 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 isMemoryChart
? formatBytesMinMB(bytesValue, valuePrecision)
: 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>
)
}