mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55: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? 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>
432 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|