QueryDetail.tsx251 lines · main
1import { ChevronsUpDown, Lightbulb } from 'lucide-react'
2import dynamic from 'next/dynamic'
3import { useEffect, useState } from 'react'
4import { Alert, AlertDescription, AlertTitle, Button, cn } from 'ui'
5
6import { QueryPanelContainer, QueryPanelSection } from './QueryPanel'
7import { buildQueryExplanationPrompt } from './QueryPerformance.ai'
8import { QUERY_PERFORMANCE_COLUMNS } from './QueryPerformance.constants'
9import { QueryPerformanceRow } from './QueryPerformance.types'
10import { formatDuration } from './QueryPerformance.utils'
11import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
12import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
13import { formatSql } from '@/lib/formatSql'
14import { useTrack } from '@/lib/telemetry/track'
15import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
16import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
17
18interface QueryDetailProps {
19 selectedRow?: QueryPerformanceRow
20 onClickViewSuggestion: () => void
21 onClose?: () => void
22}
23
24// Load SqlMonacoBlock (monaco editor) client-side only (does not behave well server-side)
25const SqlMonacoBlock = dynamic(
26 () => import('./SqlMonacoBlock').then(({ SqlMonacoBlock }) => SqlMonacoBlock),
27 {
28 ssr: false,
29 }
30)
31
32export const QueryDetail = ({ selectedRow, onClickViewSuggestion, onClose }: QueryDetailProps) => {
33 // [Joshen] TODO implement this logic once the linter rules are in
34 const isLinterWarning = false
35 const report = QUERY_PERFORMANCE_COLUMNS
36 const [query, setQuery] = useState(selectedRow?.['query'])
37
38 const { openSidebar } = useSidebarManagerSnapshot()
39 const aiSnap = useAiAssistantStateSnapshot()
40 const track = useTrack()
41
42 useEffect(() => {
43 if (selectedRow !== undefined) {
44 const formattedQuery = formatSql(selectedRow['query'])
45 setQuery(formattedQuery)
46 }
47 }, [selectedRow])
48
49 const [isExpanded, setIsExpanded] = useState(false)
50
51 const handleExplainQuery = () => {
52 if (!selectedRow?.query) return
53
54 const { query, prompt } = buildQueryExplanationPrompt(selectedRow)
55
56 openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
57 aiSnap.newChat({
58 sqlSnippets: [
59 {
60 label: 'Query',
61 content: query,
62 },
63 ],
64 initialMessage: prompt,
65 })
66
67 track('query_performance_explain_with_ai_button_clicked')
68
69 // Close the query detail panel since we need to see the AI assistant panel
70 onClose?.()
71 }
72
73 const buildPromptForCopy = () => {
74 if (!selectedRow?.query) return ''
75
76 const { query, prompt } = buildQueryExplanationPrompt(selectedRow)
77 return `${prompt}\n\nSQL Query:\n\`\`\`sql\n${query}\n\`\`\``
78 }
79
80 return (
81 <QueryPanelContainer>
82 <QueryPanelSection className="pt-2 border-b relative">
83 <div className="flex items-center justify-between mb-4">
84 <h4>Query pattern</h4>
85 <AiAssistantDropdown
86 label="Explain with AI"
87 buildPrompt={buildPromptForCopy}
88 onOpenAssistant={handleExplainQuery}
89 telemetrySource="query_performance"
90 size="tiny"
91 type="default"
92 />
93 </div>
94 <div
95 className={cn(
96 'overflow-hidden pb-0 z-0 relative transition-all duration-300',
97 isExpanded ? 'h-[348px]' : 'h-[120px]'
98 )}
99 >
100 <SqlMonacoBlock
101 value={query}
102 height={322}
103 lineNumbers="off"
104 wrapperClassName={cn('pl-3 bg-surface-100', !isExpanded && 'pointer-events-none')}
105 />
106 {isLinterWarning && (
107 <Alert
108 variant="default"
109 className="mt-2 border-brand-400 bg-alternative [&>svg]:p-0.5 [&>svg]:bg-transparent [&>svg]:text-brand"
110 >
111 <Lightbulb />
112 <AlertTitle>Suggested optimization: Add an index</AlertTitle>
113 <AlertDescription>
114 Adding an index will help this query execute faster
115 </AlertDescription>
116 <AlertDescription>
117 <Button className="mt-3" onClick={() => onClickViewSuggestion()}>
118 View suggestion
119 </Button>
120 </AlertDescription>
121 </Alert>
122 )}
123 </div>
124 <div
125 className={cn(
126 'absolute left-0 bottom-0 w-full bg-linear-to-t from-black/30 to-transparent h-24 transition-opacity duration-300',
127 isExpanded && 'opacity-0 pointer-events-none'
128 )}
129 />
130 <div className="absolute bottom-[-13px] left-0 right-0 w-full flex items-center justify-center z-10">
131 <Button
132 type="default"
133 className="rounded-full"
134 icon={<ChevronsUpDown />}
135 onClick={() => setIsExpanded(!isExpanded)}
136 >
137 {isExpanded ? 'Collapse' : 'Expand'}
138 </Button>
139 </div>
140 </QueryPanelSection>
141 <QueryPanelSection className="pb-3 pt-6">
142 <h4 className="mb-4">Metadata</h4>
143 <ul className="flex flex-col gap-y-3 divide-y divide-dashed">
144 {report
145 .filter((x) => x.id !== 'query')
146 .map((x) => {
147 const rawValue = selectedRow?.[x.id]
148 const isTime = x.name.includes('time')
149
150 const formattedValue = isTime
151 ? typeof rawValue === 'number' && !isNaN(rawValue) && isFinite(rawValue)
152 ? `${Math.round(rawValue).toLocaleString()}ms`
153 : 'n/a'
154 : rawValue != null
155 ? String(rawValue)
156 : 'n/a'
157
158 if (x.id === 'prop_total_time') {
159 const percentage = selectedRow?.prop_total_time || 0
160 const totalTime = selectedRow?.total_time || 0
161
162 return (
163 <li key={x.id} className="flex justify-between pb-3 text-sm">
164 <p className="text-foreground-light">{x.name}</p>
165 {percentage && totalTime ? (
166 <p className="flex items-center gap-x-1.5">
167 <span
168 className={cn(
169 'tabular-nums',
170 percentage.toFixed(1) === '0.0' && 'text-foreground-lighter'
171 )}
172 >
173 {percentage.toFixed(1)}%
174 </span>{' '}
175 <span className="text-muted">/</span>{' '}
176 <span
177 className={cn(
178 'tabular-nums',
179 formatDuration(totalTime) === '0.00s' && 'text-foreground-lighter'
180 )}
181 >
182 {formatDuration(totalTime)}
183 </span>
184 </p>
185 ) : (
186 <p className="text-muted">&ndash;</p>
187 )}
188 </li>
189 )
190 }
191
192 if (x.id == 'rows_read') {
193 return (
194 <li key={x.id} className="flex justify-between pb-3 text-sm">
195 <p className="text-foreground-light">{x.name}</p>
196 {typeof rawValue === 'number' && !isNaN(rawValue) && isFinite(rawValue) ? (
197 <p
198 className={cn('tabular-nums', rawValue === 0 && 'text-foreground-lighter')}
199 >
200 {rawValue.toLocaleString()}
201 </p>
202 ) : (
203 <p className="text-muted">&ndash;</p>
204 )}
205 </li>
206 )
207 }
208
209 const cacheHitRateToNumber = (value: number | string) => {
210 if (typeof value === 'number') return value
211 return parseFloat(value.toString().replace('%', '')) || 0
212 }
213
214 if (x.id === 'cache_hit_rate') {
215 return (
216 <li key={x.id} className="flex justify-between pb-3 text-sm">
217 <p className="text-foreground-light">{x.name}</p>
218 {typeof rawValue === 'string' || typeof rawValue === 'number' ? (
219 <p
220 className={cn(
221 cacheHitRateToNumber(rawValue).toFixed(2) === '0.00' &&
222 'text-foreground-lighter'
223 )}
224 >
225 {cacheHitRateToNumber(rawValue).toLocaleString(undefined, {
226 minimumFractionDigits: 2,
227 maximumFractionDigits: 2,
228 })}
229 %
230 </p>
231 ) : (
232 <p className="text-muted">&ndash;</p>
233 )}
234 </li>
235 )
236 }
237
238 return (
239 <li key={x.id} className="flex justify-between pb-3 text-sm">
240 <p className="text-foreground-light">{x.name}</p>
241 <p className={cn('tabular-nums', x.id === 'rolname' && 'font-mono')}>
242 {formattedValue}
243 </p>
244 </li>
245 )
246 })}
247 </ul>
248 </QueryPanelSection>
249 </QueryPanelContainer>
250 )
251}