QueryPerformanceChart.tsx361 lines · main
1import { Loader2 } from 'lucide-react'
2import { useMemo, useState } from 'react'
3import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
4
5import type { ChartDataPoint } from '../QueryInsights/QueryInsights.types'
6import { QUERY_PERFORMANCE_CHART_TABS } from './QueryPerformance.constants'
7import { ComposedChart } from '@/components/ui/Charts/ComposedChart'
8import type { MultiAttribute } from '@/components/ui/Charts/ComposedChart.utils'
9
10interface QueryPerformanceChartProps {
11 dateRange?: {
12 period_start: { date: string; time_period: string }
13 period_end: { date: string; time_period: string }
14 interval: string
15 }
16 onDateRangeChange?: (from: string, to: string) => void
17 chartData: ChartDataPoint[]
18 isLoading: boolean
19 error: any
20 currentSelectedQuery: string | null
21 parsedLogs: any[]
22}
23
24const QueryMetricBlock = ({
25 label,
26 value,
27}: {
28 label: string
29 value: string | number | undefined
30}) => {
31 return (
32 <div className="flex flex-col gap-0.5 text-xs">
33 <span className="font-mono text-xs text-foreground-lighter uppercase">{label}</span>
34 <span className="text-lg tabular-nums">{value}</span>
35 </div>
36 )
37}
38
39const formatTimeValue = (value: number): string => {
40 if (value >= 1000) {
41 return `${(value / 1000).toFixed(1)}s`
42 }
43 return `${value.toFixed(1)}ms`
44}
45
46const formatNumberValue = (value: number): string => {
47 return value.toLocaleString()
48}
49
50export const QueryPerformanceChart = ({
51 onDateRangeChange,
52 chartData,
53 isLoading,
54 error,
55 currentSelectedQuery,
56 parsedLogs,
57}: QueryPerformanceChartProps) => {
58 const [selectedMetric, setSelectedMetric] = useState('query_latency')
59
60 const currentMetrics = useMemo(() => {
61 if (!chartData || chartData.length === 0) return []
62
63 switch (selectedMetric) {
64 case 'query_latency': {
65 const totalCalls = chartData.reduce((sum, d) => sum + d.calls, 0)
66 const trueP95 =
67 totalCalls > 0
68 ? chartData.reduce((sum, d) => sum + d.p95_time * d.calls, 0) / totalCalls
69 : 0
70
71 return [
72 {
73 label: 'Average p95',
74 value: `${Math.round(trueP95)}ms`,
75 },
76 ]
77 }
78 case 'rows_read': {
79 const totalRowsRead = chartData.reduce((sum, d) => sum + d.rows_read, 0)
80
81 return [
82 {
83 label: 'Total Rows Read',
84 value: totalRowsRead.toLocaleString(),
85 },
86 ]
87 }
88 case 'calls': {
89 const totalCalls = chartData.reduce((sum, d) => sum + d.calls, 0)
90
91 return [
92 {
93 label: 'Total Calls',
94 value: totalCalls.toLocaleString(),
95 },
96 ]
97 }
98 case 'cache_hits': {
99 const totalHits = chartData.reduce((sum, d) => sum + d.cache_hits, 0)
100 const totalMisses = chartData.reduce((sum, d) => sum + d.cache_misses, 0)
101 const total = totalHits + totalMisses
102 const hitRate = total > 0 ? (totalHits / total) * 100 : 0
103
104 return [
105 {
106 label: 'Cache Hit Rate',
107 value: `${hitRate.toFixed(2)}%`,
108 },
109 ]
110 }
111 default:
112 return []
113 }
114 }, [chartData, selectedMetric])
115
116 const transformedChartData = useMemo(() => {
117 if (selectedMetric !== 'query_latency') return chartData
118
119 const transformed = chartData.map((dataPoint) => ({
120 ...dataPoint,
121 p50_time: parseFloat((dataPoint.p50_time / 1000).toFixed(3)),
122 p95_time: parseFloat((dataPoint.p95_time / 1000).toFixed(3)),
123 }))
124
125 return transformed
126 }, [chartData, selectedMetric])
127
128 const querySpecificData = useMemo(() => {
129 if (!currentSelectedQuery || !parsedLogs.length) return null
130
131 const normalizedSelected = currentSelectedQuery.replace(/\s+/g, ' ').trim()
132 const queryLogs = parsedLogs.filter((log) => {
133 const normalized = (log.query || '').replace(/\s+/g, ' ').trim()
134 return normalized === normalizedSelected
135 })
136
137 const queryDataMap = new Map<
138 number,
139 {
140 time: number
141 rows_read: number
142 calls: number
143 cache_hits: number
144 }
145 >()
146
147 queryLogs.forEach((log) => {
148 const time = new Date(log.timestamp).getTime()
149 const meanTime = log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0
150 const rowsRead = log.rows_read ?? log.rows ?? 0
151 const calls = log.calls ?? 0
152 const cacheHits = log.shared_blks_hit ?? log.cache_hits ?? 0
153
154 queryDataMap.set(time, {
155 time: parseFloat(String(meanTime)),
156 rows_read: parseFloat(String(rowsRead)),
157 calls: parseFloat(String(calls)),
158 cache_hits: parseFloat(String(cacheHits)),
159 })
160 })
161
162 return queryDataMap
163 }, [currentSelectedQuery, parsedLogs])
164
165 const mergedChartData = useMemo(() => {
166 if (!querySpecificData || !currentSelectedQuery) {
167 return transformedChartData
168 }
169
170 return transformedChartData.map((dataPoint) => {
171 const queryData = querySpecificData.get(dataPoint.period_start)
172
173 return {
174 ...dataPoint,
175 selected_query_time:
176 queryData?.time !== undefined
177 ? selectedMetric === 'query_latency'
178 ? queryData.time / 1000
179 : queryData.time
180 : null,
181 selected_query_rows_read: queryData?.rows_read !== undefined ? queryData.rows_read : null,
182 selected_query_calls: queryData?.calls !== undefined ? queryData.calls : null,
183 selected_query_cache_hits:
184 queryData?.cache_hits !== undefined ? queryData.cache_hits : null,
185 }
186 })
187 }, [transformedChartData, querySpecificData, currentSelectedQuery, selectedMetric])
188
189 const getChartAttributes = useMemo((): MultiAttribute[] => {
190 const attributeMap: Record<string, MultiAttribute[]> = {
191 query_latency: [
192 {
193 attribute: 'p50_time',
194 label: 'p50',
195 provider: 'logs',
196 type: 'line',
197 color: { light: '#8B5CF6', dark: '#8B5CF6' },
198 },
199 {
200 attribute: 'p95_time',
201 label: 'p95',
202 provider: 'logs',
203 type: 'line',
204 color: { light: '#65BCD9', dark: '#65BCD9' },
205 },
206 ],
207 rows_read: [
208 {
209 attribute: 'rows_read',
210 label: 'Rows Read',
211 provider: 'logs',
212 },
213 ],
214 calls: [
215 {
216 attribute: 'calls',
217 label: 'Calls',
218 provider: 'logs',
219 },
220 ],
221 cache_hits: [
222 {
223 attribute: 'cache_hits',
224 label: 'Cache Hits',
225 provider: 'logs',
226 type: 'line',
227 color: { light: '#10B981', dark: '#10B981' },
228 },
229 ],
230 }
231
232 const baseAttributes = attributeMap[selectedMetric] || []
233
234 if (currentSelectedQuery && querySpecificData) {
235 const dimmedBaseAttributes = baseAttributes.map((attr) => ({
236 ...attr,
237 color: attr.color
238 ? { light: attr.color.light + '4D', dark: attr.color.dark + '4D' }
239 : attr.color,
240 }))
241
242 const selectedQueryAttributes: Record<string, MultiAttribute> = {
243 query_latency: {
244 attribute: 'selected_query_time',
245 label: 'Selected Query',
246 provider: 'logs',
247 type: 'line',
248 color: { light: '#10B981', dark: '#10B981' },
249 strokeWidth: 3,
250 },
251 rows_read: {
252 attribute: 'selected_query_rows_read',
253 label: 'Selected Query',
254 provider: 'logs',
255 type: 'line',
256 color: { light: '#F59E0B', dark: '#F59E0B' },
257 strokeWidth: 3,
258 },
259 calls: {
260 attribute: 'selected_query_calls',
261 label: 'Selected Query',
262 provider: 'logs',
263 type: 'line',
264 color: { light: '#EC4899', dark: '#EC4899' },
265 strokeWidth: 3,
266 },
267 cache_hits: {
268 attribute: 'selected_query_cache_hits',
269 label: 'Selected Query',
270 provider: 'logs',
271 type: 'line',
272 color: { light: '#8B5CF6', dark: '#8B5CF6' },
273 strokeWidth: 3,
274 },
275 }
276
277 const selectedQueryAttr = selectedQueryAttributes[selectedMetric]
278 if (selectedQueryAttr) {
279 return [...dimmedBaseAttributes, selectedQueryAttr]
280 }
281 }
282
283 return baseAttributes
284 }, [selectedMetric, currentSelectedQuery, querySpecificData])
285
286 const updateDateRange = (from: string, to: string) => {
287 onDateRangeChange?.(from, to)
288 }
289
290 const getYAxisFormatter = useMemo(() => {
291 if (selectedMetric === 'query_latency') {
292 return formatTimeValue
293 }
294 return formatNumberValue
295 }, [selectedMetric])
296
297 return (
298 <div className="bg-surface-200 border-t">
299 <Tabs_Shadcn_
300 value={selectedMetric}
301 onValueChange={(value) => setSelectedMetric(value as string)}
302 className="w-full"
303 >
304 <TabsList_Shadcn_ className="flex justify-start rounded-none gap-x-4 border-b mt-0! pt-0 px-6">
305 {QUERY_PERFORMANCE_CHART_TABS.map((tab) => (
306 <TabsTrigger_Shadcn_
307 key={tab.id}
308 value={tab.id}
309 className="flex items-center gap-2 text-xs py-3 border-b font-mono uppercase"
310 >
311 {tab.label}
312 </TabsTrigger_Shadcn_>
313 ))}
314 </TabsList_Shadcn_>
315
316 <TabsContent_Shadcn_ value={selectedMetric} className="bg-surface-200 mt-0 h-inherit">
317 <div className="w-full flex items-center justify-center min-h-[282px]">
318 {isLoading ? (
319 <Loader2 size={20} className="animate-spin text-foreground-lighter" />
320 ) : error ? (
321 <p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
322 Error loading chart data
323 </p>
324 ) : (
325 <div className="w-full flex flex-col h-full px-6 py-4">
326 <div className="flex gap-6 mb-4">
327 {currentMetrics.map((metric, index) => (
328 <QueryMetricBlock key={index} label={metric.label} value={metric.value} />
329 ))}
330 </div>
331 <ComposedChart
332 data={mergedChartData as any}
333 attributes={getChartAttributes}
334 yAxisKey={getChartAttributes[0]?.attribute || ''}
335 xAxisKey="period_start"
336 title=""
337 customDateFormat="MMM D, YYYY hh:mm A"
338 hideChartType={true}
339 hideHighlightArea={true}
340 showTooltip={true}
341 showGrid={true}
342 showLegend={true}
343 showTotal={false}
344 showMaxValue={false}
345 updateDateRange={updateDateRange}
346 YAxisProps={{
347 tick: true,
348 width: 60,
349 tickFormatter: getYAxisFormatter,
350 }}
351 xAxisIsDate={true}
352 className="mt-2"
353 />
354 </div>
355 )}
356 </div>
357 </TabsContent_Shadcn_>
358 </Tabs_Shadcn_>
359 </div>
360 )
361}