QueryPerformanceGrid.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. import { useParams } from 'common'
  2. import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, TextSearch } from 'lucide-react'
  3. import { parseAsArrayOf, parseAsJson, parseAsString, useQueryStates } from 'nuqs'
  4. import { UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
  5. import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
  6. import {
  7. Button,
  8. cn,
  9. DropdownMenu,
  10. DropdownMenuContent,
  11. DropdownMenuItem,
  12. DropdownMenuTrigger,
  13. Sheet,
  14. SheetContent,
  15. SheetDescription,
  16. SheetTitle,
  17. Tabs_Shadcn_,
  18. TabsContent_Shadcn_,
  19. TabsList_Shadcn_,
  20. TabsTrigger_Shadcn_,
  21. } from 'ui'
  22. import { Admonition } from 'ui-patterns'
  23. import { CodeBlock } from 'ui-patterns/CodeBlock'
  24. import { InfoTooltip } from 'ui-patterns/info-tooltip'
  25. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  26. import { useQueryPerformanceSort } from './hooks/useQueryPerformanceSort'
  27. import {
  28. hasIndexRecommendations,
  29. queryInvolvesProtectedSchemas,
  30. } from './IndexAdvisor/index-advisor.utils'
  31. import { IndexSuggestionIcon } from './IndexAdvisor/IndexSuggestionIcon'
  32. import { QueryDetail } from './QueryDetail'
  33. import { QueryIndexes } from './QueryIndexes'
  34. import {
  35. QUERY_PERFORMANCE_COLUMNS,
  36. QUERY_PERFORMANCE_ROLE_DESCRIPTION,
  37. } from './QueryPerformance.constants'
  38. import { QueryPerformanceRow } from './QueryPerformance.types'
  39. import { formatDuration } from './QueryPerformance.utils'
  40. import { NumericFilter } from '@/components/interfaces/Reports/v2/ReportsNumericFilter'
  41. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  42. interface QueryPerformanceGridProps {
  43. aggregatedData: QueryPerformanceRow[]
  44. isLoading: boolean
  45. error?: string | null
  46. currentSelectedQuery?: string | null
  47. onCurrentSelectQuery?: (query: string) => void
  48. onRetry?: () => void
  49. onScroll?: (event: UIEvent<HTMLDivElement>) => void
  50. }
  51. const calculateTimeConsumedWidth = (data: QueryPerformanceRow[]) => {
  52. if (!data || data.length === 0) return 150
  53. let maxWidth = 150
  54. data.forEach((row) => {
  55. const percentage = row.prop_total_time || 0
  56. const totalTime = row.total_time || 0
  57. if (percentage && totalTime) {
  58. const percentageText = `${percentage.toFixed(1)}%`
  59. const durationText = formatDuration(totalTime)
  60. const fullText = `${percentageText} / ${durationText}`
  61. const estimatedWidth = fullText.length * 8 + 40
  62. maxWidth = Math.max(maxWidth, estimatedWidth)
  63. }
  64. })
  65. return Math.min(maxWidth, 300)
  66. }
  67. export const QueryPerformanceGrid = ({
  68. aggregatedData,
  69. isLoading,
  70. error,
  71. currentSelectedQuery,
  72. onCurrentSelectQuery,
  73. onRetry,
  74. onScroll,
  75. }: QueryPerformanceGridProps) => {
  76. const { sort, setSortConfig } = useQueryPerformanceSort()
  77. const gridRef = useRef<DataGridHandle>(null)
  78. const { sort: urlSort, order } = useParams()
  79. const [{ search, roles, callsFilter }] = useQueryStates({
  80. search: parseAsString.withDefault(''),
  81. roles: parseAsArrayOf(parseAsString).withDefault([]),
  82. callsFilter: parseAsJson<NumericFilter | null>(
  83. (value) => value as NumericFilter | null
  84. ).withDefault({
  85. operator: '>=',
  86. value: 0,
  87. } as NumericFilter),
  88. })
  89. const dataGridContainerRef = useRef<HTMLDivElement>(null)
  90. const [view, setView] = useState<'details' | 'suggestion'>('details')
  91. const [selectedRow, setSelectedRow] = useState<number>()
  92. const columns = QUERY_PERFORMANCE_COLUMNS.map((col) => {
  93. const nonSortableColumns = ['query']
  94. const result: Column<any> = {
  95. key: col.id,
  96. name: col.name,
  97. cellClass: `column-${col.id}`,
  98. resizable: true,
  99. minWidth:
  100. col.id === 'prop_total_time'
  101. ? calculateTimeConsumedWidth((aggregatedData as any) ?? [])
  102. : (col.minWidth ?? 120),
  103. sortable: !nonSortableColumns.includes(col.id),
  104. headerCellClass: 'first:pl-6 cursor-pointer',
  105. renderHeaderCell: () => {
  106. const isSortable = !nonSortableColumns.includes(col.id)
  107. return (
  108. <div className="flex items-center justify-between text-xs w-full">
  109. <div className="flex items-center gap-x-2">
  110. <p className="text-foreground! font-medium">{col.name}</p>
  111. {col.description && (
  112. <p className="text-foreground-lighter font-normal">{col.description}</p>
  113. )}
  114. </div>
  115. {isSortable && (
  116. <DropdownMenu>
  117. <DropdownMenuTrigger asChild>
  118. <Button
  119. type="text"
  120. size="tiny"
  121. className="p-1 h-5 w-5 shrink-0"
  122. icon={<ChevronDown size={14} className="text-foreground-muted" />}
  123. onClick={(e) => e.stopPropagation()}
  124. />
  125. </DropdownMenuTrigger>
  126. <DropdownMenuContent align="end" className="w-48">
  127. <DropdownMenuItem
  128. onClick={() => {
  129. setSortConfig(col.id, 'asc')
  130. }}
  131. className={cn(
  132. 'flex gap-2',
  133. sort?.column === col.id && sort?.order === 'asc' && 'text-foreground'
  134. )}
  135. >
  136. <ArrowUp size={14} />
  137. Sort Ascending
  138. </DropdownMenuItem>
  139. <DropdownMenuItem
  140. onClick={() => {
  141. setSortConfig(col.id, 'desc')
  142. }}
  143. className={cn(
  144. 'flex gap-2',
  145. sort?.column === col.id && sort?.order === 'desc' && 'text-foreground'
  146. )}
  147. >
  148. <ArrowDown size={14} />
  149. Sort Descending
  150. </DropdownMenuItem>
  151. </DropdownMenuContent>
  152. </DropdownMenu>
  153. )}
  154. </div>
  155. )
  156. },
  157. renderCell: (props) => {
  158. const value = props.row?.[col.id]
  159. if (col.id === 'query') {
  160. return (
  161. <div className="w-full flex items-center gap-x-3 group">
  162. <div className="shrink-0 w-4">
  163. {hasIndexRecommendations(props.row.index_advisor_result, true) && (
  164. <IndexSuggestionIcon
  165. indexAdvisorResult={props.row.index_advisor_result}
  166. onClickIcon={() => {
  167. setSelectedRow(props.rowIdx)
  168. setView('suggestion')
  169. gridRef.current?.scrollToCell({ idx: 0, rowIdx: props.rowIdx })
  170. }}
  171. />
  172. )}
  173. </div>
  174. <CodeBlock
  175. language="pgsql"
  176. className="bg-transparent! p-0! m-0! border-none! truncate! whitespace-nowrap! w-full! pr-20! pointer-events-none"
  177. wrapperClassName="flex-1 min-w-0 max-w-full overflow-hidden!"
  178. hideLineNumbers
  179. hideCopy
  180. value={typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''}
  181. wrapLines={false}
  182. />
  183. {onCurrentSelectQuery && (
  184. <ButtonTooltip
  185. tooltip={{ content: { text: 'Query details' } }}
  186. icon={<ArrowRight size={14} />}
  187. size="tiny"
  188. type="default"
  189. onClick={(e) => {
  190. e.stopPropagation()
  191. setSelectedRow(props.rowIdx)
  192. setView('details')
  193. gridRef.current?.scrollToCell({ idx: 0, rowIdx: props.rowIdx })
  194. }}
  195. className="p-1 shrink-0 -translate-x-2 group-hover:flex hidden"
  196. />
  197. )}
  198. </div>
  199. )
  200. }
  201. const isTime = col.name.includes('time')
  202. const formattedValue =
  203. !!value && typeof value === 'number' && !isNaN(value) && isFinite(value)
  204. ? isTime
  205. ? `${value.toFixed(0).toLocaleString()}ms`
  206. : value.toLocaleString()
  207. : ''
  208. if (col.id === 'prop_total_time') {
  209. const percentage = props.row.prop_total_time || 0
  210. const totalTime = props.row.total_time || 0
  211. const fillWidth = Math.min(percentage, 100)
  212. return (
  213. <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
  214. <div
  215. className="absolute inset-0 bg-foreground transition-all duration-200 z-0"
  216. style={{
  217. width: `${fillWidth}%`,
  218. opacity: 0.04,
  219. }}
  220. />
  221. {percentage && totalTime ? (
  222. <span className="flex items-center justify-end gap-x-1.5">
  223. <span
  224. className={cn(percentage.toFixed(1) === '0.0' && 'text-foreground-lighter')}
  225. >
  226. {percentage.toFixed(1)}%
  227. </span>{' '}
  228. <span className="text-muted">/</span>
  229. <span
  230. className={cn(
  231. formatDuration(totalTime) === '0.00s' && 'text-foreground-lighter'
  232. )}
  233. >
  234. {formatDuration(totalTime)}
  235. </span>
  236. </span>
  237. ) : (
  238. <p className="text-muted">&ndash;</p>
  239. )}
  240. </div>
  241. )
  242. }
  243. if (col.id === 'calls') {
  244. return (
  245. <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
  246. {typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
  247. <p className={cn(value === 0 && 'text-foreground-lighter')}>
  248. {value.toLocaleString()}
  249. </p>
  250. ) : (
  251. <p className="text-muted">&ndash;</p>
  252. )}
  253. </div>
  254. )
  255. }
  256. if (col.id === 'max_time' || col.id === 'mean_time' || col.id === 'min_time') {
  257. return (
  258. <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
  259. {typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
  260. <p className={cn(value.toFixed(0) === '0' && 'text-foreground-lighter')}>
  261. {Math.round(value).toLocaleString()}ms
  262. </p>
  263. ) : (
  264. <p className="text-muted">&ndash;</p>
  265. )}
  266. </div>
  267. )
  268. }
  269. if (col.id === 'rows_read') {
  270. return (
  271. <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
  272. {typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
  273. <p className={cn(value === 0 && 'text-foreground-lighter')}>
  274. {value.toLocaleString()}
  275. </p>
  276. ) : (
  277. <p className="text-muted">&ndash;</p>
  278. )}
  279. </div>
  280. )
  281. }
  282. if (col.id === 'cache_hit_rate') {
  283. const numericValue = typeof value === 'number' ? value : parseFloat(value)
  284. return (
  285. <div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
  286. {typeof numericValue === 'number' &&
  287. !isNaN(numericValue) &&
  288. isFinite(numericValue) ? (
  289. <p className={cn(numericValue.toFixed(2) === '0.00' && 'text-foreground-lighter')}>
  290. {numericValue.toLocaleString(undefined, {
  291. minimumFractionDigits: 2,
  292. maximumFractionDigits: 2,
  293. })}
  294. %
  295. </p>
  296. ) : (
  297. <p className="text-muted">&ndash;</p>
  298. )}
  299. </div>
  300. )
  301. }
  302. if (col.id === 'rolname') {
  303. return (
  304. <div className="w-full flex flex-col justify-center">
  305. {value ? (
  306. <span className="flex items-center gap-x-1">
  307. <p className="font-mono text-xs">{value}</p>
  308. <InfoTooltip align="end" alignOffset={-12} className="w-56">
  309. {
  310. QUERY_PERFORMANCE_ROLE_DESCRIPTION.find((role) => role.name === value)
  311. ?.description
  312. }
  313. </InfoTooltip>
  314. </span>
  315. ) : (
  316. <p className="text-muted">&ndash;</p>
  317. )}
  318. </div>
  319. )
  320. }
  321. if (col.id === 'application_name') {
  322. return (
  323. <div className="w-full flex flex-col justify-center">
  324. {value ? (
  325. <p className="font-mono text-xs">{value}</p>
  326. ) : (
  327. <p className="text-muted">&ndash;</p>
  328. )}
  329. </div>
  330. )
  331. }
  332. return (
  333. <div className="w-full flex flex-col gap-y-0.5 justify-center text-xs">
  334. <p>{formattedValue}</p>
  335. </div>
  336. )
  337. },
  338. }
  339. return result
  340. })
  341. const reportData = useMemo(() => {
  342. let data = [...aggregatedData]
  343. if (search && typeof search === 'string' && search.length > 0) {
  344. data = data.filter((row) => row.query.toLowerCase().includes(search.toLowerCase()))
  345. }
  346. if (roles && Array.isArray(roles) && roles.length > 0) {
  347. data = data.filter((row) => row.rolname && roles.includes(row.rolname))
  348. }
  349. if (callsFilter) {
  350. const { operator, value } = callsFilter
  351. data = data.filter((row) => {
  352. const calls = row.calls || 0
  353. switch (operator) {
  354. case '=':
  355. return calls === value
  356. case '>=':
  357. return calls >= value
  358. case '<=':
  359. return calls <= value
  360. case '>':
  361. return calls > value
  362. case '<':
  363. return calls < value
  364. case '!=':
  365. return calls !== value
  366. default:
  367. return true
  368. }
  369. })
  370. }
  371. if (sort?.column === 'prop_total_time') {
  372. data.sort((a, b) => {
  373. const aValue = a.prop_total_time || 0
  374. const bValue = b.prop_total_time || 0
  375. return sort.order === 'asc' ? aValue - bValue : bValue - aValue
  376. })
  377. } else if (sort?.column && sort.column !== 'query') {
  378. data.sort((a, b) => {
  379. const aValue = a[sort.column as keyof QueryPerformanceRow] || 0
  380. const bValue = b[sort.column as keyof QueryPerformanceRow] || 0
  381. if (typeof aValue === 'number' && typeof bValue === 'number') {
  382. return sort.order === 'asc' ? aValue - bValue : bValue - aValue
  383. }
  384. return 0
  385. })
  386. }
  387. return data
  388. }, [aggregatedData, sort, search, roles, callsFilter])
  389. useEffect(() => {
  390. setSelectedRow(undefined)
  391. }, [search, roles, urlSort, order, callsFilter])
  392. const handleKeyDown = useCallback(
  393. (event: KeyboardEvent) => {
  394. if (!reportData.length || selectedRow === undefined) return
  395. if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
  396. event.stopPropagation()
  397. let nextIndex = selectedRow
  398. if (event.key === 'ArrowUp' && selectedRow > 0) {
  399. nextIndex = selectedRow - 1
  400. } else if (event.key === 'ArrowDown' && selectedRow < reportData.length - 1) {
  401. nextIndex = selectedRow + 1
  402. }
  403. if (nextIndex !== selectedRow) {
  404. setSelectedRow(nextIndex)
  405. gridRef.current?.scrollToCell({ idx: 0, rowIdx: nextIndex })
  406. const rowQuery = reportData[nextIndex]?.query ?? ''
  407. if (!rowQuery.trim().toLowerCase().startsWith('select')) {
  408. setView('details')
  409. }
  410. }
  411. },
  412. [reportData, selectedRow]
  413. )
  414. useEffect(() => {
  415. // run before RDG to prevent header focus (the third param: true)
  416. window.addEventListener('keydown', handleKeyDown, true)
  417. return () => {
  418. window.removeEventListener('keydown', handleKeyDown, true)
  419. }
  420. }, [handleKeyDown])
  421. const isSelectQuery = (query: string | undefined): boolean => {
  422. if (!query) return false
  423. const formattedQuery = query.trim().toLowerCase()
  424. return (
  425. formattedQuery.startsWith('select') ||
  426. formattedQuery.startsWith('with pgrst_source') ||
  427. formattedQuery.startsWith('with pgrst_payload')
  428. )
  429. }
  430. useEffect(() => {
  431. if (selectedRow !== undefined && view === 'suggestion') {
  432. const query = reportData[selectedRow]?.query
  433. if (!isSelectQuery(query)) {
  434. setView('details')
  435. }
  436. }
  437. }, [selectedRow, view, reportData])
  438. if (error) {
  439. return (
  440. <div className="relative flex grow bg-alternative min-h-0">
  441. <div className="flex-1 min-w-0 p-6">
  442. <Admonition
  443. type="destructive"
  444. title="Failed to load query performance data"
  445. description={error}
  446. >
  447. {onRetry && (
  448. <div className="mt-4">
  449. <Button type="default" onClick={onRetry}>
  450. Try again
  451. </Button>
  452. </div>
  453. )}
  454. </Admonition>
  455. </div>
  456. </div>
  457. )
  458. }
  459. const selectedQuery = selectedRow !== undefined ? reportData[selectedRow]?.query : undefined
  460. const isProtectedSchemaQuery = queryInvolvesProtectedSchemas(selectedQuery)
  461. const canShowIndexesTab = isSelectQuery(selectedQuery) && !isProtectedSchemaQuery
  462. return (
  463. <div className="relative flex grow bg-alternative min-h-0">
  464. <div ref={dataGridContainerRef} className="flex-1 min-w-0 overflow-x-auto">
  465. <DataGrid
  466. ref={gridRef}
  467. style={{ height: '100%' }}
  468. className={cn('flex-1 grow h-full')}
  469. rowHeight={44}
  470. headerRowHeight={36}
  471. columns={columns}
  472. rows={reportData}
  473. onScroll={onScroll}
  474. rowClass={(_, idx) => {
  475. const isSelected = idx === selectedRow
  476. const query = reportData[idx]?.query
  477. const isCharted = currentSelectedQuery ? currentSelectedQuery === query : false
  478. const hasRecommendations = hasIndexRecommendations(
  479. reportData[idx]?.index_advisor_result,
  480. true
  481. )
  482. return [
  483. `${isSelected ? (hasRecommendations ? 'bg-warning/10 hover:bg-warning/20' : 'bg-surface-300 dark:bg-surface-300') : hasRecommendations ? 'bg-warning/10 hover:bg-warning/20' : 'bg-200 hover:bg-surface-200'} cursor-pointer`,
  484. `${isSelected ? (hasRecommendations ? '[&>div:first-child]:border-l-4 border-l-warning [&>div]:border-l-warning' : '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground!') : ''}`,
  485. `${isCharted ? 'bg-surface-200 dark:bg-surface-200' : ''}`,
  486. `${isCharted ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-brand' : ''}`,
  487. '[&>.rdg-cell]:box-border [&>.rdg-cell]:outline-hidden [&>.rdg-cell]:shadow-none',
  488. '[&>.rdg-cell.column-prop_total_time]:relative',
  489. ].join(' ')
  490. }}
  491. renderers={{
  492. renderRow(idx, props) {
  493. return (
  494. <Row
  495. {...props}
  496. key={`qp-row-${props.rowIdx}`}
  497. onClick={(event) => {
  498. event.stopPropagation()
  499. if (typeof idx === 'number' && idx >= 0) {
  500. if (onCurrentSelectQuery) {
  501. const query = reportData[idx]?.query
  502. if (query) {
  503. onCurrentSelectQuery(query)
  504. }
  505. } else {
  506. setSelectedRow(idx)
  507. const hasRecommendations = hasIndexRecommendations(
  508. reportData[idx]?.index_advisor_result,
  509. true
  510. )
  511. setView(hasRecommendations ? 'suggestion' : 'details')
  512. gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
  513. }
  514. }
  515. }}
  516. />
  517. )
  518. },
  519. noRowsFallback: isLoading ? (
  520. <div className="absolute top-14 px-6 w-full">
  521. <GenericSkeletonLoader />
  522. </div>
  523. ) : (
  524. <div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
  525. <TextSearch className="text-foreground-muted" strokeWidth={1} />
  526. <div className="text-center">
  527. <p className="text-foreground">No queries detected</p>
  528. <p className="text-foreground-light">
  529. There are no actively running queries that match the criteria
  530. </p>
  531. </div>
  532. </div>
  533. ),
  534. }}
  535. />
  536. </div>
  537. <Sheet
  538. open={selectedRow !== undefined}
  539. onOpenChange={(open) => {
  540. if (!open) {
  541. setSelectedRow(undefined)
  542. }
  543. }}
  544. modal={false}
  545. >
  546. <SheetTitle className="sr-only">Query details</SheetTitle>
  547. <SheetDescription className="sr-only">
  548. Query Performance Details &amp; Indexes
  549. </SheetDescription>
  550. <SheetContent
  551. side="right"
  552. className="flex flex-col h-full bg-studio border-l lg:w-[calc(100vw-802px)]! max-w-[700px] w-full"
  553. hasOverlay={false}
  554. onInteractOutside={(event) => {
  555. if (dataGridContainerRef.current?.contains(event.target as Node)) {
  556. event.preventDefault()
  557. }
  558. }}
  559. >
  560. <Tabs_Shadcn_
  561. value={view}
  562. className="flex flex-col h-full"
  563. onValueChange={(value: any) => setView(value)}
  564. >
  565. <div className="px-5 border-b">
  566. <TabsList_Shadcn_ className="px-0 flex gap-x-4 min-h-[46px] border-b-0 [&>button]:h-[47px]">
  567. <TabsTrigger_Shadcn_
  568. value="details"
  569. className="px-0 pb-0 data-[state=active]:bg-transparent shadow-none!"
  570. >
  571. Query details
  572. </TabsTrigger_Shadcn_>
  573. {selectedRow !== undefined && canShowIndexesTab && (
  574. <TabsTrigger_Shadcn_
  575. value="suggestion"
  576. className="px-0 pb-0 data-[state=active]:bg-transparent shadow-none!"
  577. >
  578. Indexes
  579. </TabsTrigger_Shadcn_>
  580. )}
  581. </TabsList_Shadcn_>
  582. </div>
  583. <TabsContent_Shadcn_ value="details" className="mt-0 grow min-h-0 overflow-y-auto">
  584. {selectedRow !== undefined && (
  585. <QueryDetail
  586. selectedRow={reportData[selectedRow]}
  587. onClickViewSuggestion={() => setView('suggestion')}
  588. onClose={() => setSelectedRow(undefined)}
  589. />
  590. )}
  591. </TabsContent_Shadcn_>
  592. {selectedRow !== undefined && canShowIndexesTab && (
  593. <TabsContent_Shadcn_ value="suggestion" className="mt-0 grow min-h-0 overflow-y-auto">
  594. <QueryIndexes selectedRow={reportData[selectedRow]} />
  595. </TabsContent_Shadcn_>
  596. )}
  597. </Tabs_Shadcn_>
  598. </SheetContent>
  599. </Sheet>
  600. </div>
  601. )
  602. }