Files
7fce0a12d9 feat(design-system): first pass at db report chart colours (#46787)
## 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?

This is a first draft at introducing semantic colours to our
Observability charts. This moves away from just random colours being
assigned to prop after prop. They're only scoped to the Database reports
right now, but if it flows nice, we can open it up to the other reports
too.

This also aims to tone down some of the harsher colours in our charts,
such as the orange which sometimes can look like a warning metric/prop.

| Before | After |
|--------|--------|
| <img width="839" height="336" alt="Screenshot 2026-06-10 at 09 14 56"
src="https://github.com/user-attachments/assets/222747c5-973b-4165-aa53-df7b93412ad3"
/> | <img width="950" height="341" alt="Screenshot 2026-09-14 at 18 14
47"
src="https://github.com/user-attachments/assets/836f3ddd-4a97-4064-b8cf-3a3b435417ac"
/> |

cc @supabase/design for additional thoughts.


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

* **New Features**
* Added semantic chart color roles with light and dark theme variants
for consistent visualizations.
* Standardized colors and fills across database, networking, storage,
and connection charts.
  * Maximum-value lines now use configured chart colors when available.
  * Added chart palette reference and stress-test examples.
* Added stacked bar charts, customizable margins, and gradient-filled
line charts.
* Improved multi-series bar chart focus and date-range footer alignment.

* **Documentation**
* Documented the chart palette, theme variants, accessibility guidance,
and usage recommendations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
2026-09-16 09:18:40 +01:00

432 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 ThemedColor = {
light?: string
dark?: string
}
export const resolveChartColor = (
color: string | ThemedColor | undefined,
isDarkMode: boolean | undefined
) => {
if (typeof color === 'string') return color
return isDarkMode ? color?.dark : color?.light
}
export type MultiAttribute = {
attribute: string
provider?: Provider
label?: string
color?: string | { light: string; dark: string }
fill?: string | ThemedColor
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>
)
}