diff --git a/apps/studio/components/interfaces/Reports/ReportChart.tsx b/apps/studio/components/interfaces/Reports/ReportChart.tsx index 8cfe89816f0..4883fc624af 100644 --- a/apps/studio/components/interfaces/Reports/ReportChart.tsx +++ b/apps/studio/components/interfaces/Reports/ReportChart.tsx @@ -7,7 +7,7 @@ * This component acts as a bridge between the data-fetching logic and the * presentational chart component. */ -import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' +import LogChartHandler from 'components/ui/Charts/LogChartHandler' import { useChartData } from 'hooks/useChartData' import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' @@ -43,7 +43,7 @@ const ReportChart = ({ }) return ( - 0 ? chartAttributes : chart.attributes) as MultiAttribute[] diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx index 84f50022199..1c90a17dd37 100644 --- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx @@ -1,11 +1,5 @@ -/** - * ComposedChartHandler - * - * A presentational component for rendering charts. - * It is responsible only for rendering the chart UI based on the data and loading state passed to it as props. - * All the complex data fetching logic has been moved to the useChartData hook. - */ -import React, { PropsWithChildren, useState, useEffect, useRef } from 'react' +import React, { PropsWithChildren, useState, useMemo, useEffect, useRef } from 'react' +import { useRouter } from 'next/router' import { Loader2 } from 'lucide-react' import { cn, WarningIcon } from 'ui' @@ -17,14 +11,14 @@ import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query' import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries' import { ProjectDailyStatsAttribute } from 'data/analytics/project-daily-stats-query' import { useProjectDailyStatsQueries } from 'data/analytics/project-daily-stats-queries' +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { useChartHighlight } from './useChartHighlight' -import { getMockDataForAttribute } from 'data/reports/auth-charts' import type { ChartData } from './Charts.types' import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' -import type { MultiAttribute } from './ComposedChart.utils' +import { MultiAttribute } from './ComposedChart.utils' -interface ComposedChartHandlerProps { +export interface ComposedChartHandlerProps { id?: string label: string attributes: MultiAttribute[] @@ -46,7 +40,6 @@ interface ComposedChartHandlerProps { updateDateRange: UpdateDateRange valuePrecision?: number isVisible?: boolean - titleTooltip?: string docsUrl?: string } @@ -98,6 +91,9 @@ const LazyChartWrapper = ({ children }: PropsWithChildren) => { const ComposedChartHandler = ({ label, attributes, + startDate, + endDate, + interval, customDateFormat, children = null, defaultChartStyle = 'bar', @@ -113,14 +109,131 @@ const ComposedChartHandler = ({ showTotal, updateDateRange, valuePrecision, - titleTooltip, + isVisible = true, id, ...otherProps }: PropsWithChildren) => { + const router = useRouter() + const { ref } = router.query + + const state = useDatabaseSelectorStateSnapshot() const [chartStyle, setChartStyle] = useState(defaultChartStyle) const chartHighlight = useChartHighlight() - if (isLoading) { + const databaseIdentifier = state.selectedDatabaseId + + // Use the custom hook at the top level of the component + const attributeQueries = useAttributeQueries( + attributes, + ref, + startDate, + endDate, + interval as AnalyticsInterval, + databaseIdentifier, + data, + isVisible + ) + + // Combine all the data into a single dataset + const combinedData = useMemo(() => { + if (data) return 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 + + // Get all unique timestamps from all datasets + const timestamps = new Set() + 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' + ) + + // Combine data points for each timestamp + const combined = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { timestamp } + + // Add regular attributes + attributes.forEach((attr, index) => { + if (!attr) return + + // Handle custom value attributes (like disk size) + if (attr.customValue !== undefined) { + point[attr.attribute] = attr.customValue + return + } + + // Skip reference line attributes here, we'll add them below + 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 + + // Apply value manipulation if provided + if (attr.manipulateValue && typeof attr.manipulateValue === 'function') { + // Ensure value is a number before manipulation + const numericValue = typeof value === 'number' ? value : Number(value) || 0 + value = attr.manipulateValue(numericValue) + } + + point[attr.attribute] = value + }) + + // Add reference line values for each timestamp + referenceLineQueries.forEach((query: any) => { + const attr = query.data.attribute + const value = query.data.total + point[attr] = value + }) + + return point as DataPoint + }) + + return combined as DataPoint[] + }, [data, attributeQueries, attributes]) + + const loading = isLoading || attributeQueries.some((query: any) => query.isLoading) + + // Calculate highlighted value based on the first attribute's data + 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]) + + if (loading) { return ( @@ -159,11 +272,11 @@ const ComposedChartHandler = ({
{children}
@@ -184,7 +296,7 @@ const ComposedChartHandler = ({ ) } -export const useAttributeQueries = ( +const useAttributeQueries = ( attributes: MultiAttribute[], ref: string | string[] | undefined, startDate: string, @@ -194,15 +306,16 @@ export const useAttributeQueries = ( data: ChartData | undefined, isVisible: boolean ) => { - const projectRef = typeof ref === 'string' ? ref : Array.isArray(ref) ? ref[0] : '' - - const infraAttributes = attributes.filter((attr) => attr.provider === 'infra-monitoring') - const dailyStatsAttributes = attributes.filter((attr) => attr.provider === 'daily-stats') - const mockAttributes = attributes.filter((attr) => attr.provider === 'mock') - const referenceLineAttributes = attributes.filter((attr) => attr.provider === 'reference-line') + 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.map((attr) => attr.attribute as InfraMonitoringAttribute), + infraAttributes, ref, startDate, endDate, @@ -212,7 +325,7 @@ export const useAttributeQueries = ( isVisible ) const dailyStatsQueries = useProjectDailyStatsQueries( - dailyStatsAttributes.map((attr) => attr.attribute as ProjectDailyStatsAttribute), + dailyStatsAttributes, ref, startDate, endDate, @@ -222,48 +335,23 @@ export const useAttributeQueries = ( isVisible ) - let infraIdx = 0 - let dailyStatsIdx = 0 - return attributes - .filter((attr) => attr.provider !== 'logs') - .map((attr) => { - if (attr.provider === 'infra-monitoring') { - return { - ...infraQueries[infraIdx++], - data: { ...infraQueries[infraIdx - 1]?.data, provider: 'infra-monitoring' }, - } - } else if (attr.provider === 'daily-stats') { - return { - ...dailyStatsQueries[dailyStatsIdx++], - data: { ...dailyStatsQueries[dailyStatsIdx - 1]?.data, provider: 'daily-stats' }, - } - } else if (attr.provider === 'mock') { - const mockData = getMockDataForAttribute(attr.attribute) - return { - isLoading: false, - data: { ...mockData, provider: 'mock', attribute: attr.attribute }, - } - } else if (attr.provider === 'reference-line') { - let value = attr.value || 0 - return { - data: { - data: [], - attribute: attr.attribute, - total: value, - maximum: value, - totalGrouped: { [attr.attribute]: value }, - provider: 'reference-line', - }, - isLoading: false, - isError: false, - } - } else { - return { - isLoading: false, - data: undefined, - } - } - }) + const referenceLineQueries = referenceLines.map((line) => { + let value = line.value || 0 + + return { + data: { + data: [], // Will be populated in combinedData + attribute: line.attribute, + total: value, + maximum: value, + totalGrouped: { [line.attribute]: value }, + }, + isLoading: false, + isError: false, + } + }) + + return [...infraQueries, ...dailyStatsQueries, ...referenceLineQueries] } export default function LazyComposedChartHandler(props: ComposedChartHandlerProps) { diff --git a/apps/studio/components/ui/Charts/LogChartHandler.tsx b/apps/studio/components/ui/Charts/LogChartHandler.tsx new file mode 100644 index 00000000000..2db174a9d19 --- /dev/null +++ b/apps/studio/components/ui/Charts/LogChartHandler.tsx @@ -0,0 +1,268 @@ +import React, { PropsWithChildren, useState, useEffect, useRef } from 'react' +import { Loader2 } from 'lucide-react' +import { cn, WarningIcon } from 'ui' + +import Panel from 'components/ui/Panel' +import ComposedChart from './ComposedChart' + +import { AnalyticsInterval, DataPoint } from 'data/analytics/constants' +import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query' +import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries' +import { ProjectDailyStatsAttribute } from 'data/analytics/project-daily-stats-query' +import { useProjectDailyStatsQueries } from 'data/analytics/project-daily-stats-queries' +import { useChartHighlight } from './useChartHighlight' +import { getMockDataForAttribute } from 'data/reports/auth-charts' + +import type { ChartData } from './Charts.types' +import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' +import type { MultiAttribute } from './ComposedChart.utils' + +interface LogChartHandlerProps { + id?: string + label: string + attributes: MultiAttribute[] + startDate: string + endDate: string + interval: string + customDateFormat?: string + defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine' + hideChartType?: boolean + data?: ChartData + isLoading?: boolean + format?: string + highlightedValue?: string | number + className?: string + showTooltip?: boolean + showLegend?: boolean + showTotal?: boolean + showMaxValue?: boolean + updateDateRange: UpdateDateRange + valuePrecision?: number + isVisible?: boolean + titleTooltip?: string + docsUrl?: string +} + +/** + * Wrapper component that handles intersection observer logic for lazy loading + */ +const LazyChartWrapper = ({ children }: PropsWithChildren) => { + const [isVisible, setIsVisible] = useState(false) + const ref = useRef(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
{React.cloneElement(children as React.ReactElement, { isVisible })}
+} + +/** + * 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 LogChartHandler = ({ + label, + attributes, + customDateFormat, + children = null, + defaultChartStyle = 'bar', + hideChartType = false, + data, + isLoading, + format, + highlightedValue, + className, + showTooltip, + showLegend, + showMaxValue, + showTotal, + updateDateRange, + valuePrecision, + titleTooltip, + id, + ...otherProps +}: PropsWithChildren) => { + const [chartStyle, setChartStyle] = useState(defaultChartStyle) + const chartHighlight = useChartHighlight() + + if (isLoading) { + return ( + + +

Loading data for {label}

+
+ ) + } + + if (!data) { + return ( +
+ +

Unable to load data for {label}

+
+ ) + } + + // Rest of the component remains similar, but pass all attributes to charts + return ( + + +
{children}
+ +
+
+ ) +} + +export const useAttributeQueries = ( + attributes: MultiAttribute[], + ref: string | string[] | undefined, + startDate: string, + endDate: string, + interval: AnalyticsInterval, + databaseIdentifier: string | undefined, + data: ChartData | undefined, + isVisible: boolean +) => { + const projectRef = typeof ref === 'string' ? ref : Array.isArray(ref) ? ref[0] : '' + + const infraAttributes = attributes.filter((attr) => attr.provider === 'infra-monitoring') + const dailyStatsAttributes = attributes.filter((attr) => attr.provider === 'daily-stats') + const mockAttributes = attributes.filter((attr) => attr.provider === 'mock') + const referenceLineAttributes = attributes.filter((attr) => attr.provider === 'reference-line') + + const infraQueries = useInfraMonitoringQueries( + infraAttributes.map((attr) => attr.attribute as InfraMonitoringAttribute), + ref, + startDate, + endDate, + interval, + databaseIdentifier, + data, + isVisible + ) + const dailyStatsQueries = useProjectDailyStatsQueries( + dailyStatsAttributes.map((attr) => attr.attribute as ProjectDailyStatsAttribute), + ref, + startDate, + endDate, + interval, + databaseIdentifier, + data, + isVisible + ) + + let infraIdx = 0 + let dailyStatsIdx = 0 + return attributes + .filter((attr) => attr.provider !== 'logs') + .map((attr) => { + if (attr.provider === 'infra-monitoring') { + return { + ...infraQueries[infraIdx++], + data: { ...infraQueries[infraIdx - 1]?.data, provider: 'infra-monitoring' }, + } + } else if (attr.provider === 'daily-stats') { + return { + ...dailyStatsQueries[dailyStatsIdx++], + data: { ...dailyStatsQueries[dailyStatsIdx - 1]?.data, provider: 'daily-stats' }, + } + } else if (attr.provider === 'mock') { + const mockData = getMockDataForAttribute(attr.attribute) + return { + isLoading: false, + data: { ...mockData, provider: 'mock', attribute: attr.attribute }, + } + } else if (attr.provider === 'reference-line') { + let value = attr.value || 0 + return { + data: { + data: [], + attribute: attr.attribute, + total: value, + maximum: value, + totalGrouped: { [attr.attribute]: value }, + provider: 'reference-line', + }, + isLoading: false, + isError: false, + } + } else { + return { + isLoading: false, + data: undefined, + } + } + }) +} + +export default function LazyLogChartHandler(props: LogChartHandlerProps) { + return ( + + + + ) +} diff --git a/apps/studio/hooks/useChartData.ts b/apps/studio/hooks/useChartData.ts index a413d7ee726..003d4bab529 100644 --- a/apps/studio/hooks/useChartData.ts +++ b/apps/studio/hooks/useChartData.ts @@ -16,7 +16,7 @@ import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants' import { useAuthLogsReport } from 'data/reports/auth-report-query' import type { ChartData } from 'components/ui/Charts/Charts.types' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import { useAttributeQueries } from 'components/ui/Charts/ComposedChartHandler' +import { useAttributeQueries } from 'components/ui/Charts/LogChartHandler' export const useChartData = ({ attributes, diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 3a1952fc28a..7e883005489 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -21,6 +21,7 @@ import ChartHandler from 'components/ui/Charts/ChartHandler' import Panel from 'components/ui/Panel' import ShimmerLine from 'components/ui/ShimmerLine' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' +import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' import { DateRangePicker } from 'components/ui/DateRangePicker' import GrafanaPromoBanner from 'components/ui/GrafanaPromoBanner' @@ -36,8 +37,8 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { TIME_PERIODS_INFRA } from 'lib/constants/metrics' import { formatBytes } from 'lib/helpers' -import ReportChart from 'components/interfaces/Reports/ReportChart' import type { NextPageWithLayout } from 'types' +import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' const DatabaseReport: NextPageWithLayout = () => { return ( @@ -207,10 +208,15 @@ const DatabaseUsage = () => { return ( <> +
+
+ +
+
-
+
{
{dateRange && REPORT_ATTRIBUTES_V2.filter((chart) => !chart.hide).map((chart) => ( - ))}