Files
supabase/apps/studio/components/ui/Charts/Charts.utils.tsx
kemal.earth bc95a2f19a fix(studio): edge func exec time formatting in reports (#48539)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Fixes Edge Function Execution Time chart within our observability
reports time formatting. This also fixes the non-hovered state which
would lose the `ms` formatting.

| Before | After |
|--------|--------|
| <img width="2160" height="652" alt="cleanshot_2026-07-29_at_02 15
53_2x"
src="https://github.com/user-attachments/assets/cfd6dbc2-f283-4379-a133-581c76990cb5"
/> | <img width="797" height="314" alt="Screenshot 2026-07-31 at 14 30
48"
src="https://github.com/user-attachments/assets/f5f1ace5-b6ef-43db-aebd-e10d31631013"
/> |



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

## Summary by CodeRabbit

* **New Features**
* Improved execution-time chart formatting with clearer millisecond
values, thousands separators, and configurable precision.
* Chart highlights now support custom value formatting alongside
existing number, percentage, and byte formats.

* **Bug Fixes**
  * Non-finite execution-time values now display safely as `0ms`.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 16:20:37 +01:00

335 lines
9.4 KiB
TypeScript

import dayjs from 'dayjs'
import { useMemo, type FC, type ReactElement } from 'react'
import { ResponsiveContainer } from 'recharts'
import { DateTimeFormats } from './Charts.constants'
import type { CommonChartProps, StackedChartProps } from './Charts.types'
/**
* Auto formats a number to a default precision if it is a float
*
* @example
* numberFormatter(123) // "123"
* numberFormatter(123.123) // "123.12"
* numberFormatter(123, 2) // "123.00"
*/
export const numberFormatter = (num: number, precision = 2) => {
return isFloat(num) ? precisionFormatter(num, precision) : num.toLocaleString()
}
/**
* Tests if a number is a float.
*
* @example
* isFloat(123) // false
* isFloat(123.123) // true
*/
export const isFloat = (num: number) => String(num).includes('.')
/**
* Formats a number to a particular precision.
*
* @example
* precisionFormatter(123, 2) // "123.00"
* precisionFormatter(123.123, 2) // "123.12"
* precisionFormatter(0.00123, 2) // "<0.01"
* precisionFormatter(-0.00123, 2) // ">-0.01"
*/
export const precisionFormatter = (num: number, precision: number): string => {
if (precision === 0) {
return String(Math.round(num))
}
// Handle small numbers that would display as 0.00
const threshold = 1 / Math.pow(10, precision)
if (num > 0 && num < threshold) {
return `<${threshold.toFixed(precision)}`
}
if (num < 0 && num > -threshold) {
return `>-${threshold.toFixed(precision)}`
}
if (isFloat(num)) {
const [head, tail] = String(num).split('.')
return Number(head).toLocaleString() + '.' + tail.slice(0, precision)
} else {
// pad int with 0
return num.toLocaleString() + '.' + '0'.repeat(precision)
}
}
/**
* Formats a number compactly for Y-axis ticks by abbreviating large values.
* Prevents long numbers like 1,000,000 from overflowing the Y-axis width.
*
* @example
* compactNumberFormatter(999) // "999"
* compactNumberFormatter(1000) // "1K"
* compactNumberFormatter(1500) // "1.5K"
* compactNumberFormatter(1000000) // "1M"
* compactNumberFormatter(2500000) // "2.5M"
*/
export const compactNumberFormatter = (num: number): string => {
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(num)
}
/**
* Formats a duration in milliseconds with thousands separators and a unit suffix.
*
* @example
* millisecondFormatter(123) // "123ms"
* millisecondFormatter(90000) // "90,000ms"
* millisecondFormatter(1234.56) // "1,235ms"
* millisecondFormatter(1234.56, 2) // "1,234.56ms"
*/
export const millisecondFormatter = (value: number, precision = 0) => {
if (!Number.isFinite(value)) return '0ms'
return `${value.toLocaleString('en-US', {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
})}ms`
}
/**
* Formats a percentage, trimming decimals at 100.
*
* @example
* formatPercentage(100, 2) // "100%"
* formatPercentage(99.99, 2) // "99.99%"
*/
export const formatPercentage = (value: number, precision = 2) => {
const isHundred = Math.abs(value - 100) < 1e-6
if (isHundred) return '100%'
if (Number.isInteger(value)) return `${value}%`
const formatted = precisionFormatter(value, precision)
if (formatted.startsWith('<') || formatted.startsWith('>')) {
return `${formatted}%`
}
if (formatted.includes('.')) {
const [head, tail = ''] = formatted.split('.')
return `${head}.${tail.padEnd(precision, '0')}%`
}
return `${formatted}%`
}
/**
* Formats a timestamp.
* Optionally formats the string to UTC
* @param value
* @param format
* @param utc
* @returns
*/
export const timestampFormatter = (
value: string,
format: string = DateTimeFormats.FULL,
utc: boolean = false
) => {
if (utc) {
return dayjs.utc(value).format(format)
}
return dayjs(value).format(format)
}
/**
* Computes the Y-axis domain for a ComposedChart that may contain stacked Bar components
* and an optional max-value reference Line.
*
* Recharts' `['auto', 'auto']` domain does not correctly include a Line component's values
* when stacked Bars are present — the domain is derived only from the bar data, so the
* reference line (e.g. Max IOPS) and any bars that exceed it get visually clipped.
* This function returns an explicit `[0, max]` domain when a visible reference line exists.
*
* @example
* // Max IOPS reference line at 25 000, bars reach up to 25 403
* computeYAxisDomain({ maxAttributeKey: 'disk_iops_max', showMaxLine: true, ... })
* // → [0, 25403]
*
* // Percentage chart zoomed in (no max line toggle)
* computeYAxisDomain({ isPercentage: true, showMaxValue: false, yMaxFromVisible: 75, ... })
* // → [0, 75]
*
* // No max reference line — let Recharts auto-scale
* computeYAxisDomain({ maxAttributeKey: undefined, ... })
* // → ['auto', 'auto']
*/
export function computeYAxisDomain({
isPercentage,
showMaxValue,
yMaxFromVisible,
maxAttributeKey,
showMaxLine,
data,
visibleAttributeNames,
}: {
isPercentage: boolean
showMaxValue: boolean
yMaxFromVisible: number
maxAttributeKey: string | undefined
showMaxLine: boolean
data: Record<string, unknown>[]
visibleAttributeNames: string[]
}): [number, number] | ['auto', 'auto'] {
if (isPercentage && !showMaxValue) return [0, yMaxFromVisible]
if (!maxAttributeKey || !showMaxLine) return ['auto', 'auto']
const maxRefValue = data.reduce((max, point) => {
const val = point[maxAttributeKey]
return typeof val === 'number' ? Math.max(max, val) : max
}, 0)
if (maxRefValue <= 0) return ['auto', 'auto']
const maxStackedTotal = data.reduce((max, point) => {
const total = visibleAttributeNames.reduce((sum, name) => {
const val = point[name]
return sum + (typeof val === 'number' ? val : 0)
}, 0)
return Math.max(max, total)
}, 0)
return [0, Math.max(maxRefValue, maxStackedTotal)]
}
export function normalizeStackedSeriesData<T extends Record<string, unknown>>({
data,
attributeNames,
totalTarget = 100,
}: {
data: T[]
attributeNames: string[]
totalTarget?: number
}): T[] {
return data.map((point) => {
const values = attributeNames.map((name) => ({
name,
value: typeof point[name] === 'number' ? point[name] : 0,
}))
const total = values.reduce((sum, entry) => sum + entry.value, 0)
if (total <= 0) return point
const largestEntry = values.reduce((largest, entry) =>
entry.value > largest.value ? entry : largest
)
let normalizedTotal = 0
const nextPoint: Record<string, unknown> = { ...point }
values.forEach(({ name, value }) => {
if (name === largestEntry.name) return
const normalizedValue = (value / total) * totalTarget
nextPoint[name] = normalizedValue
normalizedTotal += normalizedValue
})
nextPoint[largestEntry.name] = Math.max(0, totalTarget - normalizedTotal)
return nextPoint as T
})
}
/**
* Hook to create common wrapping components, perform data transformations
* returns a Container component and the minHeight set
*/
export const useChartSize = (
size: CommonChartProps<any>['size'] = 'normal',
sizeMap: {
tiny: number
small: number
normal: number
large: number
} = {
tiny: 76,
small: 96,
normal: 160,
large: 280,
}
) => {
const minHeight = sizeMap[size]
const Container: FC<{ children: ReactElement; className?: string }> = useMemo(
() =>
({ className, children }) => (
<ResponsiveContainer
className={className}
height={minHeight}
minHeight={minHeight}
width="100%"
>
{children}
</ResponsiveContainer>
),
[size]
)
return {
Container,
minHeight,
}
}
/**
* Transforms data points into a stacked data structure that can be consumed by recharts
*/
export const useStacked = ({
data,
xAxisKey,
yAxisKey,
stackKey,
variant = 'values',
}: Pick<StackedChartProps<any>, 'xAxisKey' | 'yAxisKey' | 'stackKey'> & {
variant?: 'values' | 'percentages'
} & Pick<CommonChartProps<Record<string, number>>, 'data'>) => {
const stackedData = useMemo(() => {
if (!data) return []
const mapping = data.reduce(
(acc, datum) => {
const x = datum[xAxisKey]
const y = datum[yAxisKey]
const s = datum[stackKey]
if (!acc[x]) {
acc[x] = {}
}
acc[x][s] = y
return acc
},
{} as Record<string, Record<string, number>>
)
const flattened = Object.entries(mapping).map(([x, sMap]) => ({
...sMap,
[xAxisKey]: Number.isNaN(Number(x)) ? x : Number(x),
}))
return flattened
}, [JSON.stringify(data)])
const dataKeys = useMemo(() => {
return Object.keys(stackedData[0] || {})
.filter((k) => k !== xAxisKey && k !== yAxisKey)
.sort()
}, [JSON.stringify(stackedData[0] || {})])
const percentagesStackedData = useMemo(() => {
if (variant !== 'percentages') return
return stackedData.map((stack) => {
const entries = Object.entries(stack) as Array<[string, number]>
let map
const sum = entries
.filter(([key, _value]) => dataKeys.includes(key))
.reduce((acc, [_key, value]) => acc + value, 0)
map = entries.reduce((acc, [key, value]) => {
if (!dataKeys.includes(key)) {
return { ...acc, [key]: value }
}
return { ...acc, [key]: value !== 0 ? value / sum : 0 }
}, {} as any)
return map
})
}, [JSON.stringify(stackedData)])
return { dataKeys, stackedData, percentagesStackedData }
}