mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-05-26 07:44:05 +00:00
Major additions: - Complete Next.js studio application with 1600+ components - Docker support (Dockerfile.combined, docker-compose.yml) - GCP deployment documentation and benchmarks - SQL benchmark scripts for performance testing - Sentry integration for monitoring - Comprehensive test suite and mocks Studio features: - Dashboard and admin interfaces - Data visualization components - Authentication and user management - API integration with RuVector backend - Static data and public assets 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
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 Panel from 'components/ui/Panel'
|
|
import type { ChartHighlightAction } from './ChartHighlightActions'
|
|
import { ComposedChart } from './ComposedChart'
|
|
|
|
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 { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
|
import { useChartHighlight } from './useChartHighlight'
|
|
|
|
import dayjs from 'dayjs'
|
|
import type { UpdateDateRange } from 'pages/project/[ref]/observability/database'
|
|
import type { ChartData } from './Charts.types'
|
|
import { MultiAttribute } from './ComposedChart.utils'
|
|
|
|
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
|
|
isLoading?: boolean
|
|
format?: string
|
|
highlightedValue?: string | number
|
|
className?: string
|
|
showTooltip?: boolean
|
|
showLegend?: boolean
|
|
showTotal?: boolean
|
|
showMaxValue?: boolean
|
|
updateDateRange?: UpdateDateRange
|
|
valuePrecision?: number
|
|
isVisible?: boolean
|
|
docsUrl?: string
|
|
hide?: boolean
|
|
syncId?: string
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
data,
|
|
isVisible
|
|
)
|
|
|
|
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
|
|
|
|
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>
|
|
)
|
|
}
|