mirror of
https://github.com/supabase/supabase.git
synced 2026-06-15 18:17:09 +08:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Subtle improvement to charts that cannot load data. Less floaty. | Before | After | |--------|--------| | <img width="731" height="323" alt="Screenshot 2026-06-08 at 11 46 21" src="https://github.com/user-attachments/assets/14c87766-dfe3-448d-8c9c-f3176b658d08" /> | <img width="791" height="333" alt="Screenshot 2026-06-08 at 11 46 01" src="https://github.com/user-attachments/assets/d0e33b91-2990-41d2-b14c-25065b1f0c12" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes **Style** - Improved visual styling for chart error and loading states. Chart placeholders now feature increased height and enhanced border presentation with dashed borders and rounded corners, providing clearer visual distinction when data is unavailable or unable to load. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
387 lines
11 KiB
TypeScript
387 lines
11 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'
|
|
|
|
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-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 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>
|
|
)
|
|
}
|