useQueryInsightsTableColumns.tsx596 lines · main
1import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, ExternalLink, ScanSearch } from 'lucide-react'
2import { useMemo, type RefObject } from 'react'
3// eslint-disable-next-line no-restricted-imports
4import { type Column, type DataGridHandle } from 'react-data-grid'
5import {
6 Button,
7 cn,
8 DropdownMenu,
9 DropdownMenuContent,
10 DropdownMenuItem,
11 DropdownMenuTrigger,
12 Tooltip,
13 TooltipContent,
14 TooltipTrigger,
15} from 'ui'
16import { CodeBlock } from 'ui-patterns/CodeBlock'
17import { InfoTooltip } from 'ui-patterns/info-tooltip'
18
19import { buildQueryInsightFixPrompt } from '../../QueryPerformance/QueryPerformance.ai'
20import { QUERY_PERFORMANCE_ROLE_DESCRIPTION } from '../../QueryPerformance/QueryPerformance.constants'
21import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types'
22import {
23 ISSUE_DOT_COLORS,
24 ISSUE_ICONS,
25 NON_SORTABLE_COLUMNS,
26 QUERY_INSIGHTS_EXPLORER_COLUMNS,
27} from '../QueryInsightsTable/QueryInsightsTable.constants'
28import {
29 formatDuration,
30 getColumnName,
31 getTableName,
32} from '../QueryInsightsTable/QueryInsightsTable.utils'
33import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
34import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
35
36interface UseQueryInsightsTableColumnsParams {
37 sort: { column: string; order: 'asc' | 'desc' }
38 setSort: (config: { column: string; order: 'asc' | 'desc' } | null) => void
39 timeConsumedWidth: number
40 triageQueryColWidth: number
41 gridRef: RefObject<DataGridHandle | null>
42 setSelectedRow: (idx: number) => void
43 setSelectedTriageRow: (idx: number | undefined) => void
44 setSheetView: (view: 'details' | 'indexes' | 'explain') => void
45 handleGoToLogs: () => void
46 handleAiSuggestedFix: (item: ClassifiedQuery) => void
47}
48
49export function useQueryInsightsTableColumns({
50 sort,
51 setSort,
52 timeConsumedWidth,
53 triageQueryColWidth,
54 gridRef,
55 setSelectedRow,
56 setSelectedTriageRow,
57 setSheetView,
58 handleGoToLogs,
59 handleAiSuggestedFix,
60}: UseQueryInsightsTableColumnsParams): {
61 columns: Column<ClassifiedQuery>[]
62 triageColumns: Column<ClassifiedQuery>[]
63} {
64 const columns = useMemo(() => {
65 return QUERY_INSIGHTS_EXPLORER_COLUMNS.map((col) => {
66 const isSortable = !NON_SORTABLE_COLUMNS.includes(col.id as never)
67
68 const result: Column<ClassifiedQuery> = {
69 key: col.id,
70 name: col.name,
71 cellClass: `column-${col.id}`,
72 resizable: true,
73 minWidth: col.id === 'prop_total_time' ? timeConsumedWidth : (col.minWidth ?? 120),
74 sortable: isSortable,
75 headerCellClass: 'first:pl-6 cursor-pointer',
76 renderHeaderCell: () => {
77 return (
78 <div className="flex items-center justify-between text-xs w-full">
79 <div className="flex items-center gap-x-2">
80 <p className="text-foreground! font-medium">{col.name}</p>
81 {col.description && (
82 <p className="text-foreground-lighter font-normal">{col.description}</p>
83 )}
84 </div>
85
86 {isSortable && (
87 <DropdownMenu>
88 <DropdownMenuTrigger asChild>
89 <Button
90 type="text"
91 size="tiny"
92 className="p-1 h-5 w-5 shrink-0"
93 icon={<ChevronDown size={14} className="text-foreground-muted" />}
94 onClick={(e) => e.stopPropagation()}
95 />
96 </DropdownMenuTrigger>
97 <DropdownMenuContent align="end" className="w-48">
98 <DropdownMenuItem
99 onClick={() => setSort({ column: col.id, order: 'asc' })}
100 className={cn(
101 'flex gap-2',
102 sort?.column === col.id && sort?.order === 'asc' && 'text-foreground'
103 )}
104 >
105 <ArrowUp size={14} />
106 Sort Ascending
107 </DropdownMenuItem>
108 <DropdownMenuItem
109 onClick={() => setSort({ column: col.id, order: 'desc' })}
110 className={cn(
111 'flex gap-2',
112 sort?.column === col.id && sort?.order === 'desc' && 'text-foreground'
113 )}
114 >
115 <ArrowDown size={14} />
116 Sort Descending
117 </DropdownMenuItem>
118 </DropdownMenuContent>
119 </DropdownMenu>
120 )}
121 </div>
122 )
123 },
124 renderCell: (props) => {
125 const row = props.row
126 const value = row[col.id]
127
128 if (col.id === 'query') {
129 const IssueIcon = row.issueType ? ISSUE_ICONS[row.issueType] : null
130 return (
131 <div className="w-full flex items-center gap-x-3 group">
132 <div className="shrink-0 w-6">
133 {row.issueType && IssueIcon && (
134 <Tooltip>
135 <TooltipTrigger asChild>
136 <div
137 className={cn(
138 'h-6 w-6 rounded-full border flex items-center justify-center cursor-default',
139 ISSUE_DOT_COLORS[row.issueType]?.border,
140 ISSUE_DOT_COLORS[row.issueType]?.background
141 )}
142 >
143 <IssueIcon size={14} className={ISSUE_DOT_COLORS[row.issueType].color} />
144 </div>
145 </TooltipTrigger>
146 {row.hint && (
147 <TooltipContent side="top" className="max-w-[260px]">
148 {row.hint}
149 </TooltipContent>
150 )}
151 </Tooltip>
152 )}
153 </div>
154 <CodeBlock
155 language="pgsql"
156 className="bg-transparent! p-0! m-0! border-none! whitespace-nowrap! [&>code]:whitespace-nowrap! [&>code]:wrap-break-word overflow-visible! truncate! w-full! pr-20! pointer-events-none"
157 wrapperClassName="max-w-full! flex-1"
158 hideLineNumbers
159 hideCopy
160 value={typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''}
161 wrapLines={false}
162 />
163 <ButtonTooltip
164 tooltip={{ content: { text: 'Query details' } }}
165 icon={<ArrowRight size={14} />}
166 size="tiny"
167 type="default"
168 onClick={(e: React.MouseEvent) => {
169 e.stopPropagation()
170 setSelectedRow(props.rowIdx)
171 setSheetView('details')
172 gridRef.current?.scrollToCell({ idx: 0, rowIdx: props.rowIdx })
173 }}
174 className="p-1 shrink-0 -translate-x-2 group-hover:flex hidden"
175 />
176 </div>
177 )
178 }
179
180 if (col.id === 'prop_total_time') {
181 const percentage = row.prop_total_time || 0
182 const totalTime = row.total_time || 0
183 const fillWidth = Math.min(percentage, 100)
184 return (
185 <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
186 <div
187 className="absolute inset-0 bg-foreground transition-all duration-200 z-0"
188 style={{ width: `${fillWidth}%`, opacity: 0.04 }}
189 />
190 {percentage && totalTime ? (
191 <span className="flex items-center justify-end gap-x-1.5">
192 <span
193 className={cn(percentage.toFixed(1) === '0.0' && 'text-foreground-lighter')}
194 >
195 {percentage.toFixed(1)}%
196 </span>
197 <span className="text-muted">/</span>
198 <span
199 className={cn(
200 formatDuration(totalTime) === '0ms' && 'text-foreground-lighter'
201 )}
202 >
203 {formatDuration(totalTime)}
204 </span>
205 </span>
206 ) : (
207 <p className="text-muted">&ndash;</p>
208 )}
209 </div>
210 )
211 }
212
213 if (col.id === 'calls') {
214 return (
215 <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
216 {typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
217 <p className={cn(value === 0 && 'text-foreground-lighter')}>
218 {value.toLocaleString()}
219 </p>
220 ) : (
221 <p className="text-muted">&ndash;</p>
222 )}
223 </div>
224 )
225 }
226
227 if (col.id === 'max_time' || col.id === 'mean_time' || col.id === 'min_time') {
228 return (
229 <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
230 {typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
231 <p className={cn(value.toFixed(0) === '0' && 'text-foreground-lighter')}>
232 {Math.round(value).toLocaleString()}ms
233 </p>
234 ) : (
235 <p className="text-muted">&ndash;</p>
236 )}
237 </div>
238 )
239 }
240
241 if (col.id === 'rows_read') {
242 return (
243 <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
244 {typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
245 <p className={cn(value === 0 && 'text-foreground-lighter')}>
246 {value.toLocaleString()}
247 </p>
248 ) : (
249 <p className="text-muted">&ndash;</p>
250 )}
251 </div>
252 )
253 }
254
255 if (col.id === 'cache_hit_rate') {
256 const num = typeof value === 'number' ? value : parseFloat(value ?? '0')
257 return (
258 <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
259 {typeof num === 'number' && !isNaN(num) && isFinite(num) ? (
260 <p className={cn(num.toFixed(2) === '0.00' && 'text-foreground-lighter')}>
261 {num.toLocaleString(undefined, {
262 minimumFractionDigits: 2,
263 maximumFractionDigits: 2,
264 })}
265 %
266 </p>
267 ) : (
268 <p className="text-muted">&ndash;</p>
269 )}
270 </div>
271 )
272 }
273
274 if (col.id === 'rolname') {
275 return (
276 <div className="w-full flex flex-col justify-center">
277 {value ? (
278 <span className="flex items-center gap-x-1">
279 <p className="font-mono text-xs">{value}</p>
280 <InfoTooltip align="end" alignOffset={-12} className="w-56">
281 {
282 QUERY_PERFORMANCE_ROLE_DESCRIPTION.find((r) => r.name === value)
283 ?.description
284 }
285 </InfoTooltip>
286 </span>
287 ) : (
288 <p className="text-muted">&ndash;</p>
289 )}
290 </div>
291 )
292 }
293
294 if (col.id === 'application_name') {
295 return (
296 <div className="w-full flex flex-col justify-center">
297 {value ? (
298 <p className="font-mono text-xs">{value}</p>
299 ) : (
300 <p className="text-muted">&ndash;</p>
301 )}
302 </div>
303 )
304 }
305
306 return null
307 },
308 }
309 return result
310 })
311 }, [sort, setSort, timeConsumedWidth, gridRef, setSelectedRow, setSheetView])
312
313 const triageColumns = useMemo(
314 (): Column<ClassifiedQuery>[] => [
315 {
316 key: 'query',
317 name: 'Query',
318 minWidth: triageQueryColWidth,
319 width: triageQueryColWidth,
320 resizable: true,
321 headerCellClass: 'first:pl-6 cursor-default',
322 renderHeaderCell: () => (
323 <div className="flex items-center text-xs w-full">
324 <p className="text-foreground! font-medium">Query</p>
325 </div>
326 ),
327 renderCell: (props) => {
328 const row = props.row as ClassifiedQuery
329 const IssueIcon = row.issueType ? ISSUE_ICONS[row.issueType] : null
330 return (
331 <div className="w-full flex items-center gap-x-3 group">
332 <div className="shrink-0 w-6">
333 {row.issueType && IssueIcon && (
334 <div
335 className={cn(
336 'h-6 w-6 rounded-full border flex items-center justify-center',
337 ISSUE_DOT_COLORS[row.issueType]?.border,
338 ISSUE_DOT_COLORS[row.issueType]?.background
339 )}
340 >
341 <IssueIcon size={14} className={ISSUE_DOT_COLORS[row.issueType].color} />
342 </div>
343 )}
344 </div>
345 <div className="flex-1 min-w-0">
346 <p className="text-xs font-mono text-foreground truncate">
347 {row.queryType ?? '–'}
348 {getTableName(row.query) && (
349 <>
350 {' '}
351 <span className="text-foreground-lighter">in</span> {getTableName(row.query)}
352 </>
353 )}
354 {getColumnName(row.query) && (
355 <>
356 <span className="text-foreground-lighter">,</span> {getColumnName(row.query)}
357 </>
358 )}
359 </p>
360 <p
361 className={cn(
362 'text-xs mt-0.5 font-mono truncate',
363 row.issueType === 'error' && 'text-destructive-600',
364 row.issueType === 'index' && 'text-warning-600',
365 row.issueType === 'slow' && 'text-foreground-lighter'
366 )}
367 >
368 {row.hint}
369 </p>
370 </div>
371 <ButtonTooltip
372 tooltip={{ content: { text: 'Query details' } }}
373 icon={<ArrowRight size={14} />}
374 size="tiny"
375 type="default"
376 onClick={(e: React.MouseEvent) => {
377 e.stopPropagation()
378 setSelectedTriageRow(props.rowIdx)
379 setSheetView('details')
380 }}
381 className="p-1 shrink-0 group-hover:flex hidden"
382 />
383 </div>
384 )
385 },
386 },
387 {
388 key: 'prop_total_time',
389 name: 'Time consumed',
390 minWidth: timeConsumedWidth,
391 resizable: true,
392 cellClass: 'column-prop_total_time',
393 headerCellClass: 'cursor-default',
394 renderHeaderCell: () => (
395 <div className="flex items-center text-xs w-full">
396 <p className="text-foreground! font-medium">Time consumed</p>
397 </div>
398 ),
399 renderCell: (props) => {
400 const row = props.row as ClassifiedQuery
401 const percentage = row.prop_total_time || 0
402 const totalTime = row.total_time || 0
403 const fillWidth = Math.min(percentage, 100)
404 return (
405 <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
406 <div
407 className="absolute inset-0 bg-foreground transition-all duration-200 z-0"
408 style={{ width: `${fillWidth}%`, opacity: 0.04 }}
409 />
410 {percentage && totalTime ? (
411 <span className="flex items-center justify-end gap-x-1.5">
412 <span
413 className={cn(percentage.toFixed(1) === '0.0' && 'text-foreground-lighter')}
414 >
415 {percentage.toFixed(1)}%
416 </span>
417 <span className="text-muted">/</span>
418 <span
419 className={cn(formatDuration(totalTime) === '0ms' && 'text-foreground-lighter')}
420 >
421 {formatDuration(totalTime)}
422 </span>
423 </span>
424 ) : (
425 <p className="text-muted">&ndash;</p>
426 )}
427 </div>
428 )
429 },
430 },
431 {
432 key: 'calls',
433 name: 'Calls',
434 minWidth: 90,
435 resizable: true,
436 headerCellClass: 'cursor-default',
437 renderHeaderCell: () => (
438 <div className="flex items-center text-xs w-full">
439 <p className="text-foreground! font-medium">Calls</p>
440 </div>
441 ),
442 renderCell: (props) => {
443 const value = (props.row as ClassifiedQuery).calls
444 return (
445 <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
446 {typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
447 <p className={cn(value === 0 && 'text-foreground-lighter')}>
448 {value.toLocaleString()}
449 </p>
450 ) : (
451 <p className="text-muted">&ndash;</p>
452 )}
453 </div>
454 )
455 },
456 },
457 {
458 key: 'mean_time',
459 name: 'Mean time',
460 minWidth: 90,
461 resizable: true,
462 headerCellClass: 'cursor-default',
463 renderHeaderCell: () => (
464 <div className="flex items-center text-xs w-full">
465 <p className="text-foreground! font-medium">Mean time</p>
466 </div>
467 ),
468 renderCell: (props) => {
469 const value = (props.row as ClassifiedQuery).mean_time
470 return (
471 <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
472 {typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
473 <p className={cn(value === 0 && 'text-foreground-lighter')}>
474 {formatDuration(value)}
475 </p>
476 ) : (
477 <p className="text-muted">&ndash;</p>
478 )}
479 </div>
480 )
481 },
482 },
483 {
484 key: 'actions',
485 name: 'Actions',
486 minWidth: 200,
487 resizable: false,
488 headerCellClass: 'cursor-default',
489 renderHeaderCell: () => (
490 <div className="flex items-center text-xs w-full">
491 <p className="text-foreground! font-medium">Actions</p>
492 </div>
493 ),
494 renderCell: (props) => {
495 const row = props.row as ClassifiedQuery
496 return (
497 <div className="flex items-center gap-2 justify-end w-full h-full">
498 {!row.issueType && (
499 <Button
500 type="default"
501 size="tiny"
502 onClick={(e: React.MouseEvent) => {
503 e.stopPropagation()
504 handleGoToLogs()
505 }}
506 >
507 Go to Logs
508 </Button>
509 )}
510 {row.issueType === 'index' && (
511 <div className="flex items-center" onClick={(e) => e.stopPropagation()}>
512 <Button
513 type="primary"
514 size="tiny"
515 className="rounded-r-none border-r-0"
516 onClick={() => {
517 setSelectedTriageRow(props.rowIdx)
518 setSheetView('indexes')
519 }}
520 >
521 Create Index
522 </Button>
523 <DropdownMenu>
524 <DropdownMenuTrigger asChild>
525 <Button
526 type="primary"
527 size="tiny"
528 className="rounded-l-none px-1"
529 icon={<ChevronDown size={12} />}
530 />
531 </DropdownMenuTrigger>
532 <DropdownMenuContent align="end" className="w-40">
533 <DropdownMenuItem onClick={() => handleGoToLogs()} className="gap-2">
534 <ExternalLink size={14} />
535 Go to Logs
536 </DropdownMenuItem>
537 <DropdownMenuItem
538 onClick={() => {
539 setSelectedTriageRow(props.rowIdx)
540 setSheetView('explain')
541 }}
542 className="gap-2"
543 >
544 <ScanSearch size={14} />
545 Explain
546 </DropdownMenuItem>
547 </DropdownMenuContent>
548 </DropdownMenu>
549 </div>
550 )}
551 {(row.issueType === 'error' || row.issueType === 'slow') && (
552 <div onClick={(e) => e.stopPropagation()}>
553 <AiAssistantDropdown
554 label="Fix with AI"
555 buildPrompt={() => buildQueryInsightFixPrompt(row).prompt}
556 onOpenAssistant={() => handleAiSuggestedFix(row)}
557 copyLabel="Copy Markdown"
558 additionalDropdownItems={[
559 {
560 label: 'Go to Logs',
561 icon: <ExternalLink size={14} />,
562 onClick: () => handleGoToLogs(),
563 },
564 ...(row.issueType === 'slow'
565 ? [
566 {
567 label: 'Explain',
568 icon: <ScanSearch size={14} />,
569 onClick: () => {
570 setSelectedTriageRow(props.rowIdx)
571 setSheetView('explain')
572 },
573 },
574 ]
575 : []),
576 ]}
577 />
578 </div>
579 )}
580 </div>
581 )
582 },
583 },
584 ],
585 [
586 triageQueryColWidth,
587 timeConsumedWidth,
588 handleGoToLogs,
589 handleAiSuggestedFix,
590 setSelectedTriageRow,
591 setSheetView,
592 ]
593 )
594
595 return { columns, triageColumns }
596}