QueryPerformanceChart.tsx361 lines · main
| 1 | import { Loader2 } from 'lucide-react' |
| 2 | import { useMemo, useState } from 'react' |
| 3 | import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' |
| 4 | |
| 5 | import type { ChartDataPoint } from '../QueryInsights/QueryInsights.types' |
| 6 | import { QUERY_PERFORMANCE_CHART_TABS } from './QueryPerformance.constants' |
| 7 | import { ComposedChart } from '@/components/ui/Charts/ComposedChart' |
| 8 | import type { MultiAttribute } from '@/components/ui/Charts/ComposedChart.utils' |
| 9 | |
| 10 | interface 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 | |
| 24 | const 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 | |
| 39 | const formatTimeValue = (value: number): string => { |
| 40 | if (value >= 1000) { |
| 41 | return `${(value / 1000).toFixed(1)}s` |
| 42 | } |
| 43 | return `${value.toFixed(1)}ms` |
| 44 | } |
| 45 | |
| 46 | const formatNumberValue = (value: number): string => { |
| 47 | return value.toLocaleString() |
| 48 | } |
| 49 | |
| 50 | export 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 | } |