Files
supabase/apps/studio/components/ui/Charts/ComposedChartHandler.tsx
b934e148a3 fix(reports): replace explicit any with number in tickFormatter callbacks (#45794)
## Problem

Recent database report PRs introduced `(value: any)` type annotations in
recharts `tickFormatter` callbacks, pushing the
`@typescript-eslint/no-explicit-any` ESLint ratchet from baseline 1014
to 1017 and blocking all PR merges.

## Fix

Replace `(value: any)` with `(value: number)` in all affected
`tickFormatter` callbacks across the three flagged files. Recharts
always passes a number to tick formatters, so this is a safe and
accurate type.

## How to test

- Run `pnpm lint --filter=studio` and confirm no new
`@typescript-eslint/no-explicit-any` violations appear in the three
changed files
- Check the ESLint ratchet count is back at or below baseline 1014

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Improved type safety for Y‑axis tick formatters across observability
charts (Memory, Swap, Network throughput, Disk IOPS/throughput/usage,
Replication Lag), ensuring tick values are treated as numeric for more
consistent chart rendering and reliability.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45794)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Ali Waseem <waseema393@gmail.com>
2026-05-11 14:41:14 +00:00

383 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 })}</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 || 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>
)
}