mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
Line-style report charts (auth processing time, percentiles, edge functions, realtime, etc.) hardcoded `stackId="1"` on every `<Area>`, so recharts summed the series additively instead of overlaying them. When multiple series share a value (e.g. Max/Min/Avg all `153.98`), they rendered as three stacked bands at 1x/2x/3x the value, even though the tooltip showed the true identical values. ## Fix Default each area to its own `stackId` (its attribute name) so series overlay, while still honoring an explicit per-attribute `stackId` from config — matching the existing bar-chart path directly above it. `normalizeVisibleStackToPercent`, the only flag that would make stacked areas intentional, is never enabled anywhere. ## Before / after Before: three equal values drawn at `0→153.98`, `153.98→307.96`, `307.96→461.94`. After: all three overlay at `153.98`. Affects every line-style multi-series report, not just auth. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved stacked chart behavior by consistently applying configured series stacking settings. - Ensured area charts can overlay correctly when no explicit stacking configuration is provided. - Added safeguards for missing or invalid chart attribute data. - **Tests** - Added coverage for configured stack IDs, fallback behavior, empty values, and invalid inputs. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
425 lines
14 KiB
TypeScript
425 lines
14 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)
|
|
|
|
/**
|
|
* Resolve the recharts `stackId` for a series.
|
|
*
|
|
* Series that share a `stackId` are stacked additively, so overlaid series
|
|
* (e.g. min/max/avg of the same metric) must each get a distinct id. Bar
|
|
* charts pass `'1'` as the fallback to stack together; area charts pass the
|
|
* attribute name so each series overlays independently. An explicit per-
|
|
* attribute `stackId` always wins.
|
|
*/
|
|
export const getStackId = (
|
|
attributes: (MultiAttribute | false | null | undefined)[] | null | undefined,
|
|
name: string | null | undefined,
|
|
fallback: string
|
|
): string => {
|
|
const configured = Array.isArray(attributes)
|
|
? attributes.find((a): a is MultiAttribute => !!a && a.attribute === name)?.stackId
|
|
: undefined
|
|
return configured ?? fallback
|
|
}
|
|
|
|
/**
|
|
* 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}
|
|
tabIndex={0}
|
|
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>
|
|
)
|
|
}
|