mirror of
https://github.com/supabase/supabase.git
synced 2026-06-15 18:17:09 +08:00
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>
334 lines
9.9 KiB
TypeScript
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>
|
|
)
|
|
}
|