QueryDetail.tsx251 lines · main
| 1 | import { ChevronsUpDown, Lightbulb } from 'lucide-react' |
| 2 | import dynamic from 'next/dynamic' |
| 3 | import { useEffect, useState } from 'react' |
| 4 | import { Alert, AlertDescription, AlertTitle, Button, cn } from 'ui' |
| 5 | |
| 6 | import { QueryPanelContainer, QueryPanelSection } from './QueryPanel' |
| 7 | import { buildQueryExplanationPrompt } from './QueryPerformance.ai' |
| 8 | import { QUERY_PERFORMANCE_COLUMNS } from './QueryPerformance.constants' |
| 9 | import { QueryPerformanceRow } from './QueryPerformance.types' |
| 10 | import { formatDuration } from './QueryPerformance.utils' |
| 11 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 12 | import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' |
| 13 | import { formatSql } from '@/lib/formatSql' |
| 14 | import { useTrack } from '@/lib/telemetry/track' |
| 15 | import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' |
| 16 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 17 | |
| 18 | interface 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) |
| 25 | const SqlMonacoBlock = dynamic( |
| 26 | () => import('./SqlMonacoBlock').then(({ SqlMonacoBlock }) => SqlMonacoBlock), |
| 27 | { |
| 28 | ssr: false, |
| 29 | } |
| 30 | ) |
| 31 | |
| 32 | export 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">–</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">–</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">–</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 | } |