| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- import dayjs from 'dayjs'
- import { List, Loader2 } from 'lucide-react'
- import { useRouter } from 'next/router'
- import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react'
- import { Card, cn, WarningIcon } from 'ui'
- import type { ChartHighlightAction } from './ChartHighlightActions'
- import type { ChartData } from './Charts.types'
- import { ComposedChart } from './ComposedChart'
- import { MultiAttribute } from './ComposedChart.utils'
- import { useChartHighlight } from './useChartHighlight'
- import Panel from '@/components/ui/Panel'
- import { AnalyticsInterval, DataPoint } from '@/data/analytics/constants'
- import { useInfraMonitoringQueries } from '@/data/analytics/infra-monitoring-queries'
- import { InfraMonitoringAttribute } from '@/data/analytics/infra-monitoring-query'
- import { useProjectDailyStatsQueries } from '@/data/analytics/project-daily-stats-queries'
- import { ProjectDailyStatsAttribute } from '@/data/analytics/project-daily-stats-query'
- import type { UpdateDateRange } from '@/pages/project/[ref]/observability/database'
- import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
- export interface ComposedChartHandlerProps {
- id?: string
- label: string
- attributes: MultiAttribute[]
- startDate: string
- endDate: string
- interval?: string
- customDateFormat?: string
- defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine'
- hideChartType?: boolean
- data?: ChartData | DataPoint[]
- isLoading?: boolean
- format?: string
- highlightedValue?: string | number
- className?: string
- showTooltip?: boolean
- showLegend?: boolean
- showTotal?: boolean
- showMaxValue?: boolean
- normalizeVisibleStackToPercent?: boolean
- updateDateRange?: UpdateDateRange
- valuePrecision?: number
- isVisible?: boolean
- docsUrl?: string
- hide?: boolean
- syncId?: string
- YAxisProps?: {
- width?: number
- tickFormatter?: (value: number) => string
- domain?: [number | string, number | string]
- allowDataOverflow?: boolean
- }
- }
- /**
- * Wrapper component that handles intersection observer logic for lazy loading
- */
- const LazyChartWrapper = ({ children }: PropsWithChildren) => {
- const [isVisible, setIsVisible] = useState(false)
- const ref = useRef<HTMLDivElement>(null)
- useEffect(() => {
- const observer = new IntersectionObserver(
- ([entry]) => {
- if (entry.isIntersecting) {
- setIsVisible(true)
- observer.disconnect()
- }
- },
- {
- rootMargin: '150px 0px', // Start loading before the component enters viewport
- threshold: 0,
- }
- )
- const currentRef = ref.current
- if (currentRef) {
- observer.observe(currentRef)
- }
- return () => {
- if (currentRef) {
- observer.unobserve(currentRef)
- }
- }
- }, [])
- return (
- <div ref={ref}>
- {React.cloneElement(children as React.ReactElement<{ isVisible: boolean }>, { isVisible })}
- </div>
- )
- }
- /**
- * Controls chart display state. Optionally fetches static chart data if data is not provided.
- *
- * If the `data` prop is provided, it will disable automatic chart data fetching and pass the data directly to the chart render.
- * - loading state can also be provided through the `isLoading` prop, to display loading placeholders. Ignored if `data` key not provided.
- * - if `isLoading=true` and `data` is `undefined`, loading error message will be shown.
- *
- * Provided data must be in the expected chart format.
- */
- const ComposedChartHandler = ({
- label,
- attributes,
- startDate,
- endDate,
- interval,
- customDateFormat,
- children = null,
- defaultChartStyle = 'bar',
- hideChartType = false,
- data,
- isLoading,
- format,
- highlightedValue,
- className,
- showTooltip,
- showLegend,
- showMaxValue,
- showTotal,
- updateDateRange,
- valuePrecision,
- isVisible = true,
- id,
- syncId,
- ...otherProps
- }: PropsWithChildren<ComposedChartHandlerProps>) => {
- const router = useRouter()
- const { ref } = router.query
- const state = useDatabaseSelectorStateSnapshot()
- const [chartStyle, setChartStyle] = useState<string>(defaultChartStyle)
- const chartHighlight = useChartHighlight()
- const databaseIdentifier = state.selectedDatabaseId
- const attributeQueries = useAttributeQueries(
- attributes,
- ref,
- startDate,
- endDate,
- interval as AnalyticsInterval,
- databaseIdentifier,
- Array.isArray(data) ? undefined : data,
- isVisible
- )
- const combinedData = useMemo(() => {
- if (data) return Array.isArray(data) ? data : data.data
- const isLoading = attributeQueries.some((query: any) => query.isLoading)
- if (isLoading) return undefined
- const hasError = attributeQueries.some((query: any) => !query.data)
- if (hasError) return undefined
- const timestamps = new Set<string>()
- attributeQueries.forEach((query: any) => {
- query.data?.data?.forEach((point: any) => {
- if (point?.period_start) {
- timestamps.add(point.period_start)
- }
- })
- })
- const referenceLineQueries = attributeQueries.filter(
- (_, index) => attributes[index].provider === 'reference-line'
- )
- const combined = Array.from(timestamps)
- .sort()
- .map((timestamp) => {
- const point: any = { timestamp }
- attributes.forEach((attr, index) => {
- if (!attr) return
- if (attr.customValue !== undefined) {
- point[attr.attribute] = attr.customValue
- return
- }
- if (attr.provider === 'reference-line') return
- const queryData = attributeQueries[index]?.data?.data
- const matchingPoint = queryData?.find((p: any) => p.period_start === timestamp)
- let value = matchingPoint?.[attr.attribute] ?? 0
- if (attr.manipulateValue && typeof attr.manipulateValue === 'function') {
- const numericValue = typeof value === 'number' ? value : Number(value) || 0
- value = attr.manipulateValue(numericValue)
- }
- point[attr.attribute] = value
- })
- referenceLineQueries.forEach((query: any) => {
- const attr = query.data.attribute
- const value = query.data.total
- point[attr] = value
- })
- const formattedDataPoint: DataPoint =
- !('period_start' in point) && 'timestamp' in point
- ? { ...point, period_start: dayjs.utc(point.timestamp).unix() * 1000 }
- : point
- return formattedDataPoint
- })
- return combined as DataPoint[]
- }, [data, attributeQueries, attributes])
- const loading = isLoading || attributeQueries.some((query: any) => query.isLoading)
- const _highlightedValue = useMemo(() => {
- if (highlightedValue !== undefined) return highlightedValue
- const firstAttr = attributes[0]
- const firstQuery = attributeQueries[0]
- const firstData = firstQuery?.data
- if (!firstData) return undefined
- const shouldHighlightMaxValue =
- firstAttr.provider === 'daily-stats' &&
- !firstAttr.attribute.includes('ingress') &&
- !firstAttr.attribute.includes('egress') &&
- 'maximum' in firstData
- const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData
- return shouldHighlightMaxValue
- ? firstData.maximum
- : firstAttr.provider === 'daily-stats'
- ? firstData.total
- : shouldHighlightTotalGroupedValue
- ? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped]
- : (firstData.data[firstData.data.length - 1] as any)?.[firstAttr.attribute]
- }, [highlightedValue, attributes, attributeQueries])
- const highlightActions: ChartHighlightAction[] = useMemo(() => {
- return [
- {
- id: 'open-logs',
- label: 'Open in Postgres Logs',
- icon: <List size={12} />,
- onSelect: ({ start, end }) => {
- const projectRef = ref as string
- if (!projectRef) return
- const url = `/project/${projectRef}/logs/postgres-logs?its=${start}&ite=${end}`
- router.push(url)
- },
- },
- ]
- }, [ref])
- if (loading) {
- return (
- <Card
- className={cn(
- 'flex min-h-[280px] w-full flex-col items-center justify-center gap-y-2',
- className
- )}
- >
- <Loader2 size={18} className="animate-spin text-border-strong" />
- <p className="text-xs text-foreground-lighter">Loading data for {label}</p>
- </Card>
- )
- }
- if (!combinedData) {
- return (
- <div className="flex h-52 w-full flex-col items-center justify-center gap-y-2">
- <WarningIcon />
- <p className="text-xs text-foreground-lighter">Unable to load data for {label}</p>
- </div>
- )
- }
- return (
- <Panel
- noMargin
- noHideOverflow
- className={cn('relative w-full scroll-mt-16', className)}
- wrapWithLoading={false}
- id={id ?? label.toLowerCase().replaceAll(' ', '-')}
- >
- <Panel.Content className="flex flex-col gap-4">
- <div className="absolute right-6 z-50 flex justify-between scroll-mt-16">{children}</div>
- <ComposedChart
- attributes={attributes}
- data={combinedData as DataPoint[]}
- format={format}
- // [Joshen] This is where it's messing up
- xAxisKey="period_start"
- yAxisKey={attributes[0].attribute}
- highlightedValue={_highlightedValue}
- title={label}
- customDateFormat={customDateFormat}
- chartHighlight={chartHighlight}
- chartStyle={chartStyle}
- showTooltip={showTooltip}
- showLegend={showLegend}
- showTotal={showTotal}
- showMaxValue={showMaxValue}
- onChartStyleChange={setChartStyle}
- updateDateRange={updateDateRange}
- valuePrecision={valuePrecision}
- hideChartType={hideChartType}
- syncId={syncId}
- highlightActions={highlightActions}
- {...otherProps}
- />
- </Panel.Content>
- </Panel>
- )
- }
- const useAttributeQueries = (
- attributes: MultiAttribute[],
- ref: string | string[] | undefined,
- startDate: string,
- endDate: string,
- interval: AnalyticsInterval,
- databaseIdentifier: string | undefined,
- data: ChartData | undefined,
- isVisible: boolean
- ) => {
- const infraAttributes = attributes
- .filter((attr) => attr?.provider === 'infra-monitoring')
- .map((attr) => attr.attribute as InfraMonitoringAttribute)
- const dailyStatsAttributes = attributes
- .filter((attr) => attr?.provider === 'daily-stats')
- .map((attr) => attr.attribute as ProjectDailyStatsAttribute)
- const referenceLines = attributes.filter((attr) => attr?.provider === 'reference-line')
- const infraQueries = useInfraMonitoringQueries(
- infraAttributes,
- ref,
- startDate,
- endDate,
- interval,
- databaseIdentifier,
- data,
- isVisible
- )
- const dailyStatsQueries = useProjectDailyStatsQueries(
- dailyStatsAttributes,
- ref,
- startDate,
- endDate,
- data,
- isVisible
- )
- const referenceLineQueries = referenceLines.map((line) => {
- let value = line.value ?? line.customValue ?? 0
- return {
- data: {
- data: [],
- attribute: line.attribute,
- total: value,
- maximum: value,
- totalGrouped: { [line.attribute]: value },
- },
- isLoading: false,
- isError: false,
- }
- })
- return [...infraQueries, ...dailyStatsQueries, ...referenceLineQueries]
- }
- export function LazyComposedChartHandler(props: ComposedChartHandlerProps) {
- if (props.hide) return null
- return (
- <LazyChartWrapper>
- <ComposedChartHandler {...props} />
- </LazyChartWrapper>
- )
- }
|