useQueryInsightsTableColumns.tsx 22 KB

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