ComposedChartHandler.tsx386 lines · main
| 1 | import dayjs from 'dayjs' |
| 2 | import { List, Loader2 } from 'lucide-react' |
| 3 | import { useRouter } from 'next/router' |
| 4 | import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react' |
| 5 | import { Card, cn, WarningIcon } from 'ui' |
| 6 | |
| 7 | import type { ChartHighlightAction } from './ChartHighlightActions' |
| 8 | import type { ChartData } from './Charts.types' |
| 9 | import { ComposedChart } from './ComposedChart' |
| 10 | import { MultiAttribute } from './ComposedChart.utils' |
| 11 | import { useChartHighlight } from './useChartHighlight' |
| 12 | import Panel from '@/components/ui/Panel' |
| 13 | import { AnalyticsInterval, DataPoint } from '@/data/analytics/constants' |
| 14 | import { useInfraMonitoringQueries } from '@/data/analytics/infra-monitoring-queries' |
| 15 | import { InfraMonitoringAttribute } from '@/data/analytics/infra-monitoring-query' |
| 16 | import { useProjectDailyStatsQueries } from '@/data/analytics/project-daily-stats-queries' |
| 17 | import { ProjectDailyStatsAttribute } from '@/data/analytics/project-daily-stats-query' |
| 18 | import type { UpdateDateRange } from '@/pages/project/[ref]/observability/database' |
| 19 | import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' |
| 20 | |
| 21 | export 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 | */ |
| 58 | const 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 | */ |
| 104 | const 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 | |
| 322 | const 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 | |
| 378 | export function LazyComposedChartHandler(props: ComposedChartHandlerProps) { |
| 379 | if (props.hide) return null |
| 380 | |
| 381 | return ( |
| 382 | <LazyChartWrapper> |
| 383 | <ComposedChartHandler {...props} /> |
| 384 | </LazyChartWrapper> |
| 385 | ) |
| 386 | } |