mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
## 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 -->
335 lines
9.4 KiB
TypeScript
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 }
|
|
}
|