Files
supabase/apps/studio/components/ui/Charts/ComposedChartHandler.tsx

394 lines
12 KiB
TypeScript

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'
const parseChartTimestamp = (value: string) => {
const asNumber = Number(value)
return value !== '' && Number.isFinite(asNumber) ? dayjs(asNumber) : dayjs(value)
}
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 = ({ id, children }: PropsWithChildren<{ id: string }>) => {
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} id={id} className="scroll-mt-16">
{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 its = parseChartTimestamp(start).toISOString()
const ite = parseChartTimestamp(end).toISOString()
const url = `/project/${projectRef}/logs/postgres-logs?its=${its}&ite=${ite}`
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-64 w-full flex-col items-center justify-center gap-y-2 border border-dashed rounded-md">
<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', className)}
wrapWithLoading={false}
>
<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
const anchorId = props.id ?? props.label.toLowerCase().replaceAll(' ', '-')
return (
<LazyChartWrapper id={anchorId}>
<ComposedChartHandler {...props} />
</LazyChartWrapper>
)
}