ComposedChartHandler.tsx386 lines · main
1import dayjs from 'dayjs'
2import { List, Loader2 } from 'lucide-react'
3import { useRouter } from 'next/router'
4import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react'
5import { Card, cn, WarningIcon } from 'ui'
6
7import type { ChartHighlightAction } from './ChartHighlightActions'
8import type { ChartData } from './Charts.types'
9import { ComposedChart } from './ComposedChart'
10import { MultiAttribute } from './ComposedChart.utils'
11import { useChartHighlight } from './useChartHighlight'
12import Panel from '@/components/ui/Panel'
13import { AnalyticsInterval, DataPoint } from '@/data/analytics/constants'
14import { useInfraMonitoringQueries } from '@/data/analytics/infra-monitoring-queries'
15import { InfraMonitoringAttribute } from '@/data/analytics/infra-monitoring-query'
16import { useProjectDailyStatsQueries } from '@/data/analytics/project-daily-stats-queries'
17import { ProjectDailyStatsAttribute } from '@/data/analytics/project-daily-stats-query'
18import type { UpdateDateRange } from '@/pages/project/[ref]/observability/database'
19import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
20
21export interface ComposedChartHandlerProps {
22 id?: string
23 label: string
24 attributes: MultiAttribute[]
25 startDate: string
26 endDate: string
27 interval?: string
28 customDateFormat?: string
29 defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine'
30 hideChartType?: boolean
31 data?: ChartData | DataPoint[]
32 isLoading?: boolean
33 format?: string
34 highlightedValue?: string | number
35 className?: string
36 showTooltip?: boolean
37 showLegend?: boolean
38 showTotal?: boolean
39 showMaxValue?: boolean
40 normalizeVisibleStackToPercent?: boolean
41 updateDateRange?: UpdateDateRange
42 valuePrecision?: number
43 isVisible?: boolean
44 docsUrl?: string
45 hide?: boolean
46 syncId?: string
47 YAxisProps?: {
48 width?: number
49 tickFormatter?: (value: number) => string
50 domain?: [number | string, number | string]
51 allowDataOverflow?: boolean
52 }
53}
54
55/**
56 * Wrapper component that handles intersection observer logic for lazy loading
57 */
58const LazyChartWrapper = ({ children }: PropsWithChildren) => {
59 const [isVisible, setIsVisible] = useState(false)
60 const ref = useRef<HTMLDivElement>(null)
61
62 useEffect(() => {
63 const observer = new IntersectionObserver(
64 ([entry]) => {
65 if (entry.isIntersecting) {
66 setIsVisible(true)
67 observer.disconnect()
68 }
69 },
70 {
71 rootMargin: '150px 0px', // Start loading before the component enters viewport
72 threshold: 0,
73 }
74 )
75
76 const currentRef = ref.current
77 if (currentRef) {
78 observer.observe(currentRef)
79 }
80
81 return () => {
82 if (currentRef) {
83 observer.unobserve(currentRef)
84 }
85 }
86 }, [])
87
88 return (
89 <div ref={ref}>
90 {React.cloneElement(children as React.ReactElement<{ isVisible: boolean }>, { isVisible })}
91 </div>
92 )
93}
94
95/**
96 * Controls chart display state. Optionally fetches static chart data if data is not provided.
97 *
98 * If the `data` prop is provided, it will disable automatic chart data fetching and pass the data directly to the chart render.
99 * - loading state can also be provided through the `isLoading` prop, to display loading placeholders. Ignored if `data` key not provided.
100 * - if `isLoading=true` and `data` is `undefined`, loading error message will be shown.
101 *
102 * Provided data must be in the expected chart format.
103 */
104const ComposedChartHandler = ({
105 label,
106 attributes,
107 startDate,
108 endDate,
109 interval,
110 customDateFormat,
111 children = null,
112 defaultChartStyle = 'bar',
113 hideChartType = false,
114 data,
115 isLoading,
116 format,
117 highlightedValue,
118 className,
119 showTooltip,
120 showLegend,
121 showMaxValue,
122 showTotal,
123 updateDateRange,
124 valuePrecision,
125 isVisible = true,
126 id,
127 syncId,
128 ...otherProps
129}: PropsWithChildren<ComposedChartHandlerProps>) => {
130 const router = useRouter()
131 const { ref } = router.query
132
133 const state = useDatabaseSelectorStateSnapshot()
134 const [chartStyle, setChartStyle] = useState<string>(defaultChartStyle)
135 const chartHighlight = useChartHighlight()
136
137 const databaseIdentifier = state.selectedDatabaseId
138
139 const attributeQueries = useAttributeQueries(
140 attributes,
141 ref,
142 startDate,
143 endDate,
144 interval as AnalyticsInterval,
145 databaseIdentifier,
146 Array.isArray(data) ? undefined : data,
147 isVisible
148 )
149
150 const combinedData = useMemo(() => {
151 if (data) return Array.isArray(data) ? data : data.data
152
153 const isLoading = attributeQueries.some((query: any) => query.isLoading)
154 if (isLoading) return undefined
155
156 const hasError = attributeQueries.some((query: any) => !query.data)
157 if (hasError) return undefined
158
159 const timestamps = new Set<string>()
160 attributeQueries.forEach((query: any) => {
161 query.data?.data?.forEach((point: any) => {
162 if (point?.period_start) {
163 timestamps.add(point.period_start)
164 }
165 })
166 })
167
168 const referenceLineQueries = attributeQueries.filter(
169 (_, index) => attributes[index].provider === 'reference-line'
170 )
171
172 const combined = Array.from(timestamps)
173 .sort()
174 .map((timestamp) => {
175 const point: any = { timestamp }
176
177 attributes.forEach((attr, index) => {
178 if (!attr) return
179
180 if (attr.customValue !== undefined) {
181 point[attr.attribute] = attr.customValue
182 return
183 }
184
185 if (attr.provider === 'reference-line') return
186
187 const queryData = attributeQueries[index]?.data?.data
188 const matchingPoint = queryData?.find((p: any) => p.period_start === timestamp)
189 let value = matchingPoint?.[attr.attribute] ?? 0
190
191 if (attr.manipulateValue && typeof attr.manipulateValue === 'function') {
192 const numericValue = typeof value === 'number' ? value : Number(value) || 0
193 value = attr.manipulateValue(numericValue)
194 }
195
196 point[attr.attribute] = value
197 })
198
199 referenceLineQueries.forEach((query: any) => {
200 const attr = query.data.attribute
201 const value = query.data.total
202 point[attr] = value
203 })
204
205 const formattedDataPoint: DataPoint =
206 !('period_start' in point) && 'timestamp' in point
207 ? { ...point, period_start: dayjs.utc(point.timestamp).unix() * 1000 }
208 : point
209
210 return formattedDataPoint
211 })
212
213 return combined as DataPoint[]
214 }, [data, attributeQueries, attributes])
215
216 const loading = isLoading || attributeQueries.some((query: any) => query.isLoading)
217
218 const _highlightedValue = useMemo(() => {
219 if (highlightedValue !== undefined) return highlightedValue
220
221 const firstAttr = attributes[0]
222 const firstQuery = attributeQueries[0]
223 const firstData = firstQuery?.data
224
225 if (!firstData) return undefined
226
227 const shouldHighlightMaxValue =
228 firstAttr.provider === 'daily-stats' &&
229 !firstAttr.attribute.includes('ingress') &&
230 !firstAttr.attribute.includes('egress') &&
231 'maximum' in firstData
232
233 const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData
234
235 return shouldHighlightMaxValue
236 ? firstData.maximum
237 : firstAttr.provider === 'daily-stats'
238 ? firstData.total
239 : shouldHighlightTotalGroupedValue
240 ? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped]
241 : (firstData.data[firstData.data.length - 1] as any)?.[firstAttr.attribute]
242 }, [highlightedValue, attributes, attributeQueries])
243
244 const highlightActions: ChartHighlightAction[] = useMemo(() => {
245 return [
246 {
247 id: 'open-logs',
248 label: 'Open in Postgres Logs',
249 icon: <List size={12} />,
250 onSelect: ({ start, end }) => {
251 const projectRef = ref as string
252 if (!projectRef) return
253 const url = `/project/${projectRef}/logs/postgres-logs?its=${start}&ite=${end}`
254 router.push(url)
255 },
256 },
257 ]
258 }, [ref])
259
260 if (loading) {
261 return (
262 <Card
263 className={cn(
264 'flex min-h-[280px] w-full flex-col items-center justify-center gap-y-2',
265 className
266 )}
267 >
268 <Loader2 size={18} className="animate-spin text-border-strong" />
269 <p className="text-xs text-foreground-lighter">Loading data for {label}</p>
270 </Card>
271 )
272 }
273
274 if (!combinedData) {
275 return (
276 <div className="flex h-52 w-full flex-col items-center justify-center gap-y-2">
277 <WarningIcon />
278 <p className="text-xs text-foreground-lighter">Unable to load data for {label}</p>
279 </div>
280 )
281 }
282
283 return (
284 <Panel
285 noMargin
286 noHideOverflow
287 className={cn('relative w-full scroll-mt-16', className)}
288 wrapWithLoading={false}
289 id={id ?? label.toLowerCase().replaceAll(' ', '-')}
290 >
291 <Panel.Content className="flex flex-col gap-4">
292 <div className="absolute right-6 z-50 flex justify-between scroll-mt-16">{children}</div>
293 <ComposedChart
294 attributes={attributes}
295 data={combinedData as DataPoint[]}
296 format={format}
297 // [Joshen] This is where it's messing up
298 xAxisKey="period_start"
299 yAxisKey={attributes[0].attribute}
300 highlightedValue={_highlightedValue}
301 title={label}
302 customDateFormat={customDateFormat}
303 chartHighlight={chartHighlight}
304 chartStyle={chartStyle}
305 showTooltip={showTooltip}
306 showLegend={showLegend}
307 showTotal={showTotal}
308 showMaxValue={showMaxValue}
309 onChartStyleChange={setChartStyle}
310 updateDateRange={updateDateRange}
311 valuePrecision={valuePrecision}
312 hideChartType={hideChartType}
313 syncId={syncId}
314 highlightActions={highlightActions}
315 {...otherProps}
316 />
317 </Panel.Content>
318 </Panel>
319 )
320}
321
322const useAttributeQueries = (
323 attributes: MultiAttribute[],
324 ref: string | string[] | undefined,
325 startDate: string,
326 endDate: string,
327 interval: AnalyticsInterval,
328 databaseIdentifier: string | undefined,
329 data: ChartData | undefined,
330 isVisible: boolean
331) => {
332 const infraAttributes = attributes
333 .filter((attr) => attr?.provider === 'infra-monitoring')
334 .map((attr) => attr.attribute as InfraMonitoringAttribute)
335 const dailyStatsAttributes = attributes
336 .filter((attr) => attr?.provider === 'daily-stats')
337 .map((attr) => attr.attribute as ProjectDailyStatsAttribute)
338 const referenceLines = attributes.filter((attr) => attr?.provider === 'reference-line')
339
340 const infraQueries = useInfraMonitoringQueries(
341 infraAttributes,
342 ref,
343 startDate,
344 endDate,
345 interval,
346 databaseIdentifier,
347 data,
348 isVisible
349 )
350 const dailyStatsQueries = useProjectDailyStatsQueries(
351 dailyStatsAttributes,
352 ref,
353 startDate,
354 endDate,
355 data,
356 isVisible
357 )
358
359 const referenceLineQueries = referenceLines.map((line) => {
360 let value = line.value ?? line.customValue ?? 0
361
362 return {
363 data: {
364 data: [],
365 attribute: line.attribute,
366 total: value,
367 maximum: value,
368 totalGrouped: { [line.attribute]: value },
369 },
370 isLoading: false,
371 isError: false,
372 }
373 })
374
375 return [...infraQueries, ...dailyStatsQueries, ...referenceLineQueries]
376}
377
378export function LazyComposedChartHandler(props: ComposedChartHandlerProps) {
379 if (props.hide) return null
380
381 return (
382 <LazyChartWrapper>
383 <ComposedChartHandler {...props} />
384 </LazyChartWrapper>
385 )
386}