| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588 |
- import { safeSql, type SafeSqlFragment } from '@supabase/pg-meta'
- import { wrapWithRollback } from '@supabase/pg-meta/src/query'
- import { useParams } from 'common'
- import { Search, TextSearch, X } from 'lucide-react'
- import { useRouter } from 'next/router'
- import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
- // eslint-disable-next-line no-restricted-imports
- import DataGrid, { DataGridHandle, Row } from 'react-data-grid'
- import { Button, cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
- import { buildQueryInsightFixPrompt } from '../../QueryPerformance/QueryPerformance.ai'
- import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types'
- import { useQueryInsightsIssues } from '../hooks/useQueryInsightsIssues'
- import { useQueryInsightsTableColumns } from '../hooks/useQueryInsightsTableColumns'
- import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types'
- import { QueryInsightsDetailSheet } from './QueryInsightsDetailSheet'
- import type { IssueFilter, Mode } from './QueryInsightsTable.types'
- import {
- formatDuration,
- getColumnName,
- getQueryType,
- getTableName,
- } from './QueryInsightsTable.utils'
- import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
- import { FilterPill } from '@/components/interfaces/QueryPerformance/components/FilterPill'
- import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
- import { FilterPopover } from '@/components/ui/FilterPopover'
- import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle'
- import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
- import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
- interface QueryInsightsTableProps {
- data: QueryPerformanceRow[]
- isLoading: boolean
- currentSelectedQuery?: string | null
- onCurrentSelectQuery?: (query: string | null) => void
- }
- export const QueryInsightsTable = ({
- data,
- isLoading,
- currentSelectedQuery,
- onCurrentSelectQuery,
- }: QueryInsightsTableProps) => {
- const [mode, setMode] = useState<Mode>('triage')
- const [filter, setFilter] = useState<IssueFilter>('all')
- const [
- { search: urlSearch, sort: urlSortCol, order: urlSortOrder, source: urlSource },
- setQueryStates,
- ] = useQueryStates({
- search: parseAsString.withDefault(''),
- sort: parseAsString,
- order: parseAsString,
- source: parseAsArrayOf(parseAsString).withDefault([]),
- })
- const [searchQuery, setSearchQuery] = useState(urlSearch || '')
- const appNameFilter = urlSource
- const setAppNameFilter = (names: string[]) =>
- setQueryStates({ source: names.length ? names : null })
- const appNameOptions = useMemo(() => {
- const names = Array.from(
- new Set(data.map((r) => r.application_name).filter(Boolean))
- ) as string[]
- return names.map((name) => ({ value: name, label: name }))
- }, [data])
- const filteredData = useMemo(() => {
- if (appNameFilter.length === 0) return data
- return data.filter((r) => appNameFilter.includes(r.application_name ?? ''))
- }, [data, appNameFilter])
- const { classified, errors, indexIssues, slowQueries } = useQueryInsightsIssues(filteredData)
- const [selectedRow, setSelectedRow] = useState<number>()
- const [selectedTriageRow, setSelectedTriageRow] = useState<number | undefined>()
- const [sheetView, setSheetView] = useState<'details' | 'indexes' | 'explain'>('details')
- const gridRef = useRef<DataGridHandle>(null)
- const triageGridRef = useRef<DataGridHandle>(null)
- const dataGridContainerRef = useRef<HTMLDivElement>(null)
- const triageContainerRef = useRef<HTMLDivElement>(null)
- const scrollContainerRef = useRef<HTMLDivElement>(null)
- const [triageContainerWidth, setTriageContainerWidth] = useState(0)
- const sort = useMemo<{ column: string; order: 'asc' | 'desc' }>(
- () =>
- urlSortCol && urlSortOrder && ['asc', 'desc'].includes(urlSortOrder)
- ? { column: urlSortCol, order: urlSortOrder as 'asc' | 'desc' }
- : { column: 'prop_total_time', order: 'desc' },
- [urlSortCol, urlSortOrder]
- )
- const setSort = useCallback(
- (config: { column: string; order: 'asc' | 'desc' } | null) =>
- setQueryStates(
- config ? { sort: config.column, order: config.order } : { sort: null, order: null }
- ),
- [setQueryStates]
- )
- const [explainResults, setExplainResults] = useState<Record<string, QueryPlanRow[]>>({})
- const [explainLoadingQuery, setExplainLoadingQuery] = useState<string | null>(null)
- const { ref } = useParams()
- const router = useRouter()
- const { openSidebar } = useSidebarManagerSnapshot()
- const aiSnap = useAiAssistantStateSnapshot()
- const { data: project } = useSelectedProjectQuery()
- const { mutate: executeExplain } = useExecuteSqlMutation()
- const triageItems = useMemo(() => classified.filter((q) => q.issueType !== null), [classified])
- const filteredTriageItems = useMemo(() => {
- const filtered =
- filter === 'all' ? triageItems : triageItems.filter((q) => q.issueType === filter)
- return filtered.map((item) => ({
- ...item,
- queryType: getQueryType(item.query),
- }))
- }, [triageItems, filter])
- const explorerItems = useMemo(() => {
- let items = [...classified]
- if (searchQuery.trim()) {
- const searchLower = searchQuery.toLowerCase()
- items = items.filter((item) => {
- const queryType = getQueryType(item.query) ?? ''
- const tableName = getTableName(item.query) ?? ''
- const columnName = getColumnName(item.query) ?? ''
- const appName = item.application_name ?? ''
- const query = item.query ?? ''
- return (
- queryType.toLowerCase().includes(searchLower) ||
- tableName.toLowerCase().includes(searchLower) ||
- columnName.toLowerCase().includes(searchLower) ||
- appName.toLowerCase().includes(searchLower) ||
- query.toLowerCase().includes(searchLower)
- )
- })
- }
- if (sort) {
- items.sort((a, b) => {
- if (sort.column === 'query') {
- const aDate = a.first_seen ? new Date(a.first_seen).getTime() : 0
- const bDate = b.first_seen ? new Date(b.first_seen).getTime() : 0
- return sort.order === 'asc' ? aDate - bDate : bDate - aDate
- }
- const aValue: unknown = a[sort.column as keyof typeof a]
- const bValue: unknown = b[sort.column as keyof typeof b]
- if (typeof aValue === 'number' && typeof bValue === 'number') {
- return sort.order === 'asc' ? aValue - bValue : bValue - aValue
- }
- return 0
- })
- }
- return items
- }, [classified, searchQuery, sort])
- const activeSheetRow: ClassifiedQuery | undefined = useMemo(() => {
- if (mode === 'triage') {
- return selectedTriageRow !== undefined ? filteredTriageItems[selectedTriageRow] : undefined
- }
- return selectedRow !== undefined ? (explorerItems[selectedRow] as ClassifiedQuery) : undefined
- }, [mode, selectedTriageRow, selectedRow, filteredTriageItems, explorerItems])
- const runExplain = useCallback(
- (query: SafeSqlFragment) => {
- if (explainResults[query]) return
- if (explainLoadingQuery) return
- const requestQuery = query
- setExplainLoadingQuery(requestQuery)
- executeExplain(
- {
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- sql: wrapWithRollback(safeSql`EXPLAIN ANALYZE ${requestQuery}`),
- },
- {
- onSuccess(data) {
- setExplainResults((prev) => ({ ...prev, [requestQuery]: data.result }))
- setExplainLoadingQuery(null)
- },
- onError() {
- setExplainLoadingQuery(null)
- },
- }
- )
- },
- [explainResults, explainLoadingQuery, executeExplain, project]
- )
- const handleGoToLogs = useCallback(() => {
- router.push(`/project/${ref}/logs/postgres-logs`)
- }, [router, ref])
- const handleAiSuggestedFix = useCallback(
- (item: ClassifiedQuery) => {
- const { query, prompt } = buildQueryInsightFixPrompt(item)
- openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
- aiSnap.newChat({
- sqlSnippets: [{ label: 'Query', content: query }],
- initialMessage: prompt,
- })
- },
- [openSidebar, aiSnap]
- )
- useEffect(() => {
- if (sheetView === 'explain' && activeSheetRow?.query) {
- runExplain(activeSheetRow.query)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sheetView, activeSheetRow?.query])
- useEffect(() => {
- const currentPath = router.asPath.split('?')[0]
- const handleRouteChange = (url: string) => {
- if (url.split('?')[0] !== currentPath) {
- setQueryStates({ search: null })
- onCurrentSelectQuery?.(null)
- }
- }
- router.events.on('routeChangeStart', handleRouteChange)
- return () => router.events.off('routeChangeStart', handleRouteChange)
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
- const timeConsumedWidth = useMemo(() => {
- if (!explorerItems.length) return 150
- let maxWidth = 150
- explorerItems.forEach((row) => {
- const pct = row.prop_total_time || 0
- const total = row.total_time || 0
- if (pct && total) {
- const text = `${pct.toFixed(1)}% / ${formatDuration(total)}`
- maxWidth = Math.max(maxWidth, text.length * 8 + 40)
- }
- })
- return Math.min(maxWidth, 300)
- }, [explorerItems])
- const triageQueryColWidth = useMemo(() => {
- if (!triageContainerWidth) return 380
- const fixed = timeConsumedWidth + 100 + 300 + 4
- return Math.max(380, triageContainerWidth - fixed - 120)
- }, [triageContainerWidth, timeConsumedWidth])
- const { columns, triageColumns } = useQueryInsightsTableColumns({
- sort,
- setSort,
- timeConsumedWidth,
- triageQueryColWidth,
- gridRef,
- setSelectedRow,
- setSelectedTriageRow,
- setSheetView,
- handleGoToLogs,
- handleAiSuggestedFix,
- })
- const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
- if (!explorerItems.length || selectedRow === undefined) return
- if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
- event.stopPropagation()
- let nextIndex = selectedRow
- if (event.key === 'ArrowUp' && selectedRow > 0) {
- nextIndex = selectedRow - 1
- } else if (event.key === 'ArrowDown' && selectedRow < explorerItems.length - 1) {
- nextIndex = selectedRow + 1
- }
- if (nextIndex !== selectedRow) {
- setSelectedRow(nextIndex)
- gridRef.current?.scrollToCell({ idx: 0, rowIdx: nextIndex })
- }
- },
- [explorerItems, selectedRow]
- )
- useEffect(() => {
- window.addEventListener('keydown', handleKeyDown, true)
- return () => {
- window.removeEventListener('keydown', handleKeyDown, true)
- }
- }, [handleKeyDown])
- useEffect(() => {
- setSelectedRow(undefined)
- }, [searchQuery, sort])
- useEffect(() => {
- if (mode === 'triage') {
- triageGridRef.current?.scrollToCell({ idx: 0, rowIdx: 0 })
- } else {
- gridRef.current?.scrollToCell({ idx: 0, rowIdx: 0 })
- }
- }, [mode])
- useEffect(() => {
- const el = triageContainerRef.current
- if (!el) return
- const observer = new ResizeObserver(([entry]) => {
- setTriageContainerWidth(entry.contentRect.width)
- })
- observer.observe(el)
- return () => observer.disconnect()
- }, [])
- useEffect(() => {
- if (urlSearch !== searchQuery) {
- setQueryStates({ search: searchQuery || null })
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchQuery])
- const errorCount = errors.length
- const indexCount = indexIssues.length
- const slowCount = slowQueries.length
- return (
- <div className="flex flex-col flex-1 min-h-0">
- <div className="overflow-x-auto shrink-0 bg-surface-100 border-b">
- <div className="flex items-center justify-between px-6 h-10 min-w-max">
- <div className="flex items-center gap-x-1.5">
- <TwoOptionToggle
- width={75}
- options={['explorer', 'triage']}
- activeOption={mode}
- borderOverride="border"
- onClickOption={(mode) => setMode(mode as Mode)}
- />
- {appNameFilter.length > 0 ? (
- <FilterPill
- label="Source"
- value={appNameFilter.join(', ')}
- onClear={() => setAppNameFilter([])}
- />
- ) : (
- <FilterPopover
- name="Source"
- options={appNameOptions}
- activeOptions={appNameFilter}
- valueKey="value"
- labelKey="label"
- onSaveFilters={setAppNameFilter}
- showOnlyButton={false}
- />
- )}
- </div>
- <div className="flex items-center">
- {mode === 'triage' ? (
- <Tabs_Shadcn_ value={filter} onValueChange={(v) => setFilter(v as IssueFilter)}>
- <TabsList_Shadcn_ className="flex gap-x-4 rounded-none mt-0! pt-0 border-none!">
- <TabsTrigger_Shadcn_
- value="all"
- className="text-xs py-3 border-b font-mono uppercase"
- >
- All{triageItems.length > 0 && ` (${triageItems.length})`}
- </TabsTrigger_Shadcn_>
- <TabsTrigger_Shadcn_
- value="error"
- className="text-xs py-3 border-b font-mono uppercase"
- >
- Errors{errorCount > 0 && ` (${errorCount})`}
- </TabsTrigger_Shadcn_>
- <TabsTrigger_Shadcn_
- value="index"
- className="text-xs py-3 border-b font-mono uppercase"
- >
- Index{indexCount > 0 && ` (${indexCount})`}
- </TabsTrigger_Shadcn_>
- <TabsTrigger_Shadcn_
- value="slow"
- className="text-xs py-3 border-b font-mono uppercase"
- >
- Slow{slowCount > 0 && ` (${slowCount})`}
- </TabsTrigger_Shadcn_>
- </TabsList_Shadcn_>
- </Tabs_Shadcn_>
- ) : (
- <Input
- size="tiny"
- autoComplete="off"
- icon={<Search className="h-4 w-4" />}
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- name="search"
- id="search"
- placeholder="Search queries..."
- className="w-64"
- actions={[
- searchQuery && (
- <Button
- key="clear"
- size="tiny"
- type="text"
- icon={<X className="h-4 w-4" />}
- onClick={() => setSearchQuery('')}
- className="p-0 h-5 w-5"
- />
- ),
- ]}
- />
- )}
- </div>
- </div>
- </div>
- <div
- ref={scrollContainerRef}
- className="relative flex-1 min-h-0 flex flex-col overflow-hidden"
- >
- <div
- className={[
- 'absolute bottom-8 left-0 right-0 flex justify-center z-10 pointer-events-none',
- 'transition-all duration-200',
- currentSelectedQuery
- ? 'opacity-100 translate-y-0 pointer-events-auto'
- : 'opacity-0 translate-y-4 pointer-events-none',
- ].join(' ')}
- >
- <Button
- type="default"
- size="tiny"
- className="rounded-full shadow-md"
- onClick={() => onCurrentSelectQuery?.(null)}
- >
- Clear query
- </Button>
- </div>
- {isLoading ? (
- <div className="px-6 py-4">
- <GenericSkeletonLoader />
- </div>
- ) : mode === 'triage' ? (
- <div ref={triageContainerRef} className="flex-1 min-h-0 min-w-0 overflow-x-auto">
- <DataGrid
- ref={triageGridRef}
- style={{ height: '100%' }}
- className="flex-1 grow h-full"
- rowHeight={60}
- headerRowHeight={36}
- columns={triageColumns}
- rows={filteredTriageItems}
- rowClass={(_, idx) => {
- const isSelected = idx === selectedTriageRow
- const query = filteredTriageItems[idx]?.query
- const isCharted = currentSelectedQuery ? currentSelectedQuery === query : false
- return [
- `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : isCharted ? 'bg-surface-200 dark:bg-surface-200' : 'bg-200 hover:bg-surface-200'} cursor-pointer`,
- '[&>div:first-child]:border-l-4 [&>div:first-child]:pl-5 [&>div:last-child]:pr-6',
- `${isSelected || isCharted ? '[&>div:first-child]:border-l-foreground' : '[&>div:first-child]:border-l-transparent'}`,
- '[&>.rdg-cell]:box-border [&>.rdg-cell]:outline-hidden [&>.rdg-cell]:shadow-none [&>.rdg-cell]:py-3',
- '[&>.rdg-cell.column-prop_total_time]:relative',
- ].join(' ')
- }}
- renderers={{
- renderRow(idx, props) {
- return (
- <Row
- {...props}
- key={`triage-row-${props.rowIdx}`}
- onClick={(event) => {
- event.stopPropagation()
- if (typeof idx === 'number' && idx >= 0) {
- const query = filteredTriageItems[idx]?.query
- if (query && onCurrentSelectQuery) {
- onCurrentSelectQuery(currentSelectedQuery === query ? null : query)
- }
- }
- }}
- />
- )
- },
- noRowsFallback: (
- <div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
- <TextSearch className="text-foreground-muted" strokeWidth={1} />
- <div className="text-center">
- <p className="text-foreground">No issues found</p>
- <p className="text-foreground-light">
- {data.length === 0
- ? 'No query data available yet'
- : 'No issues detected for the selected filter'}
- </p>
- </div>
- </div>
- ),
- }}
- />
- </div>
- ) : (
- <div ref={dataGridContainerRef} className="flex-1 min-h-0 min-w-0 overflow-x-auto">
- <DataGrid
- ref={gridRef}
- style={{ height: '100%' }}
- className={cn('flex-1 grow h-full')}
- rowHeight={44}
- headerRowHeight={36}
- columns={columns}
- rows={explorerItems}
- rowClass={(_, idx) => {
- const isSelected = idx === selectedRow
- const query = explorerItems[idx]?.query
- const isCharted = currentSelectedQuery ? currentSelectedQuery === query : false
- return [
- `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : isCharted ? 'bg-surface-200 dark:bg-surface-200' : 'bg-200 hover:bg-surface-200'} cursor-pointer`,
- '[&>div:first-child]:border-l-4 [&>div:first-child]:pl-5 [&>div:last-child]:pr-6',
- `${isSelected || isCharted ? '[&>div:first-child]:border-l-foreground' : '[&>div:first-child]:border-l-transparent'}`,
- '[&>.rdg-cell]:box-border [&>.rdg-cell]:outline-hidden [&>.rdg-cell]:shadow-none [&>.rdg-cell]:py-3',
- '[&>.rdg-cell.column-prop_total_time]:relative',
- ].join(' ')
- }}
- renderers={{
- renderRow(idx, props) {
- return (
- <Row
- {...props}
- key={`explorer-row-${props.rowIdx}`}
- onClick={(event) => {
- event.stopPropagation()
- if (typeof idx === 'number' && idx >= 0) {
- const query = explorerItems[idx]?.query
- if (query && onCurrentSelectQuery) {
- onCurrentSelectQuery(currentSelectedQuery === query ? null : query)
- }
- }
- }}
- />
- )
- },
- noRowsFallback: (
- <div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
- <TextSearch className="text-foreground-muted" strokeWidth={1} />
- <div className="text-center">
- <p className="text-foreground">No queries found</p>
- <p className="text-foreground-light">
- {searchQuery.trim()
- ? 'No queries match your search criteria'
- : 'No query data available yet'}
- </p>
- </div>
- </div>
- ),
- }}
- />
- </div>
- )}
- </div>
- <QueryInsightsDetailSheet
- open={activeSheetRow !== undefined}
- onOpenChange={(open) => {
- if (!open) {
- setSelectedTriageRow(undefined)
- setSelectedRow(undefined)
- }
- }}
- activeSheetRow={activeSheetRow}
- sheetView={sheetView}
- onSheetViewChange={setSheetView}
- onClose={() => {
- setSelectedTriageRow(undefined)
- setSelectedRow(undefined)
- }}
- dataGridContainerRef={dataGridContainerRef}
- triageContainerRef={triageContainerRef}
- explainLoadingQuery={explainLoadingQuery}
- explainResults={explainResults}
- />
- </div>
- )
- }
|