ChartBlock.tsx368 lines · main
1import dayjs from 'dayjs'
2import { Activity, BarChartIcon, Loader2 } from 'lucide-react'
3import { useRouter } from 'next/router'
4import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
5import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
6import { ChartContainer, ChartTooltip, ChartTooltipContent, WarningIcon } from 'ui'
7
8import { METRIC_THRESHOLDS } from './ReportBlock.constants'
9import { ReportBlockContainer } from './ReportBlockContainer'
10import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
11import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
12import { timestampFormatter } from '@/components/ui/Charts/Charts.utils'
13import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder'
14import {
15 checkHasNonPositiveValues,
16 computeYAxisWidth,
17 formatLogTick,
18 formatYAxisTick,
19} from '@/components/ui/QueryBlock/QueryBlock.utils'
20import { AnalyticsInterval } from '@/data/analytics/constants'
21import { mapMultiResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries'
22import {
23 InfraMonitoringAttribute,
24 useInfraMonitoringAttributesQuery,
25} from '@/data/analytics/infra-monitoring-query'
26import {
27 ProjectDailyStatsAttribute,
28 useProjectDailyStatsQuery,
29} from '@/data/analytics/project-daily-stats-query'
30import { METRICS } from '@/lib/constants/metrics'
31import { useFormatDateTime } from '@/lib/datetime'
32import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
33import type { Dashboards } from '@/types'
34
35interface ChartBlockProps {
36 label: string
37 attribute: string
38 provider: 'infra-monitoring' | 'daily-stats'
39 startDate: string
40 endDate: string
41 interval?: AnalyticsInterval
42 defaultChartStyle?: 'bar' | 'line'
43 defaultLogScale?: boolean
44 isLoading?: boolean
45 actions?: ReactNode
46 maxHeight?: number
47 onUpdateChartConfig?: ({
48 chart,
49 chartConfig,
50 }: {
51 chart?: Partial<Dashboards.Chart>
52 chartConfig?: Partial<ChartConfig>
53 }) => void
54}
55
56export const ChartBlock = ({
57 label,
58 attribute,
59 provider,
60 startDate,
61 endDate,
62 interval = '1d',
63 defaultChartStyle = 'bar',
64 defaultLogScale = false,
65 isLoading = false,
66 actions,
67 maxHeight,
68 onUpdateChartConfig,
69}: ChartBlockProps) => {
70 const router = useRouter()
71 const { ref } = router.query
72
73 const state = useDatabaseSelectorStateSnapshot()
74 const [chartStyle, setChartStyle] = useState<string>(defaultChartStyle)
75 const logScale = useMemo(() => defaultLogScale, [defaultLogScale])
76 const [latestValue, setLatestValue] = useState<string | undefined>()
77 const formatChartDate = useFormatDateTime()
78 const formatTooltipDate = useCallback(
79 (value: string | number, format: string) =>
80 /^\d{4}-\d{2}-\d{2}$/.test(String(value))
81 ? timestampFormatter(String(value), format, true)
82 : formatChartDate(value, format),
83 [formatChartDate]
84 )
85
86 const databaseIdentifier = state.selectedDatabaseId
87
88 const {
89 data: dailyStatsData,
90 isFetching: isFetchingDailyStats,
91 isPending: isLoadingDailyStats,
92 } = useProjectDailyStatsQuery(
93 {
94 projectRef: ref as string,
95 attribute: attribute as ProjectDailyStatsAttribute,
96 startDate: dayjs(startDate).format('YYYY-MM-DD'),
97 endDate: dayjs(endDate).format('YYYY-MM-DD'),
98 },
99 { enabled: provider === 'daily-stats' }
100 )
101
102 const {
103 data: infraMonitoringRawData,
104 isFetching: isFetchingInfraMonitoring,
105 isPending: isLoadingInfraMonitoring,
106 } = useInfraMonitoringAttributesQuery(
107 {
108 projectRef: ref as string,
109 attributes: [attribute as InfraMonitoringAttribute],
110 startDate,
111 endDate,
112 interval: interval as AnalyticsInterval,
113 databaseIdentifier,
114 },
115 { enabled: provider === 'infra-monitoring' }
116 )
117
118 const infraMonitoringData = useMemo(() => {
119 if (!infraMonitoringRawData) return undefined
120 const mapped = mapMultiResponseToAnalyticsData(infraMonitoringRawData, [
121 attribute as InfraMonitoringAttribute,
122 ])
123 return mapped[attribute]
124 }, [infraMonitoringRawData, attribute])
125
126 const chartData =
127 provider === 'infra-monitoring'
128 ? infraMonitoringData
129 : provider === 'daily-stats'
130 ? dailyStatsData
131 : undefined
132
133 const isFetching =
134 provider === 'infra-monitoring'
135 ? isFetchingInfraMonitoring
136 : provider === 'daily-stats'
137 ? isFetchingDailyStats
138 : false
139
140 const loading =
141 isLoading ||
142 attribute.startsWith('new_snippet_') ||
143 (provider === 'infra-monitoring'
144 ? isLoadingInfraMonitoring
145 : provider === 'daily-stats'
146 ? isLoadingDailyStats
147 : isLoading)
148
149 const metric = METRICS.find((x) => x.key === attribute)
150 const metricLabel = metric?.label ?? attribute
151
152 const getCellColor = (attribute: string, value: number) => {
153 const threshold = METRIC_THRESHOLDS[attribute as keyof typeof METRIC_THRESHOLDS]
154 if (!threshold) return 'hsl(var(--chart-1))'
155 if (threshold.check === 'gt') {
156 return value >= threshold.danger
157 ? 'hsl(var(--chart-destructive))'
158 : value >= threshold.warning
159 ? 'hsl(var(--chart-warning))'
160 : 'hsl(var(--chart-1))'
161 } else {
162 return value <= threshold.danger
163 ? 'hsl(var(--chart-destructive))'
164 : value <= threshold.warning
165 ? 'hsl(var(--chart-warning))'
166 : 'hsl(var(--chart-1))'
167 }
168 }
169
170 const isPercentage = chartData?.format === '%'
171 const data = (chartData?.data ?? []).map((x: any) => {
172 const value = isPercentage ? x[attribute] : x[attribute]
173 const color = getCellColor(attribute, x[attribute])
174 return {
175 ...x,
176 period_start: dayjs(x.period_start).utc().format('YYYY-MM-DD'),
177 [attribute]: value,
178 [metricLabel]: value,
179 fill: color,
180 stroke: color,
181 }
182 })
183
184 const hasNonPositiveValues = useMemo(() => {
185 if (!logScale || !data.length) return false
186 return checkHasNonPositiveValues(data, metricLabel)
187 }, [logScale, data, metricLabel])
188
189 const effectiveLogScale = logScale && !hasNonPositiveValues
190
191 const yAxisWidth = computeYAxisWidth(data, metricLabel, {
192 isLogScale: effectiveLogScale,
193 isPercentage,
194 })
195
196 const getInitialHighlightedValue = useCallback(() => {
197 if (!chartData?.data?.length) return undefined
198 const lastDataPoint = chartData.data[chartData.data.length - 1]
199 const value = lastDataPoint[attribute]
200 return isPercentage
201 ? `${typeof value === 'number' ? value.toFixed(1) : value}%`
202 : typeof value === 'number'
203 ? value.toLocaleString()
204 : value
205 }, [chartData?.data, chartData?.format, attribute])
206
207 useEffect(() => {
208 if (defaultChartStyle) setChartStyle(defaultChartStyle)
209 }, [defaultChartStyle])
210
211 useEffect(() => {
212 setLatestValue(getInitialHighlightedValue())
213 }, [chartData, getInitialHighlightedValue])
214
215 return (
216 <ReportBlockContainer
217 draggable
218 showDragHandle
219 loading={isFetching}
220 icon={metric?.category?.icon('text-foreground-muted')}
221 label={label}
222 actions={
223 <>
224 <ButtonTooltip
225 type="text"
226 size="tiny"
227 disabled={loading}
228 className="w-7 h-7"
229 icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
230 onClick={() => {
231 const style = chartStyle === 'bar' ? 'line' : 'bar'
232 if (onUpdateChartConfig) onUpdateChartConfig({ chart: { chart_type: style } })
233 setChartStyle(style)
234 }}
235 tooltip={{
236 content: {
237 side: 'bottom',
238 className: 'max-w-56 text-center',
239 text: `View as ${chartStyle === 'bar' ? 'line chart' : 'bar chart'}`,
240 },
241 }}
242 />
243 <ButtonTooltip
244 type={logScale ? 'default' : 'text'}
245 size="tiny"
246 disabled={loading}
247 className="h-7 px-1.5 font-mono text-[10px]"
248 icon={<span className="font-mono text-[10px] leading-none">Log</span>}
249 onClick={() => {
250 const next = !logScale
251 if (onUpdateChartConfig) onUpdateChartConfig({ chartConfig: { logScale: next } })
252 }}
253 tooltip={{
254 content: {
255 side: 'bottom',
256 className: 'max-w-56 text-center',
257 text: `Switch to ${logScale ? 'linear' : 'logarithmic'} scale`,
258 },
259 }}
260 />
261 {actions}
262 </>
263 }
264 >
265 {loading ? (
266 <div className="flex grow w-full flex-col items-center justify-center gap-y-2 px-4">
267 <Loader2 size={18} className="animate-spin text-border-strong" />
268 <p className="text-xs text-foreground-lighter text-center">Loading data for {label}</p>
269 </div>
270 ) : chartData === undefined ? (
271 <div className="flex grow w-full flex-col items-center justify-center gap-y-2 px-4">
272 <WarningIcon />
273 <p className="text-xs text-foreground-lighter text-center">
274 Unable to load data for {label}
275 </p>
276 </div>
277 ) : data.length === 0 ? (
278 <div className="flex grow w-full flex-col items-center justify-center gap-y-2">
279 <NoDataPlaceholder
280 size="small"
281 className="border-0"
282 description="It may take up to 24 hours for data to refresh"
283 />
284 </div>
285 ) : (
286 <>
287 {latestValue && (
288 <div className="pt-2 px-3 w-full text-left leading-tight">
289 <span className="text-xs font-mono uppercase text-foreground-light">
290 Most recently
291 </span>
292 <p className="text-lg text">{latestValue}</p>
293 </div>
294 )}
295 {hasNonPositiveValues && (
296 <p className="px-3 pt-1 text-xs text-foreground-light">
297 Log scale is unavailable because the data contains zero or negative values.
298 </p>
299 )}
300 <ChartContainer
301 className="w-full aspect-auto px-3 py-2"
302 style={{
303 height: maxHeight ? `${maxHeight}px` : undefined,
304 minHeight: maxHeight ? `${maxHeight}px` : undefined,
305 }}
306 >
307 {chartStyle === 'bar' ? (
308 <BarChart accessibilityLayer margin={{ left: 0, right: 0 }} data={data}>
309 <CartesianGrid vertical={false} />
310 <XAxis
311 dataKey="period_start"
312 tickLine={false}
313 axisLine={false}
314 tickMargin={8}
315 minTickGap={32}
316 />
317 <YAxis
318 scale={effectiveLogScale ? 'log' : 'auto'}
319 domain={effectiveLogScale ? [1, 'auto'] : isPercentage ? [0, 100] : undefined}
320 allowDataOverflow={effectiveLogScale}
321 width={yAxisWidth}
322 tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick}
323 />
324 <ChartTooltip
325 content={
326 <ChartTooltipContent
327 className="min-w-[200px]"
328 labelSuffix={isPercentage ? '%' : ''}
329 labelFormatter={(x) => formatTooltipDate(x as string | number, 'DD MMM YYYY')}
330 />
331 }
332 />
333 <Bar dataKey={metricLabel} radius={[2, 2, 1, 1]} />
334 </BarChart>
335 ) : (
336 <LineChart accessibilityLayer margin={{ left: 0, right: 0 }} data={data}>
337 <CartesianGrid vertical={false} />
338 <XAxis
339 dataKey="period_start"
340 tickLine={false}
341 axisLine={false}
342 tickMargin={8}
343 minTickGap={32}
344 />
345 <YAxis
346 scale={effectiveLogScale ? 'log' : 'auto'}
347 domain={effectiveLogScale ? [1, 'auto'] : isPercentage ? [0, 100] : undefined}
348 allowDataOverflow={effectiveLogScale}
349 width={yAxisWidth}
350 tickFormatter={effectiveLogScale ? formatLogTick : formatYAxisTick}
351 />
352 <ChartTooltip
353 content={
354 <ChartTooltipContent
355 labelSuffix={chartData?.format === '%' ? '%' : ''}
356 labelFormatter={(x) => formatTooltipDate(x as string | number, 'DD MMM YYYY')}
357 />
358 }
359 />
360 <Line dataKey={metricLabel} stroke="hsl(var(--chart-1))" radius={4} />
361 </LineChart>
362 )}
363 </ChartContainer>
364 </>
365 )}
366 </ReportBlockContainer>
367 )
368}