mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
Query Performance Facelift (#22568)
* Update * Clean up + fix UI * Add data values to tab * Add comment * Format sql query in query details * Clean uo * Deprecate old files * Address comments * chore: update styles (#22591) * Add feature flag, reinstate old UI for feature flag * Update apps/studio/components/interfaces/QueryPerformanceV2/QueryPerformance.tsx Co-authored-by: Alaister Young <alaister@users.noreply.github.com> * Update apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx Co-authored-by: Alaister Young <alaister@users.noreply.github.com> * Address feedback --------- Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com> Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
This commit is contained in:
22 files changed
+961
-128
No files matched your search
@@ -6,9 +6,9 @@ import { Tabs } from 'ui'
|
||||
import { Markdown } from '../Markdown'
|
||||
import ReportQueryPerformanceTableRow from '../Reports/ReportQueryPerformanceTableRow'
|
||||
import { PresetHookResult } from '../Reports/Reports.utils'
|
||||
import { QueryPerformanceFilterBar } from './QueryPerformanceFilterBar'
|
||||
import { QueryPerformanceLoadingRow } from './QueryPerformanceLoadingRow'
|
||||
import { QueryPerformanceFilterBar } from '../QueryPerformanceV2/QueryPerformanceFilterBar'
|
||||
import { ResetAnalysisNotice } from './ResetAnalysisNotice'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
type QueryPerformancePreset = 'time' | 'frequent' | 'slowest'
|
||||
|
||||
@@ -34,14 +34,27 @@ interface QueryPerformanceProps {
|
||||
queryPerformanceQuery: DbQueryHook<any>
|
||||
}
|
||||
|
||||
const QueryPerformanceLoadingRow = ({ colSpan }: { colSpan: number }) => {
|
||||
return (
|
||||
<>
|
||||
{Array(4)
|
||||
.fill('')
|
||||
.map((_, i) => (
|
||||
<tr key={'loading-' + i}>
|
||||
<td colSpan={colSpan}>
|
||||
<ShimmeringLoader />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const QueryPerformance = ({
|
||||
queryHitRate,
|
||||
queryPerformanceQuery,
|
||||
}: QueryPerformanceProps) => {
|
||||
const router = useRouter()
|
||||
const isLoading = [queryPerformanceQuery.isLoading, queryHitRate.isLoading].every(
|
||||
(value) => value
|
||||
)
|
||||
|
||||
const handleRefresh = async () => {
|
||||
queryPerformanceQuery.runQuery()
|
||||
@@ -71,8 +84,8 @@ export const QueryPerformance = ({
|
||||
className="max-w-full [&>p]:mt-0 [&>p]:m-0 space-y-2"
|
||||
/>
|
||||
<ResetAnalysisNotice handleRefresh={handleRefresh} />
|
||||
<div className="thin-scrollbars max-w-full overflow-auto">
|
||||
<QueryPerformanceFilterBar onRefreshClick={handleRefresh} isLoading={isLoading} />
|
||||
<div className="thin-scrollbars max-w-full overflow-auto space-y-3">
|
||||
<QueryPerformanceFilterBar queryPerformanceQuery={queryPerformanceQuery} />
|
||||
<Table
|
||||
className="table-fixed"
|
||||
head={
|
||||
@@ -122,8 +135,8 @@ export const QueryPerformance = ({
|
||||
className="max-w-full [&>p]:mt-0 [&>p]:m-0 space-y-2"
|
||||
/>
|
||||
<ResetAnalysisNotice handleRefresh={handleRefresh} />
|
||||
<div className="thin-scrollbars max-w-full overflow-auto">
|
||||
<QueryPerformanceFilterBar onRefreshClick={handleRefresh} isLoading={isLoading} />
|
||||
<div className="thin-scrollbars max-w-full overflow-auto space-y-3">
|
||||
<QueryPerformanceFilterBar queryPerformanceQuery={queryPerformanceQuery} />
|
||||
<Table
|
||||
head={
|
||||
<>
|
||||
@@ -184,8 +197,8 @@ export const QueryPerformance = ({
|
||||
className="max-w-full [&>p]:mt-0 [&>p]:m-0 space-y-2"
|
||||
/>
|
||||
<ResetAnalysisNotice handleRefresh={handleRefresh} />
|
||||
<div className="thin-scrollbars max-w-full overflow-auto">
|
||||
<QueryPerformanceFilterBar onRefreshClick={handleRefresh} isLoading={isLoading} />
|
||||
<div className="thin-scrollbars max-w-full overflow-auto space-y-3">
|
||||
<QueryPerformanceFilterBar queryPerformanceQuery={queryPerformanceQuery} />
|
||||
<Table
|
||||
head={
|
||||
<>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
export const QueryPerformanceLoadingRow = ({ colSpan }: { colSpan: number }) => {
|
||||
return (
|
||||
<>
|
||||
{Array(4)
|
||||
.fill('')
|
||||
.map((_, i) => (
|
||||
<tr key={'loading-' + { i }}>
|
||||
<td colSpan={colSpan}>
|
||||
<ShimmeringLoader />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -11,6 +11,7 @@ interface IndexEfficiencyNoticeProps {
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// [Joshen] Currently not used, might be deprecated - just double checking first
|
||||
export const IndexEfficiencyNotice = ({ isLoading }: IndexEfficiencyNoticeProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const config = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
|
||||
@@ -0,0 +1,10 @@
|
||||
export const IndexSuggestion = () => {
|
||||
return (
|
||||
<div className="h-full overflow-auto flex flex-col gap-y-8 divide-y">
|
||||
<div className="px-4 flex flex-col gap-y-2">
|
||||
<p className="text-sm">Add new index</p>
|
||||
<p>Hello</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { CodeBlock, cn } from 'ui'
|
||||
import {
|
||||
QUERY_PERFORMANCE_REPORTS,
|
||||
QUERY_PERFORMANCE_REPORT_TYPES,
|
||||
} from './QueryPerformance.constants'
|
||||
import { format } from 'sql-formatter'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface QueryDetailProps {
|
||||
reportType: QUERY_PERFORMANCE_REPORT_TYPES
|
||||
selectedRow: any
|
||||
}
|
||||
|
||||
export const QueryDetail = ({ reportType, selectedRow }: QueryDetailProps) => {
|
||||
const report = QUERY_PERFORMANCE_REPORTS[reportType]
|
||||
const [query, setQuery] = useState(selectedRow?.['query'])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRow !== undefined) {
|
||||
try {
|
||||
const formattedQuery = format(selectedRow['query'], {
|
||||
language: 'postgresql',
|
||||
keywordCase: 'lower',
|
||||
})
|
||||
setQuery(formattedQuery)
|
||||
} catch (err) {
|
||||
setQuery(selectedRow['query'])
|
||||
}
|
||||
}
|
||||
}, [selectedRow])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8 divide-y">
|
||||
<div className="px-5 flex flex-col gap-y-2">
|
||||
<p className="text-sm">Query pattern</p>
|
||||
<CodeBlock
|
||||
hideLineNumbers
|
||||
value={query}
|
||||
language="sql"
|
||||
className={cn(
|
||||
'max-w-full max-h-[310px]',
|
||||
'!py-3 !px-3.5 prose dark:prose-dark transition',
|
||||
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-4 px-5 flex flex-col gap-y-1">
|
||||
{report
|
||||
.filter((x) => x.id !== 'query')
|
||||
.map((x) => {
|
||||
const isTime = x.name.includes('time')
|
||||
const formattedValue = isTime
|
||||
? `${selectedRow[x.id].toFixed(2)}ms`
|
||||
: String(selectedRow[x.id])
|
||||
return (
|
||||
<div key={x.id} className="flex gap-x-2">
|
||||
<p className="text-foreground-lighter text-sm w-32">{x.name}</p>
|
||||
<p className="text-sm w-32">{formattedValue}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export enum QUERY_PERFORMANCE_REPORT_TYPES {
|
||||
MOST_TIME_CONSUMING = 'most_time_consuming',
|
||||
MOST_FREQUENT = 'most_frequent',
|
||||
SLOWEST_EXECUTION = 'slowest_execution',
|
||||
}
|
||||
|
||||
export const QUERY_PERFORMANCE_REPORTS = {
|
||||
[QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING]: [
|
||||
{ id: 'query', name: 'Query', description: undefined, minWidth: 600 },
|
||||
{ id: 'rolname', name: 'Role', description: undefined, minWidth: undefined },
|
||||
{ id: 'calls', name: 'Calls', description: undefined, minWidth: undefined },
|
||||
{ id: 'total_time', name: 'Total time', description: 'latency', minWidth: 180 },
|
||||
{ id: 'prop_total_time', name: 'Time consumed', description: undefined, minWidth: 150 },
|
||||
],
|
||||
[QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT]: [
|
||||
{ id: 'query', name: 'Query', description: undefined, minWidth: 600 },
|
||||
{ id: 'rolname', name: 'Role', description: undefined, minWidth: undefined },
|
||||
{ id: 'avg_rows', name: 'Avg. Rows', description: undefined, minWidth: undefined },
|
||||
{ id: 'calls', name: 'Calls', description: undefined, minWidth: undefined },
|
||||
{ id: 'max_time', name: 'Max time', description: undefined, minWidth: undefined },
|
||||
{ id: 'mean_time', name: 'Mean time', description: undefined, minWidth: undefined },
|
||||
{ id: 'min_time', name: 'Min time', description: undefined, minWidth: undefined },
|
||||
{ id: 'total_time', name: 'Total time', description: 'latency', minWidth: 180 },
|
||||
],
|
||||
[QUERY_PERFORMANCE_REPORT_TYPES.SLOWEST_EXECUTION]: [
|
||||
{ id: 'query', name: 'Query', description: undefined, minWidth: 600 },
|
||||
{ id: 'rolname', name: 'Role', description: undefined, minWidth: undefined },
|
||||
{ id: 'avg_rows', name: 'Avg. Rows', description: undefined, minWidth: undefined },
|
||||
{ id: 'calls', name: 'Calls', description: undefined, minWidth: undefined },
|
||||
{ id: 'max_time', name: 'Max time', description: undefined, minWidth: undefined },
|
||||
{ id: 'mean_time', name: 'Mean time', description: undefined, minWidth: undefined },
|
||||
{ id: 'min_time', name: 'Min time', description: undefined, minWidth: undefined },
|
||||
{ id: 'total_time', name: 'Total time', description: 'latency', minWidth: 180 },
|
||||
],
|
||||
} as const
|
||||
@@ -0,0 +1,219 @@
|
||||
import { InformationCircleIcon } from '@heroicons/react/16/solid'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
|
||||
import {
|
||||
Button,
|
||||
TabsList_Shadcn_,
|
||||
TabsTrigger_Shadcn_,
|
||||
Tabs_Shadcn_,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
Tooltip_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { Markdown } from '../Markdown'
|
||||
import { useQueryPerformanceQuery } from '../Reports/Reports.queries'
|
||||
import { PresetHookResult } from '../Reports/Reports.utils'
|
||||
import { QUERY_PERFORMANCE_REPORT_TYPES } from './QueryPerformance.constants'
|
||||
import { QueryPerformanceFilterBar } from './QueryPerformanceFilterBar'
|
||||
import { QueryPerformanceGrid } from './QueryPerformanceGrid'
|
||||
|
||||
interface QueryPerformanceProps {
|
||||
queryHitRate: PresetHookResult
|
||||
queryPerformanceQuery: DbQueryHook<any>
|
||||
}
|
||||
|
||||
export const QueryPerformance = ({
|
||||
queryHitRate,
|
||||
queryPerformanceQuery,
|
||||
}: QueryPerformanceProps) => {
|
||||
const router = useRouter()
|
||||
const { preset } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const [page, setPage] = useState<QUERY_PERFORMANCE_REPORT_TYPES>(
|
||||
(preset as QUERY_PERFORMANCE_REPORT_TYPES) ?? QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING
|
||||
)
|
||||
const [showResetgPgStatStatements, setShowResetgPgStatStatements] = useState(false)
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryPerformanceQuery.runQuery()
|
||||
queryHitRate.runQuery()
|
||||
}
|
||||
|
||||
const { data: mostTimeConsumingQueries, isLoading: isLoadingMTC } = useQueryPerformanceQuery({
|
||||
preset: 'mostTimeConsuming',
|
||||
})
|
||||
const { data: mostFrequentlyInvoked, isLoading: isLoadingMFI } = useQueryPerformanceQuery({
|
||||
preset: 'mostFrequentlyInvoked',
|
||||
})
|
||||
const { data: slowestExecutionTime, isLoading: isLoadingMMF } = useQueryPerformanceQuery({
|
||||
preset: 'slowestExecutionTime',
|
||||
})
|
||||
|
||||
const QUERY_PERFORMANCE_TABS = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING,
|
||||
label: 'Most time consuming',
|
||||
description: 'Lists queries ordered by their cumulative total execution time.',
|
||||
isLoading: isLoadingMTC,
|
||||
max:
|
||||
(mostTimeConsumingQueries ?? []).length > 0
|
||||
? Math.max(...(mostTimeConsumingQueries ?? []).map((x: any) => x.total_time)).toFixed(2)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT,
|
||||
label: 'Most frequent',
|
||||
description: 'Lists queries in order of their execution count',
|
||||
isLoading: isLoadingMFI,
|
||||
max:
|
||||
(mostFrequentlyInvoked ?? []).length > 0
|
||||
? Math.max(...(mostFrequentlyInvoked ?? []).map((x: any) => x.calls)).toFixed(2)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: QUERY_PERFORMANCE_REPORT_TYPES.SLOWEST_EXECUTION,
|
||||
label: 'Slowest execution',
|
||||
description: 'Lists queries ordered by their maximum execution time',
|
||||
isLoading: isLoadingMMF,
|
||||
max:
|
||||
(slowestExecutionTime ?? []).length > 0
|
||||
? Math.max(...(slowestExecutionTime ?? []).map((x: any) => x.max_time)).toFixed(2)
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
}, [
|
||||
isLoadingMFI,
|
||||
isLoadingMMF,
|
||||
isLoadingMTC,
|
||||
mostFrequentlyInvoked,
|
||||
mostTimeConsumingQueries,
|
||||
slowestExecutionTime,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs_Shadcn_
|
||||
defaultValue={page}
|
||||
onValueChange={(value) => {
|
||||
setPage(value as QUERY_PERFORMANCE_REPORT_TYPES)
|
||||
const { sort, search, ...rest } = router.query
|
||||
router.push({ ...router, query: { ...rest, preset: value } })
|
||||
}}
|
||||
>
|
||||
<TabsList_Shadcn_ className={cn('flex gap-0 border-0 items-end z-10')}>
|
||||
{QUERY_PERFORMANCE_TABS.map((tab) => (
|
||||
<TabsTrigger_Shadcn_
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className={cn(
|
||||
'group',
|
||||
'relative',
|
||||
'px-6 py-3 border-b-0 flex flex-col items-start !shadow-none border-default border-t',
|
||||
'even:border-x last:border-r even:!border-x-strong last:!border-r-strong',
|
||||
tab.id === page ? '!bg-surface-200' : '!bg-surface-200/[33%]',
|
||||
'hover:!bg-surface-100',
|
||||
'data-[state=active]:!bg-surface-200',
|
||||
'hover:text-foreground-light',
|
||||
'transition'
|
||||
)}
|
||||
>
|
||||
{tab.id === page && (
|
||||
<div className="absolute top-0 left-0 w-full h-[1px] bg-foreground"></div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="">{tab.label}</span>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<InformationCircleIcon className="transition text-foreground-muted w-3 h-3 data-[state=delayed-open]:text-foreground-light" />
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="top">{tab.description}</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
{tab.isLoading ? (
|
||||
<ShimmeringLoader className="w-32 pt-1" />
|
||||
) : tab.max === undefined ? (
|
||||
<span className="text-xs text-foreground-muted group-hover:text-foreground-lighter group-data-[state=active]:text-foreground-lighter transition">
|
||||
No data yet
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-foreground-muted group-hover:text-foreground-lighter group-data-[state=active]:text-foreground-lighter transition">
|
||||
{Number(tab.max).toLocaleString()}
|
||||
{tab.id !== QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT ? 'ms' : ' calls'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{tab.id === page && (
|
||||
<div className="absolute bottom-0 left-0 w-full h-[1px] bg-surface-200"></div>
|
||||
)}
|
||||
</TabsTrigger_Shadcn_>
|
||||
))}
|
||||
</TabsList_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
|
||||
<div className="px-6 py-3 bg-surface-200 border-t -mt-px">
|
||||
<QueryPerformanceFilterBar queryPerformanceQuery={queryPerformanceQuery} />
|
||||
</div>
|
||||
|
||||
<QueryPerformanceGrid queryPerformanceQuery={queryPerformanceQuery} />
|
||||
|
||||
<div className="px-6 py-6 flex gap-x-4 border-t">
|
||||
<div className="w-[35%] flex flex-col gap-y-1 text-sm">
|
||||
<p>Reset report</p>
|
||||
<p className="text-xs text-foreground-light">
|
||||
Consider resetting the analysis after optimizing any queries
|
||||
</p>
|
||||
<Button
|
||||
type="default"
|
||||
className="!mt-3 w-min"
|
||||
onClick={() => setShowResetgPgStatStatements(true)}
|
||||
>
|
||||
Reset report
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-[35%] flex flex-col gap-y-1 text-sm">
|
||||
<p>How is this report generated?</p>
|
||||
<Markdown
|
||||
className="text-xs"
|
||||
content="This report uses the pg_stat_statements table, and pg_stat_statements extension. [Learn more here](https://supabase.com/docs/guides/platform/performance#examining-query-performance)."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
danger
|
||||
visible={showResetgPgStatStatements}
|
||||
title="Reset query performance analysis"
|
||||
description={
|
||||
'This will reset the `extensions.pg_stat_statements` table that is used to calculate query performance. This data will repopulate immediately after.'
|
||||
}
|
||||
buttonLabel="Clear table"
|
||||
buttonLoadingLabel="Deleting"
|
||||
onSelectCancel={() => setShowResetgPgStatStatements(false)}
|
||||
onSelectConfirm={async () => {
|
||||
try {
|
||||
await executeSql({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
sql: `SELECT pg_stat_statements_reset();`,
|
||||
})
|
||||
handleRefresh()
|
||||
setShowResetgPgStatStatements(false)
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to reset analysis: ${error.message}`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+61
-47
@@ -5,6 +5,8 @@ import { useState } from 'react'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { FilterPopover } from 'components/ui/FilterPopover'
|
||||
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
|
||||
import { useFlag } from 'hooks'
|
||||
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -14,43 +16,39 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import { TextSearchPopover } from './TextSearchPopover'
|
||||
import { QueryPerformanceSort } from '../Reports/Reports.queries'
|
||||
|
||||
export const QueryPerformanceFilterBar = ({
|
||||
isLoading,
|
||||
onRefreshClick,
|
||||
queryPerformanceQuery,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
onRefreshClick: () => void
|
||||
queryPerformanceQuery: DbQueryHook<any>
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { project } = useProjectContext()
|
||||
const enableQueryPerformanceV2 = useFlag('queryPerformanceV2')
|
||||
const defaultSearchQueryValue = router.query.search ? String(router.query.search) : ''
|
||||
const defaultSortByValue = router.query.sort ? String(router.query.sort) : 'lat_desc'
|
||||
const defaultFilterRoles = router.query.roles ? (router.query.roles as string[]) : []
|
||||
const defaultSortByValue = router.query.sort
|
||||
? ({ column: router.query.sort, order: router.query.order } as QueryPerformanceSort)
|
||||
: undefined
|
||||
|
||||
const [sortByValue, setSortByValue] = useState(defaultSortByValue)
|
||||
const [searchInputVal, setSearchInputVal] = useState(defaultSearchQueryValue)
|
||||
const [filters, setFilters] = useState<{ roles: string[]; query: string }>({
|
||||
roles: typeof defaultFilterRoles === 'string' ? [defaultFilterRoles] : defaultFilterRoles,
|
||||
query: '',
|
||||
})
|
||||
// [Joshen] This is for the old UI, can deprecated after
|
||||
const [sortByValue, setSortByValue] = useState<QueryPerformanceSort>(
|
||||
defaultSortByValue ?? { column: 'prop_total_time', order: 'desc' }
|
||||
)
|
||||
|
||||
const { isLoading, isRefetching } = queryPerformanceQuery
|
||||
const { data, isLoading: isLoadingRoles } = useDatabaseRolesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const roles = (data ?? []).sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
function getSortButtonLabel() {
|
||||
const sort = router.query.sort as 'lat_desc' | 'lat_asc'
|
||||
|
||||
if (sort === 'lat_desc') {
|
||||
return 'Sorted by latency - high to low'
|
||||
} else {
|
||||
return 'Sorted by latency - low to high'
|
||||
}
|
||||
}
|
||||
|
||||
const onSearchQueryChange = (value: string) => {
|
||||
setSearchInputVal(value)
|
||||
|
||||
@@ -68,15 +66,21 @@ export const QueryPerformanceFilterBar = ({
|
||||
router.push({ ...router, query: { ...router.query, roles } })
|
||||
}
|
||||
|
||||
const onSortChange = (sort: string) => {
|
||||
setSortByValue(sort)
|
||||
router.push({ ...router, query: { ...router.query, sort } })
|
||||
function getSortButtonLabel() {
|
||||
if (defaultSortByValue?.order === 'desc') {
|
||||
return 'Sorted by latency - high to low'
|
||||
} else {
|
||||
return 'Sorted by latency - low to high'
|
||||
}
|
||||
}
|
||||
|
||||
const ButtonIcon = sortByValue === 'lat_desc' ? ArrowDown : ArrowUp
|
||||
const onSortChange = (order: 'asc' | 'desc') => {
|
||||
setSortByValue({ column: 'prop_total_time', order })
|
||||
router.push({ ...router, query: { ...router.query, sort: 'prop_total_time', order } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-xs prose">Filter by</p>
|
||||
@@ -89,44 +93,54 @@ export const QueryPerformanceFilterBar = ({
|
||||
onSaveFilters={onFilterRolesChange}
|
||||
/>
|
||||
<TextSearchPopover name="Query" value={searchInputVal} onSaveText={onSearchQueryChange} />
|
||||
|
||||
{!enableQueryPerformanceV2 && (
|
||||
<>
|
||||
<div className="border-r border-strong h-6" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={sortByValue?.order === 'desc' ? <ArrowDown /> : <ArrowUp />}>
|
||||
{getSortButtonLabel()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortByValue?.order}
|
||||
onValueChange={(value: any) => onSortChange(value)}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value="desc"
|
||||
defaultChecked={sortByValue?.order === 'desc'}
|
||||
>
|
||||
Sort by latency - high to low
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="asc"
|
||||
defaultChecked={sortByValue?.order === 'asc'}
|
||||
>
|
||||
Sort by latency - low to high
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-r border-strong h-6" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={<ButtonIcon />}>{getSortButtonLabel()}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuRadioGroup value={sortByValue} onValueChange={onSortChange}>
|
||||
<DropdownMenuRadioItem
|
||||
value="lat_desc"
|
||||
defaultChecked={router.query.sort === 'lat_desc'}
|
||||
>
|
||||
Sort by latency - high to low
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="lat_asc"
|
||||
defaultChecked={router.query.sort === 'lat_asc'}
|
||||
>
|
||||
Sort by latency - low to high
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
onClick={onRefreshClick}
|
||||
disabled={isLoading ? true : false}
|
||||
onClick={() => queryPerformanceQuery.runQuery()}
|
||||
disabled={isLoading || isRefetching}
|
||||
icon={
|
||||
<RefreshCw
|
||||
size={12}
|
||||
className={`text-foreground-light ${isLoading ? 'animate-spin' : ''}`}
|
||||
className={`text-foreground-light ${isLoading || isRefetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLoading ? 'Refreshing' : 'Refresh'}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
import { ArrowDown, ArrowUp, TextSearch, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import DataGrid, { Column } from 'react-data-grid'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
|
||||
import {
|
||||
Button,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
TabsContent_Shadcn_,
|
||||
TabsList_Shadcn_,
|
||||
TabsTrigger_Shadcn_,
|
||||
Tabs_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import { QueryPerformanceSort } from '../Reports/Reports.queries'
|
||||
import { IndexSuggestion } from './IndexSuggestion'
|
||||
import { QueryDetail } from './QueryDetail'
|
||||
import {
|
||||
QUERY_PERFORMANCE_REPORTS,
|
||||
QUERY_PERFORMANCE_REPORT_TYPES,
|
||||
} from './QueryPerformance.constants'
|
||||
|
||||
interface QueryPerformanceGridProps {
|
||||
queryPerformanceQuery: DbQueryHook<any>
|
||||
}
|
||||
|
||||
export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformanceGridProps) => {
|
||||
// [Joshen] This will come in another PR to integrate index advisor
|
||||
const showIndexSuggestions = false
|
||||
|
||||
const router = useRouter()
|
||||
const { preset } = useParams()
|
||||
const { isLoading } = queryPerformanceQuery
|
||||
|
||||
const defaultSortValue = router.query.sort
|
||||
? ({ column: router.query.sort, order: router.query.order } as QueryPerformanceSort)
|
||||
: undefined
|
||||
|
||||
const [view, setView] = useState<'details' | 'suggestion'>('details')
|
||||
const [sort, setSort] = useState<QueryPerformanceSort | undefined>(defaultSortValue)
|
||||
const [selectedRow, setSelectedRow] = useState<number>()
|
||||
const reportType =
|
||||
(preset as QUERY_PERFORMANCE_REPORT_TYPES) ?? QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING
|
||||
|
||||
const columns = QUERY_PERFORMANCE_REPORTS[reportType].map((col) => {
|
||||
const result: Column<any> = {
|
||||
key: col.id,
|
||||
name: col.name,
|
||||
resizable: true,
|
||||
minWidth: col.minWidth ?? 120,
|
||||
headerCellClass: 'first:pl-6 cursor-pointer',
|
||||
renderHeaderCell: () => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between font-mono font-normal text-xs w-full"
|
||||
onClick={() => onSortChange(col.id)}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="!text-foreground">{col.name}</p>
|
||||
{col.description && <p className="text-foreground-lighter">{col.description}</p>}
|
||||
</div>
|
||||
{sort?.column === col.id && (
|
||||
<>{sort.order === 'desc' ? <ArrowDown size={14} /> : <ArrowUp size={14} />}</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
renderCell: (props) => {
|
||||
const value = props.row?.[col.id]
|
||||
const isTime = col.name.includes('time')
|
||||
const formattedValue = isTime ? `${value.toFixed(2)}ms` : String(value)
|
||||
return (
|
||||
<div className="flex flex-col justify-center font-mono text-xs">
|
||||
<p>{formattedValue}</p>
|
||||
{isTime && <p className="text-foreground-lighter">{(value / 1000).toFixed(2)}s</p>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const onSortChange = (column: string) => {
|
||||
let updatedSort = undefined
|
||||
|
||||
if (sort?.column === column) {
|
||||
if (sort.order === 'desc') {
|
||||
updatedSort = { column, order: 'asc' }
|
||||
} else {
|
||||
updatedSort = undefined
|
||||
}
|
||||
} else {
|
||||
updatedSort = { column, order: 'desc' }
|
||||
}
|
||||
|
||||
setSort(updatedSort as QueryPerformanceSort)
|
||||
|
||||
if (updatedSort === undefined) {
|
||||
const { sort, order, ...otherParams } = router.query
|
||||
router.push({ ...router, query: otherParams })
|
||||
} else {
|
||||
router.push({
|
||||
...router,
|
||||
query: { ...router.query, sort: updatedSort.column, order: updatedSort.order },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRow(undefined)
|
||||
}, [preset])
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="relative flex flex-grow bg-alternative min-h-0"
|
||||
autoSaveId="query-performance-layout-v1"
|
||||
>
|
||||
<ResizablePanel defaultSize={1}>
|
||||
<DataGrid
|
||||
style={{ height: '100%' }}
|
||||
className={cn('flex-1 flex-grow h-full')}
|
||||
rowHeight={44}
|
||||
headerRowHeight={36}
|
||||
onSelectedCellChange={(props) => {
|
||||
const { rowIdx } = props
|
||||
if (rowIdx >= 0) setSelectedRow(rowIdx)
|
||||
}}
|
||||
columns={columns}
|
||||
rows={queryPerformanceQuery?.data ?? []}
|
||||
rowClass={(_, idx) => {
|
||||
const isSelected = idx === selectedRow
|
||||
return [
|
||||
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
|
||||
`${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground' : ''}`,
|
||||
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
|
||||
'[&>.rdg-cell:first-child>div]:ml-4',
|
||||
].join(' ')
|
||||
}}
|
||||
renderers={{
|
||||
noRowsFallback: isLoading ? (
|
||||
<div className="absolute top-14 px-6 w-full">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : (
|
||||
<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 detected yet</p>
|
||||
<p className="text-foreground-light">
|
||||
There are no queries actively running that meet the criteria
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
{selectedRow !== undefined && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={20} maxSize={40} minSize={25} className="bg-studio border-t">
|
||||
<Button
|
||||
type="text"
|
||||
className="absolute top-3 right-3 px-1"
|
||||
icon={<X size={14} />}
|
||||
onClick={() => setSelectedRow(undefined)}
|
||||
/>
|
||||
<Tabs_Shadcn_
|
||||
className="flex flex-col h-full"
|
||||
defaultValue={view}
|
||||
onValueChange={(value: any) => setView(value)}
|
||||
>
|
||||
<TabsList_Shadcn_ className="px-5 flex gap-x-4 h-12">
|
||||
<TabsTrigger_Shadcn_ value="details" className="px-0 h-full">
|
||||
Query details
|
||||
</TabsTrigger_Shadcn_>
|
||||
{showIndexSuggestions && (
|
||||
<TabsTrigger_Shadcn_ value="suggestion" className="px-0 h-full">
|
||||
Index suggestion
|
||||
</TabsTrigger_Shadcn_>
|
||||
)}
|
||||
</TabsList_Shadcn_>
|
||||
<TabsContent_Shadcn_
|
||||
value="details"
|
||||
className="mt-0 pt-4 flex-grow min-h-0 overflow-y-auto"
|
||||
>
|
||||
<QueryDetail
|
||||
reportType={reportType}
|
||||
selectedRow={queryPerformanceQuery.data[selectedRow]}
|
||||
/>
|
||||
</TabsContent_Shadcn_>
|
||||
<TabsContent_Shadcn_
|
||||
value="suggestion"
|
||||
className="mt-0 pt-4 flex-grow min-h-0 overflow-y-auto"
|
||||
>
|
||||
<IndexSuggestion />
|
||||
</TabsContent_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
File renamed without changes.
@@ -297,7 +297,7 @@ select
|
||||
inner join pg_authid as auth on statements.userid = auth.oid
|
||||
${where || ''}
|
||||
${orderBy || 'order by statements.calls desc'}
|
||||
limit 10;`,
|
||||
limit 20;`,
|
||||
},
|
||||
mostTimeConsuming: {
|
||||
queryType: 'db',
|
||||
@@ -313,7 +313,7 @@ select
|
||||
inner join pg_authid as auth on statements.userid = auth.oid
|
||||
${where || ''}
|
||||
${orderBy || 'order by total_time desc'}
|
||||
limit 10;`,
|
||||
limit 20;`,
|
||||
},
|
||||
slowestExecutionTime: {
|
||||
queryType: 'db',
|
||||
@@ -338,7 +338,7 @@ select
|
||||
inner join pg_authid as auth on statements.userid = auth.oid
|
||||
${where || ''}
|
||||
${orderBy || 'order by max_time desc'}
|
||||
limit 10`,
|
||||
limit 20`,
|
||||
},
|
||||
queryHitRate: {
|
||||
queryType: 'db',
|
||||
|
||||
@@ -2,26 +2,34 @@ import { PRESET_CONFIG } from './Reports.constants'
|
||||
import { Presets } from './Reports.types'
|
||||
import useDbQuery from 'hooks/analytics/useDbQuery'
|
||||
|
||||
export type QueryPerformanceSort = {
|
||||
column:
|
||||
| 'total_time'
|
||||
| 'prop_total_time'
|
||||
| 'calls'
|
||||
| 'avg_rows'
|
||||
| 'max_time'
|
||||
| 'mean_time'
|
||||
| 'min_time'
|
||||
order: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
type QueryPerformanceQueryOpts = {
|
||||
searchQuery: string
|
||||
preset: 'mostFrequentlyInvoked' | 'mostTimeConsuming' | 'slowestExecutionTime' | 'queryHitRate'
|
||||
orderBy: string | 'lat_asc' | 'lat_desc'
|
||||
searchQuery?: string
|
||||
orderBy?: QueryPerformanceSort
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
export const useQueryPerformanceQuery = ({
|
||||
preset,
|
||||
orderBy,
|
||||
searchQuery,
|
||||
orderBy = { column: 'total_time', order: 'desc' },
|
||||
searchQuery = '',
|
||||
roles,
|
||||
}: QueryPerformanceQueryOpts) => {
|
||||
const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
|
||||
const baseSQL = queryPerfQueries.queries[preset]
|
||||
|
||||
if (orderBy !== 'lat_asc' && orderBy !== 'lat_desc') {
|
||||
// Default to lat_desc if not specified or invalid
|
||||
orderBy = 'lat_desc'
|
||||
}
|
||||
|
||||
const whereSql = [
|
||||
roles !== undefined && roles.length > 0
|
||||
? `WHERE auth.rolname in (${roles.map((r) => `'${r}'`).join(', ')})`
|
||||
@@ -31,7 +39,10 @@ export const useQueryPerformanceQuery = ({
|
||||
.filter((x) => x.length > 0)
|
||||
.join(' OR ')
|
||||
|
||||
const orderBySql = orderBy === 'lat_asc' ? 'ORDER BY total_time asc' : 'ORDER BY total_time desc'
|
||||
// [Joshen] TODO: Support ordering on more columns
|
||||
// calls, total_time, prop_total_time, avg_rows, max_time, mean_time, min_time
|
||||
// const orderBySql = orderBy === 'lat_asc' ? 'ORDER BY total_time asc' : 'ORDER BY total_time desc'
|
||||
const orderBySql = `ORDER BY ${orderBy.column} ${orderBy.order}`
|
||||
|
||||
const sql = baseSQL.sql([], whereSql, orderBySql)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ interface FilterPopoverProps {
|
||||
valueKey: string
|
||||
labelKey: string
|
||||
name: string
|
||||
variant?: 'rectangular' | 'rounded'
|
||||
onSaveFilters: (options: string[]) => void
|
||||
}
|
||||
|
||||
@@ -19,6 +20,7 @@ export const FilterPopover = ({
|
||||
valueKey,
|
||||
labelKey,
|
||||
name,
|
||||
variant = 'rectangular',
|
||||
onSaveFilters,
|
||||
}: FilterPopoverProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -88,6 +90,7 @@ export const FilterPopover = ({
|
||||
asChild
|
||||
type={activeOptions.length > 0 ? 'default' : 'dashed'}
|
||||
onClick={() => setOpen(false)}
|
||||
className={variant === 'rounded' ? 'rounded-full' : ''}
|
||||
>
|
||||
<div>
|
||||
<span>{name}</span>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
// [Joshen] This is just temporary while we wait for Index Advisor to be baked into the image itself
|
||||
|
||||
export type IndexAdvisorEnableVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string
|
||||
}
|
||||
|
||||
export async function enableIndexAdvisor({
|
||||
projectRef,
|
||||
connectionString,
|
||||
}: IndexAdvisorEnableVariables) {
|
||||
const sql = `
|
||||
create or replace function index_advisor(
|
||||
query text
|
||||
)
|
||||
returns table (
|
||||
startup_cost_before jsonb,
|
||||
startup_cost_after jsonb,
|
||||
total_cost_before jsonb,
|
||||
total_cost_after jsonb,
|
||||
index_statements text[],
|
||||
errors text[]
|
||||
)
|
||||
volatile
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
n_args int;
|
||||
prepared_statement_name text = 'index_advisor_working_statement';
|
||||
hypopg_schema_name text = (select extnamespace::regnamespace::text from pg_extension where extname = 'hypopg');
|
||||
explain_plan_statement text;
|
||||
error_message text;
|
||||
rec record;
|
||||
plan_initial jsonb;
|
||||
plan_final jsonb;
|
||||
statements text[] = '{}';
|
||||
begin
|
||||
|
||||
-- Remove comment lines (its common that they contain semicolons)
|
||||
query := trim(
|
||||
regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(query,'\/\*.+\*\/', '', 'g'),
|
||||
'--[^\r\n]*', ' ', 'g'),
|
||||
'\s+', ' ', 'g')
|
||||
);
|
||||
|
||||
-- Remove trailing semicolon
|
||||
query := regexp_replace(query, ';\s*$', '');
|
||||
|
||||
begin
|
||||
-- Disallow multiple statements
|
||||
if query ilike '%;%' then
|
||||
raise exception 'Query must not contain a semicolon';
|
||||
end if;
|
||||
|
||||
-- Hack to support PostgREST because the prepared statement for args incorrectly defaults to text
|
||||
query := replace(query, 'WITH pgrst_payload AS (SELECT $1 AS json_data)', 'WITH pgrst_payload AS (SELECT $1::json AS json_data)');
|
||||
|
||||
-- Create a prepared statement for the given query
|
||||
deallocate all;
|
||||
execute format('prepare %I as %s', prepared_statement_name, query);
|
||||
|
||||
-- Detect how many arguments are present in the prepared statement
|
||||
n_args = (
|
||||
select
|
||||
coalesce(array_length(parameter_types, 1), 0)
|
||||
from
|
||||
pg_prepared_statements
|
||||
where
|
||||
name = prepared_statement_name
|
||||
limit
|
||||
1
|
||||
);
|
||||
|
||||
-- Create a SQL statement that can be executed to collect the explain plan
|
||||
explain_plan_statement = format(
|
||||
'set local plan_cache_mode = force_generic_plan; explain (format json) execute %I%s',
|
||||
--'explain (format json) execute %I%s',
|
||||
prepared_statement_name,
|
||||
case
|
||||
when n_args = 0 then ''
|
||||
else format(
|
||||
'(%s)', array_to_string(array_fill('null'::text, array[n_args]), ',')
|
||||
)
|
||||
end
|
||||
);
|
||||
|
||||
-- Store the query plan before any new indexes
|
||||
execute explain_plan_statement into plan_initial;
|
||||
|
||||
-- Create possible indexes
|
||||
for rec in (
|
||||
with extension_regclass as (
|
||||
select
|
||||
distinct objid as oid
|
||||
from
|
||||
pg_catalog.pg_depend
|
||||
where
|
||||
deptype = 'e'
|
||||
)
|
||||
select
|
||||
pc.relnamespace::regnamespace::text as schema_name,
|
||||
pc.relname as table_name,
|
||||
pa.attname as column_name,
|
||||
format(
|
||||
'select %I.hypopg_create_index($i$create index on %I.%I(%I)$i$)',
|
||||
hypopg_schema_name,
|
||||
pc.relnamespace::regnamespace::text,
|
||||
pc.relname,
|
||||
pa.attname
|
||||
) hypopg_statement
|
||||
from
|
||||
pg_catalog.pg_class pc
|
||||
join pg_catalog.pg_attribute pa
|
||||
on pc.oid = pa.attrelid
|
||||
left join extension_regclass er
|
||||
on pc.oid = er.oid
|
||||
left join pg_catalog.pg_index pi
|
||||
on pc.oid = pi.indrelid
|
||||
and (select array_agg(x) from unnest(pi.indkey) v(x)) = array[pa.attnum]
|
||||
and pi.indexprs is null -- ignore expression indexes
|
||||
and pi.indpred is null -- ignore partial indexes
|
||||
where
|
||||
pc.relnamespace::regnamespace::text not in ( -- ignore schema list
|
||||
'pg_catalog', 'pg_toast', 'information_schema'
|
||||
)
|
||||
and er.oid is null -- ignore entities owned by extensions
|
||||
and pc.relkind in ('r', 'm') -- regular tables, and materialized views
|
||||
and pc.relpersistence = 'p' -- permanent tables (not unlogged or temporary)
|
||||
and pa.attnum > 0
|
||||
and not pa.attisdropped
|
||||
and pi.indrelid is null
|
||||
and pa.atttypid in (20,16,1082,1184,1114,701,23,21,700,1083,2950,1700,25,18,1042,1043)
|
||||
)
|
||||
loop
|
||||
-- Create the hypothetical index
|
||||
execute rec.hypopg_statement;
|
||||
end loop;
|
||||
|
||||
/*
|
||||
for rec in select * from hypopg()
|
||||
loop
|
||||
raise notice '%', rec;
|
||||
end loop;
|
||||
*/
|
||||
|
||||
-- Create a prepared statement for the given query
|
||||
-- The original prepared statement MUST be dropped because its plan is cached
|
||||
execute format('deallocate %I', prepared_statement_name);
|
||||
execute format('prepare %I as %s', prepared_statement_name, query);
|
||||
|
||||
-- Store the query plan after new indexes
|
||||
execute explain_plan_statement into plan_final;
|
||||
|
||||
--raise notice '%', plan_final;
|
||||
|
||||
-- Identify referenced indexes in new plan
|
||||
execute format(
|
||||
'select
|
||||
coalesce(array_agg(hypopg_get_indexdef(indexrelid) order by indrelid, indkey::text), $i\${}$i$::text[])
|
||||
from
|
||||
%I.hypopg()
|
||||
where
|
||||
%s ilike ($i$%%$i$ || indexname || $i$%%$i$)
|
||||
',
|
||||
hypopg_schema_name,
|
||||
quote_literal(plan_final)::text
|
||||
) into statements;
|
||||
|
||||
-- Reset all hypothetical indexes
|
||||
perform hypopg_reset();
|
||||
|
||||
-- Reset prepared statements
|
||||
deallocate all;
|
||||
|
||||
return query values (
|
||||
(plan_initial -> 0 -> 'Plan' -> 'Startup Cost'),
|
||||
(plan_final -> 0 -> 'Plan' -> 'Startup Cost'),
|
||||
(plan_initial -> 0 -> 'Plan' -> 'Total Cost'),
|
||||
(plan_final -> 0 -> 'Plan' -> 'Total Cost'),
|
||||
statements::text[],
|
||||
array[]::text[]
|
||||
);
|
||||
return;
|
||||
|
||||
exception when others then
|
||||
get stacked diagnostics error_message = MESSAGE_TEXT;
|
||||
|
||||
return query values (
|
||||
null::jsonb,
|
||||
null::jsonb,
|
||||
null::jsonb,
|
||||
null::jsonb,
|
||||
array[]::text[],
|
||||
array[error_message]::text[]
|
||||
);
|
||||
return;
|
||||
end;
|
||||
|
||||
end;
|
||||
$$;
|
||||
`
|
||||
const { result } = await executeSql({ projectRef, connectionString, sql })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type IndexAdvisorEnableData = Awaited<ReturnType<typeof enableIndexAdvisor>>
|
||||
|
||||
export const useIndexAdvisorEnableMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<IndexAdvisorEnableData, ResponseError, IndexAdvisorEnableVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
return useMutation<IndexAdvisorEnableData, ResponseError, IndexAdvisorEnableVariables>(
|
||||
(vars) => enableIndexAdvisor(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to enable index advisor: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -21,10 +21,7 @@ export async function formatQuery(
|
||||
{ query: sql },
|
||||
{ headers: Object.fromEntries(headers), signal }
|
||||
)
|
||||
if (response.error) {
|
||||
throw response.error
|
||||
}
|
||||
|
||||
if (response.error) throw response.error
|
||||
return { result: response }
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { executeSql } from 'data/sql/execute-sql-query'
|
||||
|
||||
export interface DbQueryHook<T = any> {
|
||||
isLoading: boolean
|
||||
isRefetching: boolean
|
||||
error: string
|
||||
data: T[]
|
||||
params: BaseReportParams
|
||||
@@ -59,7 +60,8 @@ const useDbQuery = (
|
||||
return {
|
||||
error,
|
||||
data,
|
||||
isLoading: isLoading || isRefetching,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
params,
|
||||
runQuery: refetch,
|
||||
resolvedSql,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@graphiql/toolkit": "^0.9.1",
|
||||
"@hcaptcha/react-hcaptcha": "^1.8.1",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@next/bundle-analyzer": "^14.1.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { IndexEfficiencyNotice } from 'components/interfaces/QueryPerformance/IndexEfficiencyNotice'
|
||||
import { QueryPerformance } from 'components/interfaces/QueryPerformance/QueryPerformance'
|
||||
import { QueryPerformance as QueryPerformanceV1 } from 'components/interfaces/QueryPerformance/QueryPerformance'
|
||||
import { QueryPerformance } from 'components/interfaces/QueryPerformanceV2/QueryPerformance'
|
||||
import { QUERY_PERFORMANCE_REPORT_TYPES } from 'components/interfaces/QueryPerformanceV2/QueryPerformance.constants'
|
||||
import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants'
|
||||
import { useQueryPerformanceQuery } from 'components/interfaces/Reports/Reports.queries'
|
||||
import {
|
||||
QueryPerformanceSort,
|
||||
useQueryPerformanceQuery,
|
||||
} from 'components/interfaces/Reports/Reports.queries'
|
||||
import { Presets } from 'components/interfaces/Reports/Reports.types'
|
||||
import { queriesFactory } from 'components/interfaces/Reports/Reports.utils'
|
||||
import { DatabaseLayout } from 'components/layouts'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import { FormHeader } from 'components/ui/Forms'
|
||||
import { useFlag } from 'hooks'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
type QueryPerformancePreset = 'time' | 'frequent' | 'slowest'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
|
||||
const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
const enableQueryPerformanceV2 = useFlag('queryPerformanceV2')
|
||||
|
||||
// [Joshen] Has been false on configcat for a long time
|
||||
const tableIndexEfficiencyEnabled = useFlag('tableIndexEfficiency')
|
||||
const config = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
|
||||
const hooks = queriesFactory(config.queries, projectRef ?? 'default')
|
||||
const queryHitRate = hooks.queryHitRate()
|
||||
|
||||
const orderBy = (router.query.sort as 'lat_desc' | 'lat_asc') || 'lat_desc'
|
||||
const orderBy = router.query.sort
|
||||
? ({ column: router.query.sort, order: router.query.order } as QueryPerformanceSort)
|
||||
: undefined
|
||||
const searchQuery = (router.query.search as string) || ''
|
||||
const roles = router.query.roles || []
|
||||
const presetMap = {
|
||||
time: 'mostTimeConsuming',
|
||||
frequent: 'mostFrequentlyInvoked',
|
||||
slowest: 'slowestExecutionTime',
|
||||
[QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING]: 'mostTimeConsuming',
|
||||
[QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT]: 'mostFrequentlyInvoked',
|
||||
[QUERY_PERFORMANCE_REPORT_TYPES.SLOWEST_EXECUTION]: 'slowestExecutionTime',
|
||||
} as const
|
||||
const preset = presetMap[router.query.preset as QueryPerformancePreset] || 'mostTimeConsuming'
|
||||
const preset =
|
||||
presetMap[router.query.preset as QUERY_PERFORMANCE_REPORT_TYPES] || 'mostTimeConsuming'
|
||||
|
||||
const queryPerformanceQuery = useQueryPerformanceQuery({
|
||||
searchQuery,
|
||||
@@ -41,31 +48,40 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
roles: typeof roles === 'string' ? [roles] : roles,
|
||||
})
|
||||
|
||||
const isLoading = [queryPerformanceQuery.isLoading, queryHitRate.isLoading].every(
|
||||
(value) => value
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection>
|
||||
<div className="col-span-12 flex flex-col gap-y-4">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* [Joshen] Need to double check what this is about and if it's still relevant */}
|
||||
{/* {tableIndexEfficiencyEnabled && <IndexEfficiencyNotice isLoading={isLoading} />} */}
|
||||
|
||||
{enableQueryPerformanceV2 ? (
|
||||
<>
|
||||
<FormHeader
|
||||
className="py-4 px-6 !mb-0"
|
||||
title="Query Performance"
|
||||
docsUrl="https://supabase.com/docs/guides/platform/performance#examining-query-performance"
|
||||
/>
|
||||
<QueryPerformance
|
||||
queryHitRate={queryHitRate}
|
||||
queryPerformanceQuery={queryPerformanceQuery}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection className="!grid-cols-1">
|
||||
<FormHeader
|
||||
className="!mb-0"
|
||||
title="Query Performance"
|
||||
description="Identify queries that consume the most time and database resources via the `pg_stat_statements` table"
|
||||
docsUrl="https://supabase.com/docs/guides/platform/performance#examining-query-performance"
|
||||
/>
|
||||
|
||||
{tableIndexEfficiencyEnabled && <IndexEfficiencyNotice isLoading={isLoading} />}
|
||||
<QueryPerformance
|
||||
<QueryPerformanceV1
|
||||
queryHitRate={queryHitRate}
|
||||
queryPerformanceQuery={queryPerformanceQuery}
|
||||
/>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
</>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
|
||||
.rdg-row {
|
||||
@apply bg-studio transition-colors;
|
||||
@apply hover:bg-surface-100;
|
||||
@apply hover:bg-surface-200;
|
||||
}
|
||||
|
||||
/* edit button */
|
||||
|
||||
Generated
+9
@@ -2105,6 +2105,7 @@
|
||||
"@graphiql/toolkit": "^0.9.1",
|
||||
"@hcaptcha/react-hcaptcha": "^1.8.1",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@next/bundle-analyzer": "^14.1.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
@@ -2236,6 +2237,14 @@
|
||||
"typescript": "^5.4.3"
|
||||
}
|
||||
},
|
||||
"apps/studio/node_modules/@heroicons/react": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.1.3.tgz",
|
||||
"integrity": "sha512-fEcPfo4oN345SoqdlCDdSa4ivjaKbk0jTd+oubcgNxnNgAfzysfwWfQUr+51wigiWHQQRiZNd1Ao0M5Y3M2EGg==",
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"apps/studio/node_modules/@jest/types": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz",
|
||||
|
||||
@@ -74,6 +74,7 @@ function Group({
|
||||
return (
|
||||
<Checkbox
|
||||
id={option.id}
|
||||
key={option.id}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
beforeLabel={option.beforeLabel}
|
||||
@@ -119,13 +120,13 @@ export function Checkbox({
|
||||
const markupId = id
|
||||
? id
|
||||
: name
|
||||
? name
|
||||
: label
|
||||
? label
|
||||
.toLowerCase()
|
||||
.replace(/^[^A-Z0-9]+/gi, '')
|
||||
.replace(/ /g, '-')
|
||||
: undefined
|
||||
? name
|
||||
: label
|
||||
? label
|
||||
.toLowerCase()
|
||||
.replace(/^[^A-Z0-9]+/gi, '')
|
||||
.replace(/ /g, '-')
|
||||
: undefined
|
||||
|
||||
// @ts-ignore
|
||||
size = parentSize ? parentSize : size
|
||||
|
||||
@@ -16,6 +16,7 @@ import kotlin from 'react-syntax-highlighter/dist/cjs/languages/hljs/kotlin'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
|
||||
export interface CodeBlockProps {
|
||||
title?: string
|
||||
@@ -146,7 +147,8 @@ export const CodeBlock = ({
|
||||
<CopyToClipboard text={value || children}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={copied ? <IconCheck /> : <IconCopy />}
|
||||
className="px-1.5"
|
||||
icon={copied ? <Check /> : <Copy />}
|
||||
onClick={() => handleCopy()}
|
||||
>
|
||||
{copied ? 'Copied' : ''}
|
||||
|
||||
Reference in new issue
Block a user