mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +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>
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import dayjs from 'dayjs'
|
|
import { Activity, BarChartIcon, Loader2 } from 'lucide-react'
|
|
import { useRouter } from 'next/router'
|
|
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
|
import { ChartContainer, ChartTooltip, WarningIcon } from 'ui'
|
|
|
|
import { METRIC_THRESHOLDS } from './ReportBlock.constants'
|
|
import { ReportBlockContainer } from './ReportBlockContainer'
|
|
import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { timestampFormatter } from '@/components/ui/Charts/Charts.utils'
|
|
import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder'
|
|
import { PortalChartTooltip } from '@/components/ui/Charts/PortalChartTooltip'
|
|
import {
|
|
checkHasNonPositiveValues,
|
|
computeYAxisWidth,
|
|
formatLogTick,
|
|
formatYAxisTick,
|
|
} from '@/components/ui/QueryBlock/QueryBlock.utils'
|
|
import { AnalyticsInterval } from '@/data/analytics/constants'
|
|
import { mapResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries'
|
|
import {
|
|
InfraMonitoringAttribute,
|
|
useInfraMonitoringAttributesQuery,
|
|
} from '@/data/analytics/infra-monitoring-query'
|
|
import {
|
|
ProjectDailyStatsAttribute,
|
|
useProjectDailyStatsQuery,
|
|
} from '@/data/analytics/project-daily-stats-query'
|
|
import { METRICS } from '@/lib/constants/metrics'
|
|
import { useFormatDateTime } from '@/lib/datetime'
|
|
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
|
import type { Dashboards } from '@/types'
|
|
|
|
interface ChartBlockProps {
|
|
label: string
|
|
attribute: string
|
|
provider: 'infra-monitoring' | 'daily-stats'
|
|
startDate: string
|
|
endDate: string
|
|
interval?: AnalyticsInterval
|
|
defaultChartStyle?: 'bar' | 'line'
|
|
defaultLogScale?: boolean
|
|
isLoading?: boolean
|
|
actions?: ReactNode
|
|
maxHeight?: number
|
|
onUpdateChartConfig?: ({
|
|
chart,
|
|
chartConfig,
|
|
}: {
|
|
chart?: Partial<Dashboards.Chart>
|
|
chartConfig?: Partial<ChartConfig>
|
|
}) => void
|
|
}
|
|
|
|
export const ChartBlock = ({
|
|
label,
|
|
attribute,
|
|
provider,
|
|
startDate,
|
|
endDate,
|
|
interval = '1d',
|
|
defaultChartStyle = 'bar',
|
|
defaultLogScale = false,
|
|
isLoading = false,
|
|
actions,
|
|
maxHeight,
|
|
onUpdateChartConfig,
|
|
}: ChartBlockProps) => {
|
|
const router = useRouter()
|
|
const { ref } = router.query
|
|
|
|
const state = useDatabaseSelectorStateSnapshot()
|
|
const chartRef = useRef<HTMLDivElement>(null)
|
|
const [chartStyle, setChartStyle] = useState<string>(defaultChartStyle)
|
|
const logScale = useMemo(() => defaultLogScale, [defaultLogScale])
|
|
const [latestValue, setLatestValue] = useState<string | undefined>()
|
|
const formatChartDate = useFormatDateTime()
|
|
const formatTooltipDate = useCallback(
|
|
(value: string | number, format: string) =>
|
|
/^\d{4}-\d{2}-\d{2}$/.test(String(value))
|
|
? timestampFormatter(String(value), format, true)
|
|
: formatChartDate(value, format),
|
|
[formatChartDate]
|
|
)
|
|
|
|
const databaseIdentifier = state.selectedDatabaseId
|
|
|
|
const {
|
|
data: dailyStatsData,
|
|
isFetching: isFetchingDailyStats,
|
|
isPending: isLoadingDailyStats,
|
|
} = useProjectDailyStatsQuery(
|
|
{
|
|
projectRef: ref as string,
|
|
attribute: attribute as ProjectDailyStatsAttribute,
|
|
startDate: dayjs(startDate).format('YYYY-MM-DD'),
|
|
endDate: dayjs(endDate).format('YYYY-MM-DD'),
|
|
},
|
|
{ enabled: provider === 'daily-stats' }
|
|
)
|
|
|
|
const {
|
|
data: infraMonitoringRawData,
|
|
isFetching: isFetchingInfraMonitoring,
|
|
isPending: isLoadingInfraMonitoring,
|
|
} = useInfraMonitoringAttributesQuery(
|
|
{
|
|
projectRef: ref as string,
|
|
attributes: [attribute as InfraMonitoringAttribute],
|
|
startDate,
|
|
endDate,
|
|
interval: interval as AnalyticsInterval,
|
|
databaseIdentifier,
|
|
},
|
|
{ enabled: provider === 'infra-monitoring' }
|
|
)
|
|
|
|
const infraMonitoringData = useMemo(() => {
|
|
if (!infraMonitoringRawData) return undefined
|
|
const mapped = mapResponseToAnalyticsData(infraMonitoringRawData, [
|
|
attribute as InfraMonitoringAttribute,
|
|
])
|
|
return mapped[attribute]
|
|
}, [infraMonitoringRawData, attribute])
|
|
|
|
const chartData =
|
|
provider === 'infra-monitoring'
|
|
? infraMonitoringData
|
|
: provider === 'daily-stats'
|
|
? dailyStatsData
|
|
: undefined
|
|
|
|
const isFetching =
|
|
provider === 'infra-monitoring'
|
|
? isFetchingInfraMonitoring
|
|
: provider === 'daily-stats'
|
|
? isFetchingDailyStats
|
|
: false
|
|
|
|
const loading =
|
|
isLoading ||
|
|
attribute.startsWith('new_snippet_') ||
|
|
(provider === 'infra-monitoring'
|
|
? isLoadingInfraMonitoring
|
|
: provider === 'daily-stats'
|
|
? isLoadingDailyStats
|
|
: isLoading)
|
|
|
|
const metric = METRICS.find((x) => x.key === attribute)
|
|
const metricLabel = metric?.label ?? attribute
|
|
|
|
const getCellColor = (attribute: string, value: number) => {
|
|
const threshold = METRIC_THRESHOLDS[attribute as keyof typeof METRIC_THRESHOLDS]
|
|
if (!threshold) return 'var(--chart-1)'
|
|
if (threshold.check === 'gt') {
|
|
return value >= threshold.danger
|
|
? 'var(--chart-status-destructive)'
|
|
: value >= threshold.warning
|
|
? 'var(--chart-status-warning)'
|
|
: 'var(--chart-1)'
|
|
} else {
|
|
return value <= threshold.danger
|
|
? 'var(--chart-status-destructive)'
|
|
: value <= threshold.warning
|
|
? 'var(--chart-status-warning)'
|
|
: 'var(--chart-1)'
|
|
}
|
|
}
|
|
|
|
const isPercentage = chartData?.format === '%'
|
|
const data = (chartData?.data ?? []).map((x: any) => {
|
|
const value = isPercentage ? x[attribute] : x[attribute]
|
|
const color = getCellColor(attribute, x[attribute])
|
|
return {
|
|
...x,
|
|
period_start: dayjs(x.period_start).utc().format('YYYY-MM-DD'),
|
|
[attribute]: value,
|
|
[metricLabel]: value,
|
|
fill: color,
|
|
stroke: color,
|
|
}
|
|
})
|
|
|
|
const hasNonPositiveValues = useMemo(() => {
|
|
if (!logScale || !data.length) return false
|
|
return checkHasNonPositiveValues(data, metricLabel)
|
|
}, [logScale, data, metricLabel])
|
|
|
|
const effectiveLogScale = logScale && !hasNonPositiveValues
|
|
|
|
const yAxisWidth = computeYAxisWidth(data, metricLabel, {
|
|
isLogScale: effectiveLogScale,
|
|
isPercentage,
|
|
})
|
|
|
|
const getInitialHighlightedValue = useCallback(() => {
|
|
if (!chartData?.data?.length) return undefined
|
|
const lastDataPoint = chartData.data[chartData.data.length - 1]
|
|
const value = lastDataPoint[attribute]
|
|
return isPercentage
|
|
? `${typeof value === 'number' ? value.toFixed(1) : value}%`
|
|
: typeof value === 'number'
|
|
? value.toLocaleString()
|
|
: value
|
|
}, [chartData?.data, chartData?.format, attribute])
|
|
|
|
useEffect(() => {
|
|
if (defaultChartStyle) setChartStyle(defaultChartStyle)
|
|
}, [defaultChartStyle])
|
|
|
|
useEffect(() => {
|
|
setLatestValue(getInitialHighlightedValue())
|
|
}, [chartData, getInitialHighlightedValue])
|
|
|
|
return (
|
|
<ReportBlockContainer
|
|
draggable
|
|
showDragHandle
|
|
loading={isFetching}
|
|
icon={metric?.category?.icon('text-foreground-muted')}
|
|
label={label}
|
|
actions={
|
|
<>
|
|
<ButtonTooltip
|
|
variant="text"
|
|
size="tiny"
|
|
disabled={loading}
|
|
className="w-7 h-7"
|
|
icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
|
|
onClick={() => {
|
|
const style = chartStyle === 'bar' ? 'line' : 'bar'
|
|
if (onUpdateChartConfig) onUpdateChartConfig({ chart: { chart_type: style } })
|
|
setChartStyle(style)
|
|
}}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
className: 'max-w-56 text-center',
|
|
text: `View as ${chartStyle === 'bar' ? 'line chart' : 'bar chart'}`,
|
|
},
|
|
}}
|
|
/>
|
|
<ButtonTooltip
|
|
variant={logScale ? 'default' : 'text'}
|
|
size="tiny"
|
|
disabled={loading}
|
|
className="h-7 px-1.5 font-mono text-[10px]"
|
|
icon={<span className="font-mono text-[10px] leading-none">Log</span>}
|
|
onClick={() => {
|
|
const next = !logScale
|
|
if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: { logScale: next } })
|
|
}}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
className: 'max-w-56 text-center',
|
|
text: `Switch to ${logScale ? 'linear' : 'logarithmic'} scale`,
|
|
},
|
|
}}
|
|
/>
|
|
{actions}
|
|
</>
|
|
}
|
|
>
|
|
{loading ? (
|
|
<div className="flex grow w-full flex-col items-center justify-center gap-y-2 px-4">
|
|
<Loader2 size={18} className="animate-spin text-border-strong" />
|
|
<p className="text-xs text-foreground-lighter text-center">Loading data for {label}</p>
|
|
</div>
|
|
) : chartData === undefined ? (
|
|
<div className="flex grow w-full flex-col items-center justify-center gap-y-2 px-4">
|
|
<WarningIcon />
|
|
<p className="text-xs text-foreground-lighter text-center">
|
|
Unable to load data for {label}
|
|
</p>
|
|
</div>
|
|
) : data.length === 0 ? (
|
|
<div className="flex grow w-full flex-col items-center justify-center gap-y-2">
|
|
<NoDataPlaceholder
|
|
size="small"
|
|
className="border-0"
|
|
description="It may take up to 24 hours for data to refresh"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{latestValue && (
|
|
<div className="pt-2 px-3 w-full text-left leading-tight">
|
|
<span className="text-xs font-mono uppercase text-foreground-light">
|
|
Most recently
|
|
</span>
|
|
<p className="text-lg text">{latestValue}</p>
|
|
</div>
|
|
)}
|
|
{hasNonPositiveValues && (
|
|
<p className="px-3 pt-1 text-xs text-foreground-light">
|
|
Log scale is unavailable because the data contains zero or negative values.
|
|
</p>
|
|
)}
|
|
<ChartContainer
|
|
ref={chartRef}
|
|
className="w-full aspect-auto px-3 py-2"
|
|
style={{
|
|
height: maxHeight ? `${maxHeight}px` : undefined,
|
|
minHeight: maxHeight ? `${maxHeight}px` : undefined,
|
|
}}
|
|
>
|
|
{chartStyle === 'bar' ? (
|
|
<BarChart accessibilityLayer margin={{ left: 0, right: 0 }} data={data}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="period_start"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={32}
|
|
/>
|
|
<YAxis
|
|
scale={effectiveLogScale ? 'log' : 'auto'}
|
|
domain={effectiveLogScale ? [1, 'auto'] : isPercentage ? [0, 100] : undefined}
|
|
allowDataOverflow={effectiveLogScale}
|
|
width={yAxisWidth}
|
|
tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick}
|
|
/>
|
|
<ChartTooltip
|
|
content={
|
|
<PortalChartTooltip
|
|
chartRef={chartRef}
|
|
className="min-w-[200px]"
|
|
labelSuffix={isPercentage ? '%' : ''}
|
|
labelFormatter={(x) => formatTooltipDate(x as string | number, 'DD MMM YYYY')}
|
|
/>
|
|
}
|
|
/>
|
|
<Bar dataKey={metricLabel} radius={[2, 2, 1, 1]} />
|
|
</BarChart>
|
|
) : (
|
|
<LineChart accessibilityLayer margin={{ left: 0, right: 0 }} data={data}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="period_start"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={32}
|
|
/>
|
|
<YAxis
|
|
scale={effectiveLogScale ? 'log' : 'auto'}
|
|
domain={effectiveLogScale ? [1, 'auto'] : isPercentage ? [0, 100] : undefined}
|
|
allowDataOverflow={effectiveLogScale}
|
|
width={yAxisWidth}
|
|
tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick}
|
|
/>
|
|
<ChartTooltip
|
|
content={
|
|
<PortalChartTooltip
|
|
chartRef={chartRef}
|
|
labelSuffix={chartData?.format === '%' ? '%' : ''}
|
|
labelFormatter={(x) => formatTooltipDate(x as string | number, 'DD MMM YYYY')}
|
|
/>
|
|
}
|
|
/>
|
|
<Line dataKey={metricLabel} stroke="var(--chart-1)" radius={4} />
|
|
</LineChart>
|
|
)}
|
|
</ChartContainer>
|
|
</>
|
|
)}
|
|
</ReportBlockContainer>
|
|
)
|
|
}
|