mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 14:05:05 +08:00
feat(studio): surface index advisor indicators (#40788)
* feat: change the check to show index advisor tab at all times * fix: hide add to log drains on export menu in query perf * fix: small fallback for pathname check * fix: query perf header block responsiveness * feat: admonition for index advisor * fix: add aria-describedby to query perf sheet * feat: proper way to do sheet description * chore: better title spacing in panel * fix: indexes in use empty state * fix: key in observability menu * feat: better highlighting of index advisor issues * feat: add docs button to empty indexes tab * feat: remove unused code * feat: use button tooltips for reset and refresh to gain space * feat: add dismiss to index advisor banner * feat: add warnings filter to query perf * feat: filter all queries for warnings * fix: selected state for warning rows * fix: fallback for isLogs check * fix: other instance of download button --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus'
|
||||
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
@@ -17,18 +16,12 @@ import {
|
||||
AlertDialogTrigger,
|
||||
Badge,
|
||||
Button,
|
||||
InfoIcon,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { getIndexAdvisorExtensions } from './index-advisor.utils'
|
||||
|
||||
export const EnableIndexAdvisorButton = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const { isIndexAdvisorAvailable, isIndexAdvisorEnabled } = useIndexAdvisorStatus()
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
const { data: extensions } = useDatabaseExtensionsQuery({
|
||||
@@ -72,21 +65,11 @@ export const EnableIndexAdvisorButton = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// if index_advisor is already enabled or not available to install, show nothing
|
||||
if (!isIndexAdvisorAvailable || isIndexAdvisorEnabled) return null
|
||||
|
||||
return (
|
||||
<AlertDialog open={isDialogOpen} onOpenChange={() => setIsDialogOpen(!isDialogOpen)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type="outline" className={`rounded-full`} icon={<InfoIcon />}>
|
||||
Enable Index Advisor
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Recommends indexes to improve query performance</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type="primary">Enable</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { EnableIndexAdvisorButton } from './EnableIndexAdvisorButton'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { useParams } from 'common/hooks'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export const IndexAdvisorNotice = () => {
|
||||
const { ref } = useParams()
|
||||
const { isIndexAdvisorAvailable, isIndexAdvisorEnabled } = useIndexAdvisorStatus()
|
||||
const [isDismissed, setIsDismissed] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.INDEX_ADVISOR_NOTICE_DISMISSED(ref ?? ''),
|
||||
false
|
||||
)
|
||||
|
||||
if (!isIndexAdvisorAvailable || isIndexAdvisorEnabled || isDismissed) return null
|
||||
|
||||
return (
|
||||
<div className="px-6">
|
||||
<Admonition showIcon={false} type="tip" className="relative overflow-hidden mb-4">
|
||||
<div className="absolute -inset-16 z-0 opacity-50">
|
||||
<img
|
||||
src={`${BASE_PATH}/img/reports/bg-grafana-dark.svg`}
|
||||
alt="Index Advisor"
|
||||
className="w-full h-full object-cover object-right hidden dark:block"
|
||||
/>
|
||||
<img
|
||||
src={`${BASE_PATH}/img/reports/bg-grafana-light.svg`}
|
||||
alt="Index Advisor"
|
||||
className="w-full h-full object-cover object-right dark:hidden"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-background-alternative to-transparent" />
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center gap-y-2 md:gap-x-8 justify-between px-2 py-1">
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex flex-col gap-y-2 items-start">
|
||||
<p className="text-sm font-medium">Enable Index Advisor</p>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-lighter text-balance">
|
||||
Recommends indexes to improve query performance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
onClick={() => setIsDismissed(true)}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<EnableIndexAdvisorButton />
|
||||
</div>
|
||||
</div>
|
||||
</Admonition>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
createIndexes,
|
||||
hasIndexRecommendations,
|
||||
} from './IndexAdvisor/index-advisor.utils'
|
||||
import { EnableIndexAdvisorButton } from './IndexAdvisor/EnableIndexAdvisorButton'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
|
||||
interface QueryIndexesProps {
|
||||
selectedRow: any
|
||||
@@ -122,11 +125,30 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLoadingExtensions && !isIndexAdvisorEnabled) {
|
||||
return (
|
||||
<QueryPanelContainer className="h-full">
|
||||
<QueryPanelSection className="pt-2">
|
||||
<div className="border rounded border-dashed flex flex-col items-center justify-center py-4 px-12 gap-y-1 text-center">
|
||||
<p className="text-sm text-foreground-light">Enable Index Advisor</p>
|
||||
<p className="text-center text-xs text-foreground-lighter mb-2">
|
||||
Recommends indexes to improve query performance.
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/extensions/index_advisor`} />
|
||||
<EnableIndexAdvisorButton />
|
||||
</div>
|
||||
</div>
|
||||
</QueryPanelSection>
|
||||
</QueryPanelContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryPanelContainer className="h-full">
|
||||
<QueryPanelSection className="pt-2 mb-6">
|
||||
<div className="mb-4 flex flex-col gap-y-1">
|
||||
<h4>Indexes in use</h4>
|
||||
<h4 className="mb-2">Indexes in use</h4>
|
||||
<p className="text-sm text-foreground-light">
|
||||
This query is using the following index{(usedIndexes ?? []).length > 1 ? 's' : ''}:
|
||||
</p>
|
||||
@@ -142,7 +164,7 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
{isSuccess && (
|
||||
<div>
|
||||
{usedIndexes.length === 0 && (
|
||||
<div className="border rounded border-dashed flex flex-col items-center justify-center py-4 px-20 gap-y-1">
|
||||
<div className="border rounded border-dashed flex flex-col items-center justify-center py-4 px-12 gap-y-1 text-center">
|
||||
<p className="text-sm text-foreground-light">
|
||||
No indexes are involved in this query
|
||||
</p>
|
||||
@@ -173,7 +195,7 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
</QueryPanelSection>
|
||||
<QueryPanelSection className="flex flex-col gap-y-6 py-6 border-t">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h4>New index recommendations</h4>
|
||||
<h4 className="mb-2">New index recommendations</h4>
|
||||
{isLoadingExtensions ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : !isIndexAdvisorEnabled ? (
|
||||
@@ -252,8 +274,8 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
<>
|
||||
<QueryPanelSection className="py-6 border-t">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h4>Query costs</h4>
|
||||
<div className="border rounded-md flex flex-col bg-surface-100 mt-3">
|
||||
<h4 className="mb-2">Query costs</h4>
|
||||
<div className="border rounded-md flex flex-col bg-surface-100">
|
||||
<QueryPanelScoreSection
|
||||
name="Total cost of query"
|
||||
description="An estimate of how long it will take to return all the rows (Includes start up cost)"
|
||||
@@ -280,7 +302,7 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
</QueryPanelSection>
|
||||
<QueryPanelSection className="py-6 border-t">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<h4>FAQ</h4>
|
||||
<h4 className="mb-2">FAQ</h4>
|
||||
<Accordion_Shadcn_ collapsible type="single" className="border rounded-md">
|
||||
<AccordionItem_Shadcn_ value="1">
|
||||
<AccordionTrigger className="px-4 py-3 text-sm font-normal text-foreground-light hover:text-foreground transition [&[data-state=open]]:text-foreground">
|
||||
|
||||
@@ -335,7 +335,7 @@ export const QueryPerformanceChart = ({
|
||||
</TabsList_Shadcn_>
|
||||
|
||||
<TabsContent_Shadcn_ value={selectedMetric} className="bg-surface-200 mt-0 h-inherit">
|
||||
<div className="w-full flex items-center justify-center min-h-[320px]">
|
||||
<div className="w-full flex items-center justify-center min-h-[282px]">
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin text-foreground-lighter" />
|
||||
) : error ? (
|
||||
@@ -375,7 +375,7 @@ export const QueryPerformanceChart = ({
|
||||
tickFormatter: getYAxisFormatter,
|
||||
}}
|
||||
xAxisIsDate={true}
|
||||
className="mt-6"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ReportsNumericFilter,
|
||||
NumericFilter,
|
||||
} from 'components/interfaces/Reports/v2/ReportsNumericFilter'
|
||||
import { useIndexAdvisorStatus } from './hooks/useIsIndexAdvisorStatus'
|
||||
|
||||
export const QueryPerformanceFilterBar = ({
|
||||
actions,
|
||||
@@ -23,16 +24,20 @@ export const QueryPerformanceFilterBar = ({
|
||||
}) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { sort, clearSort } = useQueryPerformanceSort()
|
||||
const { isIndexAdvisorEnabled } = useIndexAdvisorStatus()
|
||||
|
||||
const [{ search: searchQuery, roles: defaultFilterRoles, callsFilter }, setSearchParams] =
|
||||
useQueryStates({
|
||||
search: parseAsString.withDefault(''),
|
||||
roles: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
callsFilter: parseAsJson((value) => value as NumericFilter | null).withDefault({
|
||||
operator: '>=',
|
||||
value: 0,
|
||||
} as NumericFilter),
|
||||
})
|
||||
const [
|
||||
{ search: searchQuery, roles: defaultFilterRoles, callsFilter, indexAdvisor },
|
||||
setSearchParams,
|
||||
] = useQueryStates({
|
||||
search: parseAsString.withDefault(''),
|
||||
roles: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
callsFilter: parseAsJson((value) => value as NumericFilter | null).withDefault({
|
||||
operator: '>=',
|
||||
value: 0,
|
||||
} as NumericFilter),
|
||||
indexAdvisor: parseAsString.withDefault('false'),
|
||||
})
|
||||
const { data, isLoading: isLoadingRoles } = useDatabaseRolesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
@@ -56,11 +61,17 @@ export const QueryPerformanceFilterBar = ({
|
||||
setSearchParams({ roles })
|
||||
}
|
||||
|
||||
const onIndexAdvisorChange = (options: string[]) => {
|
||||
setSearchParams({ indexAdvisor: options.includes('true') ? 'true' : 'false' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onSearchQueryChange(searchValue)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchValue])
|
||||
|
||||
const indexAdvisorOptions = [{ value: 'true', label: 'Index Advisor' }]
|
||||
|
||||
return (
|
||||
<div className="px-4 py-1.5 bg-surface-200 border-t -mt-px flex justify-between items-center overflow-x-auto overflow-y-hidden w-full flex-shrink-0">
|
||||
<div className="flex items-center gap-x-4">
|
||||
@@ -111,6 +122,18 @@ export const QueryPerformanceFilterBar = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isIndexAdvisorEnabled && (
|
||||
<FilterPopover
|
||||
name="Warnings"
|
||||
options={indexAdvisorOptions}
|
||||
labelKey="label"
|
||||
valueKey="value"
|
||||
activeOptions={indexAdvisor === 'true' ? ['true'] : []}
|
||||
onSaveFilters={onIndexAdvisorChange}
|
||||
className="w-56"
|
||||
/>
|
||||
)}
|
||||
|
||||
{sort && (
|
||||
<div className="text-xs border rounded-md px-1.5 md:px-2.5 py-1 h-[26px] flex items-center gap-x-2">
|
||||
<p className="md:inline-flex gap-x-1 hidden truncate">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
TabsContent_Shadcn_,
|
||||
TabsList_Shadcn_,
|
||||
@@ -40,8 +41,8 @@ import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFi
|
||||
interface QueryPerformanceGridProps {
|
||||
aggregatedData: QueryPerformanceRow[]
|
||||
isLoading: boolean
|
||||
currentSelectedQuery?: string | null // Make optional
|
||||
onCurrentSelectQuery?: (query: string) => void // Make optional
|
||||
currentSelectedQuery?: string | null
|
||||
onCurrentSelectQuery?: (query: string) => void
|
||||
}
|
||||
|
||||
const calculateTimeConsumedWidth = (data: QueryPerformanceRow[]) => {
|
||||
@@ -397,14 +398,6 @@ export const QueryPerformanceGrid = ({
|
||||
return data
|
||||
}, [aggregatedData, sort, search, roles, callsFilter])
|
||||
|
||||
const selectedQuery = selectedRow !== undefined ? reportData[selectedRow]?.query : undefined
|
||||
const query = (selectedQuery ?? '').trim().toLowerCase()
|
||||
const showIndexSuggestions =
|
||||
(query.startsWith('select') ||
|
||||
query.startsWith('with pgrst_source') ||
|
||||
query.startsWith('with pgrst_payload')) &&
|
||||
hasIndexRecommendations(reportData[selectedRow!]?.index_advisor_result, true)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRow(undefined)
|
||||
}, [search, roles, urlSort, order, callsFilter])
|
||||
@@ -460,10 +453,14 @@ export const QueryPerformanceGrid = ({
|
||||
const isSelected = idx === selectedRow
|
||||
const query = reportData[idx]?.query
|
||||
const isCharted = currentSelectedQuery ? currentSelectedQuery === query : false
|
||||
const hasRecommendations = hasIndexRecommendations(
|
||||
reportData[idx]?.index_advisor_result,
|
||||
true
|
||||
)
|
||||
|
||||
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' : ''}`,
|
||||
`${isSelected ? (hasRecommendations ? 'bg-warning/10 hover:bg-warning/20' : 'bg-surface-300 dark:bg-surface-300') : hasRecommendations ? 'bg-warning/10 hover:bg-warning/20' : 'bg-200 hover:bg-surface-200'} cursor-pointer`,
|
||||
`${isSelected ? (hasRecommendations ? '[&>div:first-child]:border-l-4 border-l-warning [&>div]:border-l-warning' : '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:!border-l-foreground') : ''}`,
|
||||
`${isCharted ? 'bg-surface-200 dark:bg-surface-200' : ''}`,
|
||||
`${isCharted ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-brand' : ''}`,
|
||||
'[&>.rdg-cell]:box-border [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
|
||||
@@ -489,7 +486,11 @@ export const QueryPerformanceGrid = ({
|
||||
} else {
|
||||
// Otherwise, open the detail panel
|
||||
setSelectedRow(idx)
|
||||
setView('details')
|
||||
const hasRecommendations = hasIndexRecommendations(
|
||||
reportData[idx]?.index_advisor_result,
|
||||
true
|
||||
)
|
||||
setView(hasRecommendations ? 'suggestion' : 'details')
|
||||
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
|
||||
}
|
||||
}
|
||||
@@ -526,6 +527,9 @@ export const QueryPerformanceGrid = ({
|
||||
modal={false}
|
||||
>
|
||||
<SheetTitle className="sr-only">Query details</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Query Performance Details & Indexes
|
||||
</SheetDescription>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col h-full bg-studio border-l lg:!w-[calc(100vw-802px)] max-w-[700px] w-full"
|
||||
@@ -549,14 +553,12 @@ export const QueryPerformanceGrid = ({
|
||||
>
|
||||
Query details
|
||||
</TabsTrigger_Shadcn_>
|
||||
{showIndexSuggestions && (
|
||||
<TabsTrigger_Shadcn_
|
||||
value="suggestion"
|
||||
className="px-0 pb-0 data-[state=active]:bg-transparent !shadow-none"
|
||||
>
|
||||
Indexes
|
||||
</TabsTrigger_Shadcn_>
|
||||
)}
|
||||
<TabsTrigger_Shadcn_
|
||||
value="suggestion"
|
||||
className="px-0 pb-0 data-[state=active]:bg-transparent !shadow-none"
|
||||
>
|
||||
Indexes
|
||||
</TabsTrigger_Shadcn_>
|
||||
</TabsList_Shadcn_>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './WithMonitor.utils'
|
||||
import { useParams } from 'common'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { IndexAdvisorNotice } from '../IndexAdvisor/IndexAdvisorNotice'
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
||||
@@ -81,6 +82,7 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps)
|
||||
|
||||
return (
|
||||
<>
|
||||
<IndexAdvisorNotice />
|
||||
<QueryPerformanceChart
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={onDateRangeChange}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { QueryPerformanceFilterBar } from '../QueryPerformanceFilterBar'
|
||||
import { QueryPerformanceGrid } from '../QueryPerformanceGrid'
|
||||
import { transformStatementDataToRows } from './WithStatements.utils'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { IndexAdvisorNotice } from '../IndexAdvisor/IndexAdvisorNotice'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
|
||||
interface WithStatementsProps {
|
||||
queryHitRate: PresetHookResult
|
||||
@@ -66,28 +68,29 @@ export const WithStatements = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<IndexAdvisorNotice />
|
||||
<QueryPerformanceMetrics />
|
||||
<QueryPerformanceFilterBar
|
||||
showRolesFilter={true}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<RefreshCw />}
|
||||
onClick={handleRefresh}
|
||||
loading={isRefetching}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
tooltip={{ content: { side: 'top', text: 'Refresh' } }}
|
||||
className="w-[26px]"
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<RotateCcw />}
|
||||
onClick={() => setShowResetgPgStatStatements(true)}
|
||||
>
|
||||
Reset report
|
||||
</Button>
|
||||
tooltip={{ content: { side: 'top', text: 'Reset report' } }}
|
||||
className="w-[26px]"
|
||||
/>
|
||||
|
||||
<DownloadResultsButton
|
||||
results={processedData}
|
||||
fileName={`Supabase Query Performance Statements (${ref})`}
|
||||
|
||||
@@ -360,7 +360,7 @@ limit 12
|
||||
queries: {
|
||||
mostFrequentlyInvoked: {
|
||||
queryType: 'db',
|
||||
sql: (_params, where, orderBy, runIndexAdvisor = false) => `
|
||||
sql: (_params, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => `
|
||||
-- reports-query-performance-most-frequently-invoked
|
||||
set search_path to public, extensions;
|
||||
|
||||
@@ -416,7 +416,7 @@ select
|
||||
},
|
||||
mostTimeConsuming: {
|
||||
queryType: 'db',
|
||||
sql: (_, where, orderBy, runIndexAdvisor = false) => `
|
||||
sql: (_, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => `
|
||||
-- reports-query-performance-most-time-consuming
|
||||
set search_path to public, extensions;
|
||||
|
||||
@@ -454,7 +454,7 @@ select
|
||||
},
|
||||
slowestExecutionTime: {
|
||||
queryType: 'db',
|
||||
sql: (_params, where, orderBy, runIndexAdvisor = false) => `
|
||||
sql: (_params, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => `
|
||||
-- reports-query-performance-slowest-execution-time
|
||||
set search_path to public, extensions;
|
||||
|
||||
@@ -513,59 +513,68 @@ select
|
||||
},
|
||||
unified: {
|
||||
queryType: 'db',
|
||||
sql: (_params, where, orderBy, runIndexAdvisor = false) => `
|
||||
sql: (_params, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => {
|
||||
const baseQuery = `
|
||||
-- reports-query-performance-unified
|
||||
set search_path to public, extensions;
|
||||
|
||||
select
|
||||
auth.rolname,
|
||||
statements.query,
|
||||
statements.calls,
|
||||
-- -- Postgres 13, 14, 15
|
||||
statements.total_exec_time + statements.total_plan_time as total_time,
|
||||
statements.min_exec_time + statements.min_plan_time as min_time,
|
||||
statements.max_exec_time + statements.max_plan_time as max_time,
|
||||
statements.mean_exec_time + statements.mean_plan_time as mean_time,
|
||||
-- -- Postgres <= 12
|
||||
-- total_time,
|
||||
-- min_time,
|
||||
-- max_time,
|
||||
-- mean_time,
|
||||
statements.rows / statements.calls as avg_rows,
|
||||
statements.rows as rows_read,
|
||||
statements.shared_blks_hit as debug_hit,
|
||||
statements.shared_blks_read as debug_read,
|
||||
case
|
||||
when (statements.shared_blks_hit + statements.shared_blks_read) > 0
|
||||
then (statements.shared_blks_hit::numeric * 100.0) /
|
||||
(statements.shared_blks_hit + statements.shared_blks_read)
|
||||
else 0
|
||||
end as cache_hit_rate,
|
||||
((statements.total_exec_time + statements.total_plan_time)/sum(statements.total_exec_time + statements.total_plan_time) OVER()) * 100 as prop_total_time${
|
||||
runIndexAdvisor
|
||||
? `,
|
||||
case
|
||||
when (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%')
|
||||
then (
|
||||
select json_build_object(
|
||||
'has_suggestion', array_length(index_statements, 1) > 0,
|
||||
'startup_cost_before', startup_cost_before,
|
||||
'startup_cost_after', startup_cost_after,
|
||||
'total_cost_before', total_cost_before,
|
||||
'total_cost_after', total_cost_after,
|
||||
'index_statements', index_statements
|
||||
with query_results as (
|
||||
select
|
||||
auth.rolname,
|
||||
statements.query,
|
||||
statements.calls,
|
||||
-- -- Postgres 13, 14, 15
|
||||
statements.total_exec_time + statements.total_plan_time as total_time,
|
||||
statements.min_exec_time + statements.min_plan_time as min_time,
|
||||
statements.max_exec_time + statements.max_plan_time as max_time,
|
||||
statements.mean_exec_time + statements.mean_plan_time as mean_time,
|
||||
-- -- Postgres <= 12
|
||||
-- total_time,
|
||||
-- min_time,
|
||||
-- max_time,
|
||||
-- mean_time,
|
||||
statements.rows / statements.calls as avg_rows,
|
||||
statements.rows as rows_read,
|
||||
statements.shared_blks_hit as debug_hit,
|
||||
statements.shared_blks_read as debug_read,
|
||||
case
|
||||
when (statements.shared_blks_hit + statements.shared_blks_read) > 0
|
||||
then (statements.shared_blks_hit::numeric * 100.0) /
|
||||
(statements.shared_blks_hit + statements.shared_blks_read)
|
||||
else 0
|
||||
end as cache_hit_rate,
|
||||
((statements.total_exec_time + statements.total_plan_time)/sum(statements.total_exec_time + statements.total_plan_time) OVER()) * 100 as prop_total_time${
|
||||
runIndexAdvisor
|
||||
? `,
|
||||
case
|
||||
when (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%')
|
||||
then (
|
||||
select json_build_object(
|
||||
'has_suggestion', array_length(index_statements, 1) > 0,
|
||||
'startup_cost_before', startup_cost_before,
|
||||
'startup_cost_after', startup_cost_after,
|
||||
'total_cost_before', total_cost_before,
|
||||
'total_cost_after', total_cost_after,
|
||||
'index_statements', index_statements
|
||||
)
|
||||
from index_advisor(statements.query)
|
||||
)
|
||||
from index_advisor(statements.query)
|
||||
)
|
||||
else null
|
||||
end as index_advisor_result`
|
||||
: ''
|
||||
}
|
||||
from pg_stat_statements as statements
|
||||
inner join pg_authid as auth on statements.userid = auth.oid
|
||||
${where || ''}
|
||||
${orderBy || 'order by statements.total_exec_time + statements.total_plan_time desc'}
|
||||
limit 20`,
|
||||
else null
|
||||
end as index_advisor_result`
|
||||
: ''
|
||||
}
|
||||
from pg_stat_statements as statements
|
||||
inner join pg_authid as auth on statements.userid = auth.oid
|
||||
${where || ''}
|
||||
)
|
||||
select *
|
||||
from query_results
|
||||
${filterIndexAdvisor && runIndexAdvisor ? `where (index_advisor_result->>'has_suggestion')::boolean = true` : ''}
|
||||
${orderBy || 'order by total_time desc'}
|
||||
limit 20`
|
||||
|
||||
return baseQuery
|
||||
},
|
||||
},
|
||||
slowQueriesCount: {
|
||||
queryType: 'db',
|
||||
@@ -580,7 +589,7 @@ select
|
||||
},
|
||||
queryMetrics: {
|
||||
queryType: 'db',
|
||||
sql: (_params, where, orderBy, runIndexAdvisor = false) => `
|
||||
sql: (_params, where, orderBy, runIndexAdvisor = false, filterIndexAdvisor = false) => `
|
||||
-- reports-query-performance-metrics
|
||||
set search_path to public, extensions;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export type QueryPerformanceQueryOpts = {
|
||||
roles?: string[]
|
||||
runIndexAdvisor?: boolean
|
||||
minCalls?: number
|
||||
filterIndexAdvisor?: boolean
|
||||
}
|
||||
|
||||
export const useQueryPerformanceQuery = ({
|
||||
@@ -39,6 +40,7 @@ export const useQueryPerformanceQuery = ({
|
||||
roles,
|
||||
runIndexAdvisor = false,
|
||||
minCalls,
|
||||
filterIndexAdvisor = false,
|
||||
}: QueryPerformanceQueryOpts) => {
|
||||
const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
|
||||
const baseSQL = queryPerfQueries.queries[preset]
|
||||
@@ -58,7 +60,8 @@ export const useQueryPerformanceQuery = ({
|
||||
[],
|
||||
whereSql.length > 0 ? `WHERE ${whereSql}` : undefined,
|
||||
orderBySql,
|
||||
runIndexAdvisor
|
||||
runIndexAdvisor,
|
||||
filterIndexAdvisor
|
||||
)
|
||||
return useDbQuery({
|
||||
sql,
|
||||
|
||||
@@ -25,7 +25,8 @@ export interface ReportQuery {
|
||||
filters: ReportFilterItem[],
|
||||
where?: string,
|
||||
orderBy?: string,
|
||||
runIndexAdvisor?: boolean
|
||||
runIndexAdvisor?: boolean,
|
||||
filterIndexAdvisor?: boolean
|
||||
) => string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import saveAs from 'file-saver'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Download, Settings } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Papa from 'papaparse'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useGetUnifiedLogsMutation } from 'data/logs/get-unified-logs'
|
||||
import {
|
||||
@@ -38,6 +39,8 @@ interface DownloadLogsButtonProps {
|
||||
|
||||
export const DownloadLogsButton = ({ searchParameters }: DownloadLogsButtonProps) => {
|
||||
const { ref } = useParams()
|
||||
const pathname = usePathname()
|
||||
const isLogs = pathname?.includes?.('/logs') ?? false
|
||||
const [numRows, setNumRows] = useState(DEFAULT_NUM_ROWS)
|
||||
const [numHours, setNumHours] = useState(DEFAULT_NUM_ROWS)
|
||||
const [selectedFormat, setSelectedFormat] = useState<'csv' | 'json'>()
|
||||
@@ -98,16 +101,21 @@ export const DownloadLogsButton = ({ searchParameters }: DownloadLogsButtonProps
|
||||
tooltip={{ content: { side: 'bottom', text: 'Download logs' } }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem asChild className="gap-x-2">
|
||||
<Link href={`/project/${ref}/settings/log-drains`}>
|
||||
<p>Add a Log Drain</p>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSelectedFormat('csv')}>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{isLogs && IS_PLATFORM && (
|
||||
<DropdownMenuItem asChild className="gap-x-2">
|
||||
<Link href={`/project/${ref}/settings/log-drains`}>
|
||||
<Settings size={14} />
|
||||
<p>Add a Log Drain</p>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setSelectedFormat('csv')} className="gap-x-2">
|
||||
<Download size={14} />
|
||||
<p>Download as CSV</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSelectedFormat('json')}>
|
||||
<DropdownMenuItem onClick={() => setSelectedFormat('json')} className="gap-x-2">
|
||||
<Download size={14} />
|
||||
<p>Download as JSON</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useFlag, useParams } from 'common'
|
||||
@@ -275,9 +275,9 @@ const ObservabilityMenu = () => {
|
||||
</div>
|
||||
|
||||
{menuItems.map((item, idx) => (
|
||||
<>
|
||||
<Fragment key={idx}>
|
||||
<div className="h-px w-full bg-border-overlay first:hidden" />
|
||||
<div key={item.key + '-menu-group'}>
|
||||
<div>
|
||||
{item.items ? (
|
||||
<div className="px-2">
|
||||
<Menu.Group title={<span className="uppercase font-mono">{item.title}</span>} />
|
||||
@@ -305,7 +305,7 @@ const ObservabilityMenu = () => {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<UpdateCustomReportModal
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'common'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -40,6 +42,8 @@ export const DownloadResultsButton = ({
|
||||
onCopyAsJSON,
|
||||
}: DownloadResultsButtonProps) => {
|
||||
const { ref } = useParams()
|
||||
const pathname = usePathname()
|
||||
const isLogs = pathname?.includes?.('/logs') ?? false
|
||||
// [Joshen] Ensure JSON values are stringified for CSV and Markdown
|
||||
const formattedResults = results.map((row) => {
|
||||
const r = { ...row }
|
||||
@@ -109,12 +113,14 @@ export const DownloadResultsButton = ({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} className="w-44">
|
||||
<DropdownMenuItem asChild className="gap-x-2">
|
||||
<Link href={`/project/${ref}/settings/log-drains`}>
|
||||
<Settings size={14} />
|
||||
<p>Add a Log Drain</p>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isLogs && IS_PLATFORM && (
|
||||
<DropdownMenuItem asChild className="gap-x-2">
|
||||
<Link href={`/project/${ref}/settings/log-drains`}>
|
||||
<Settings size={14} />
|
||||
<p>Add a Log Drain</p>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={copyAsMarkdown} className="gap-x-2">
|
||||
<Copy size={14} />
|
||||
<p>Copy as markdown</p>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryStates } from 'n
|
||||
import { useParams } from 'common'
|
||||
import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus'
|
||||
import { useQueryPerformanceSort } from 'components/interfaces/QueryPerformance/hooks/useQueryPerformanceSort'
|
||||
import { EnableIndexAdvisorButton } from 'components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton'
|
||||
import { QueryPerformance } from 'components/interfaces/QueryPerformance/QueryPerformance'
|
||||
import {
|
||||
PRESET_CONFIG,
|
||||
@@ -17,7 +16,6 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import ObservabilityLayout from 'components/layouts/ObservabilityLayout/ObservabilityLayout'
|
||||
import DatabaseSelector from 'components/ui/DatabaseSelector'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { useReportDateRange } from 'hooks/misc/useReportDateRange'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
@@ -37,12 +35,13 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
handleDatePickerChange,
|
||||
} = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
|
||||
|
||||
const [{ search: searchQuery, roles, minCalls }] = useQueryStates({
|
||||
const [{ search: searchQuery, roles, minCalls, indexAdvisor }] = useQueryStates({
|
||||
sort: parseAsString,
|
||||
order: parseAsString,
|
||||
search: parseAsString.withDefault(''),
|
||||
roles: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
minCalls: parseAsInteger,
|
||||
indexAdvisor: parseAsString.withDefault('false'),
|
||||
})
|
||||
|
||||
const config = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
|
||||
@@ -57,37 +56,34 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
roles,
|
||||
runIndexAdvisor: isIndexAdvisorEnabled,
|
||||
minCalls: minCalls ?? undefined,
|
||||
filterIndexAdvisor: indexAdvisor === 'true',
|
||||
})
|
||||
|
||||
const isPgStatMonitorEnabled = project?.dbVersion === '17.4.1.076-psml-1'
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<FormHeader
|
||||
className="py-4 px-6 !mb-0 md:flex-row flex-col"
|
||||
title="Query Performance"
|
||||
actions={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<EnableIndexAdvisorButton />
|
||||
<DocsButton
|
||||
href={`${DOCS_URL}/guides/platform/performance#examining-query-performance`}
|
||||
<div className="w-full mb-0 flex lg:items-center justify-between gap-4 py-4 px-6 lg:flex-row flex-col">
|
||||
<h3 className="text-foreground text-xl prose">Query Performance</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<DocsButton
|
||||
href={`${DOCS_URL}/guides/platform/performance#examining-query-performance`}
|
||||
/>
|
||||
<DatabaseSelector />
|
||||
{isPgStatMonitorEnabled && (
|
||||
<LogsDatePicker
|
||||
value={datePickerValue}
|
||||
helpers={datePickerHelpers.filter(
|
||||
(h) =>
|
||||
h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES ||
|
||||
h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_3_HOURS ||
|
||||
h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_24_HOURS
|
||||
)}
|
||||
onSubmit={handleDatePickerChange}
|
||||
/>
|
||||
<DatabaseSelector />
|
||||
{isPgStatMonitorEnabled && (
|
||||
<LogsDatePicker
|
||||
value={datePickerValue}
|
||||
helpers={datePickerHelpers.filter(
|
||||
(h) =>
|
||||
h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES ||
|
||||
h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_3_HOURS ||
|
||||
h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_24_HOURS
|
||||
)}
|
||||
onSubmit={handleDatePickerChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QueryPerformance
|
||||
queryHitRate={queryHitRate}
|
||||
queryPerformanceQuery={queryPerformanceQuery}
|
||||
|
||||
@@ -84,6 +84,9 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
// Project sidebar hotkeys
|
||||
HOTKEY_SIDEBAR: (sidebarId: string) => `supabase-dashboard-hotkey-sidebar-${sidebarId}`,
|
||||
|
||||
// Index Advisor notice dismissed
|
||||
INDEX_ADVISOR_NOTICE_DISMISSED: (ref: string) => `index-advisor-notice-dismissed-${ref}`,
|
||||
|
||||
/**
|
||||
* COMMON
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user