WithStatements.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import { safeSql } from '@supabase/pg-meta/src/pg-format'
  2. import { LOCAL_STORAGE_KEYS, useParams } from 'common'
  3. import { RefreshCw, RotateCcw, X } from 'lucide-react'
  4. import { parseAsString, useQueryStates } from 'nuqs'
  5. import { useEffect, useMemo, useState } from 'react'
  6. import { toast } from 'sonner'
  7. import { Button, cn, LoadingLine } from 'ui'
  8. import { Admonition } from 'ui-patterns'
  9. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  10. import { Markdown } from '../../Markdown'
  11. import { captureQueryPerformanceError } from '../QueryPerformance.utils'
  12. import { QueryPerformanceFilterBar } from '../QueryPerformanceFilterBar'
  13. import { QueryPerformanceGrid } from '../QueryPerformanceGrid'
  14. import { QueryPerformanceMetrics } from '../QueryPerformanceMetrics'
  15. import { QueryPerformanceInfiniteHook } from '../useQueryPerformanceQuery'
  16. import { transformStatementDataToRows } from './WithStatements.utils'
  17. import { PresetHookResult } from '@/components/interfaces/Reports/Reports.utils'
  18. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  19. import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
  20. import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
  21. import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
  22. import { executeSql } from '@/data/sql/execute-sql-query'
  23. import { useInfiniteScroll } from '@/hooks/misc/useInfiniteScroll'
  24. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  25. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  26. import { DOCS_URL, IS_PLATFORM } from '@/lib/constants'
  27. import { getErrorMessage } from '@/lib/get-error-message'
  28. import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
  29. interface WithStatementsProps {
  30. queryHitRate: PresetHookResult
  31. queryPerformanceQuery: QueryPerformanceInfiniteHook
  32. queryMetrics: PresetHookResult
  33. }
  34. export const WithStatements = ({
  35. queryHitRate,
  36. queryPerformanceQuery,
  37. queryMetrics,
  38. }: WithStatementsProps) => {
  39. const { ref } = useParams()
  40. const { data: project } = useSelectedProjectQuery()
  41. const state = useDatabaseSelectorStateSnapshot()
  42. const {
  43. data,
  44. isLoading,
  45. isRefetching,
  46. isFetchingNextPage,
  47. hasNextPage,
  48. error: queryError,
  49. fetchNextPage,
  50. refetch: runQuery,
  51. } = queryPerformanceQuery
  52. const isPrimaryDatabase = state.selectedDatabaseId === ref
  53. const formattedDatabaseId = formatDatabaseID(state.selectedDatabaseId ?? '')
  54. const hitRateError = 'error' in queryHitRate ? queryHitRate.error : null
  55. const metricsError = 'error' in queryMetrics ? queryMetrics.error : null
  56. const mainQueryError = queryError || null
  57. const [showResetgPgStatStatements, setShowResetgPgStatStatements] = useState(false)
  58. const [showBottomSection, setShowBottomSection] = useLocalStorageQuery(
  59. LOCAL_STORAGE_KEYS.QUERY_PERF_SHOW_BOTTOM_SECTION,
  60. true
  61. )
  62. const [{ indexAdvisor }] = useQueryStates({
  63. indexAdvisor: parseAsString.withDefault('false'),
  64. })
  65. const handleRefresh = () => {
  66. runQuery()
  67. queryHitRate.runQuery()
  68. queryMetrics.runQuery()
  69. }
  70. const processedData = useMemo(() => {
  71. return transformStatementDataToRows(data || [], indexAdvisor === 'true')
  72. }, [data, indexAdvisor])
  73. const { data: databases } = useReadReplicasQuery({ projectRef: ref })
  74. const handleScroll = useInfiniteScroll({
  75. isLoading,
  76. isFetchingNextPage,
  77. hasNextPage,
  78. fetchNextPage,
  79. })
  80. useEffect(() => {
  81. state.setSelectedDatabaseId(ref)
  82. // eslint-disable-next-line react-hooks/exhaustive-deps
  83. }, [ref])
  84. useEffect(() => {
  85. if (mainQueryError) {
  86. const errorMessage = getErrorMessage(mainQueryError)
  87. const isNotInstalled =
  88. typeof errorMessage === 'string' &&
  89. errorMessage.includes('pg_stat_statements') &&
  90. errorMessage.includes('does not exist')
  91. if (!isNotInstalled) {
  92. captureQueryPerformanceError(mainQueryError, {
  93. projectRef: ref,
  94. databaseIdentifier: state.selectedDatabaseId,
  95. queryPreset: 'unified',
  96. queryType: 'mainQuery',
  97. postgresVersion: project?.dbVersion,
  98. databaseType: isPrimaryDatabase ? 'primary' : 'read-replica',
  99. sql: queryPerformanceQuery.resolvedSql,
  100. errorMessage: errorMessage || undefined,
  101. })
  102. }
  103. }
  104. }, [
  105. mainQueryError,
  106. ref,
  107. state.selectedDatabaseId,
  108. project?.dbVersion,
  109. isPrimaryDatabase,
  110. queryPerformanceQuery.resolvedSql,
  111. ])
  112. useEffect(() => {
  113. if (hitRateError) {
  114. const errorMessage = getErrorMessage(hitRateError)
  115. captureQueryPerformanceError(hitRateError, {
  116. projectRef: ref,
  117. databaseIdentifier: state.selectedDatabaseId,
  118. queryPreset: 'queryHitRate',
  119. queryType: 'hitRate',
  120. postgresVersion: project?.dbVersion,
  121. databaseType: isPrimaryDatabase ? 'primary' : 'read-replica',
  122. errorMessage: errorMessage || undefined,
  123. })
  124. }
  125. }, [hitRateError, ref, state.selectedDatabaseId, project?.dbVersion, isPrimaryDatabase])
  126. useEffect(() => {
  127. if (metricsError) {
  128. const errorMessage = getErrorMessage(metricsError)
  129. captureQueryPerformanceError(metricsError, {
  130. projectRef: ref,
  131. databaseIdentifier: state.selectedDatabaseId,
  132. queryPreset: 'queryMetrics',
  133. queryType: 'metrics',
  134. postgresVersion: project?.dbVersion,
  135. databaseType: isPrimaryDatabase ? 'primary' : 'read-replica',
  136. errorMessage: errorMessage || undefined,
  137. })
  138. }
  139. }, [metricsError, ref, state.selectedDatabaseId, project?.dbVersion, isPrimaryDatabase])
  140. const hasError = mainQueryError || hitRateError || metricsError
  141. const errorMessage = mainQueryError
  142. ? getErrorMessage(mainQueryError) || 'Failed to load query performance data'
  143. : hitRateError
  144. ? getErrorMessage(hitRateError) || 'Failed to load cache hit rate data'
  145. : metricsError
  146. ? getErrorMessage(metricsError) || 'Failed to load query metrics'
  147. : null
  148. const isPgStatStatementsNotInstalled =
  149. typeof errorMessage === 'string' &&
  150. errorMessage.includes('pg_stat_statements') &&
  151. errorMessage.includes('does not exist')
  152. return (
  153. <>
  154. {hasError && (
  155. <div className="px-6 pt-4">
  156. {isPgStatStatementsNotInstalled ? (
  157. <Admonition
  158. type="warning"
  159. title="pg_stat_statements extension is not enabled"
  160. description="Query Performance requires the pg_stat_statements extension. Enable it in Database → Extensions."
  161. />
  162. ) : (
  163. <Admonition
  164. type="destructive"
  165. title="Error loading query performance data"
  166. description={
  167. errorMessage ||
  168. 'An error occurred while loading query performance data. Please try refreshing the page.'
  169. }
  170. />
  171. )}
  172. </div>
  173. )}
  174. <QueryPerformanceMetrics />
  175. <QueryPerformanceFilterBar
  176. showRolesFilter
  177. showSourceFilter
  178. actions={
  179. <>
  180. <ButtonTooltip
  181. type="default"
  182. size="tiny"
  183. icon={<RefreshCw />}
  184. onClick={handleRefresh}
  185. tooltip={{ content: { side: 'top', text: 'Refresh' } }}
  186. className="w-[26px]"
  187. />
  188. <ButtonTooltip
  189. type="default"
  190. size="tiny"
  191. icon={<RotateCcw />}
  192. onClick={() => setShowResetgPgStatStatements(true)}
  193. tooltip={{ content: { side: 'top', text: 'Reset report' } }}
  194. className="w-[26px]"
  195. />
  196. <DownloadResultsButton
  197. results={processedData}
  198. fileName={`Briven Query Performance Statements (${ref})`}
  199. align="end"
  200. />
  201. </>
  202. }
  203. />
  204. <LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
  205. <QueryPerformanceGrid
  206. aggregatedData={processedData}
  207. isLoading={isLoading}
  208. error={
  209. mainQueryError
  210. ? getErrorMessage(mainQueryError) || 'Failed to load query performance data'
  211. : null
  212. }
  213. onRetry={handleRefresh}
  214. onScroll={handleScroll}
  215. />
  216. <div
  217. className={cn('px-6 py-6 flex gap-x-4 border-t relative', {
  218. hidden: showBottomSection === false,
  219. })}
  220. >
  221. <Button
  222. className="absolute top-1.5 right-3 px-1.5"
  223. type="text"
  224. size="tiny"
  225. onClick={() => setShowBottomSection(false)}
  226. >
  227. <X size="14" />
  228. </Button>
  229. <div className="w-[33%] flex flex-col gap-y-1 text-sm">
  230. <p>Reset report</p>
  231. <p className="text-xs text-foreground-light">
  232. Consider resetting the analysis after optimizing any queries
  233. </p>
  234. <Button
  235. type="default"
  236. className="mt-3! w-min"
  237. onClick={() => setShowResetgPgStatStatements(true)}
  238. >
  239. Reset report
  240. </Button>
  241. </div>
  242. <div className="w-[33%] flex flex-col gap-y-1 text-sm">
  243. <p>How is this report generated?</p>
  244. <Markdown
  245. className="text-xs"
  246. content={`This report uses the pg_stat_statements table, and pg_stat_statements extension. [Learn more here](${DOCS_URL}/guides/platform/performance#examining-query-performance).`}
  247. />
  248. </div>
  249. <div className="w-[33%] flex flex-col gap-y-1 text-sm">
  250. <p>Inspect your database for potential issues</p>
  251. <Markdown
  252. className="text-xs"
  253. content={`The Briven CLI comes with a range of tools to help inspect your Postgres instances for
  254. potential issues. [Learn more here](${DOCS_URL}/guides/database/inspect).`}
  255. />
  256. </div>
  257. </div>
  258. <ConfirmationModal
  259. visible={showResetgPgStatStatements}
  260. size="medium"
  261. variant="destructive"
  262. title="Reset query performance analysis"
  263. confirmLabel="Reset report"
  264. confirmLabelLoading="Resetting report"
  265. onCancel={() => setShowResetgPgStatStatements(false)}
  266. onConfirm={async () => {
  267. const connectionString = databases?.find(
  268. (db) => db.identifier === state.selectedDatabaseId
  269. )?.connectionString
  270. if (IS_PLATFORM && !connectionString) {
  271. return toast.error('Unable to run query: Connection string is missing')
  272. }
  273. try {
  274. await executeSql({
  275. projectRef: project?.ref,
  276. connectionString,
  277. sql: safeSql`SELECT pg_stat_statements_reset();`,
  278. })
  279. handleRefresh()
  280. setShowResetgPgStatStatements(false)
  281. } catch (error: any) {
  282. toast.error(`Failed to reset analysis: ${error.message}`)
  283. }
  284. }}
  285. >
  286. <p className="text-foreground-light text-sm">
  287. This will reset the pg_stat_statements table in the extensions schema on your{' '}
  288. <span className="text-foreground">
  289. {isPrimaryDatabase ? 'primary database' : `read replica (ID: ${formattedDatabaseId})`}
  290. </span>
  291. , which is used to calculate query performance. This data will repopulate immediately
  292. after.
  293. </p>
  294. </ConfirmationModal>
  295. </>
  296. )
  297. }