feat(studio): add columns and update labels for query performance (#38744)

* feat: change calls label to count

This changes the column title for calls to count for clarity

* feat: add cache hit rate col and number formatting

Adds a cache hit rate column for each query as well as tidies up some number formatting.

* feat: add styling for 0 numbers

This makes anything marked as 0 feint in the table for easier parsing.

* chore: remove debug console log

* fix: silly next-env again

* nit: remove avg rows col

* nit: add toFixed to cache hit rate

* feat: add  tooltip for role column
This commit is contained in:
kemal.earth
2025-09-16 13:43:43 +01:00
committed by GitHub
parent 83122eb4c6
commit bc2dac6d68
3 changed files with 166 additions and 15 deletions

View File

@@ -16,10 +16,51 @@ export const QUERY_PERFORMANCE_COLUMNS = [
{ id: 'query', name: 'Query', description: undefined, minWidth: 500 },
{ id: 'prop_total_time', name: 'Time consumed', description: undefined, minWidth: 130 },
{ id: 'total_time', name: 'Total time', description: 'latency', minWidth: 150 },
{ id: 'calls', name: 'Calls', description: undefined, minWidth: 100 },
{ id: 'calls', name: 'Count', description: undefined, minWidth: 100 },
{ id: 'max_time', name: 'Max time', description: undefined, minWidth: 100 },
{ id: 'mean_time', name: 'Mean time', description: undefined, minWidth: 100 },
{ id: 'min_time', name: 'Min time', description: undefined, minWidth: 100 },
{ id: 'avg_rows', name: 'Avg. Rows', description: undefined, minWidth: 100 },
{ id: 'rolname', name: 'Role', description: undefined, minWidth: 120 },
{ id: 'rows_read', name: 'Rows read', description: undefined, minWidth: 100 },
{ id: 'cache_hit_rate', name: 'Cache hit rate', description: undefined, minWidth: 130 },
{ id: 'rolname', name: 'Role', description: undefined, minWidth: 160 },
] as const
export const QUERY_PERFORMANCE_ROLE_DESCRIPTION = [
{ name: 'postgres', description: 'The default Postgres role. This has admin privileges.' },
{
name: 'anon',
description:
'For unauthenticated, public access. This is the role which the API (PostgREST) will use when a user is not logged in.',
},
{
name: 'authenticator',
description:
'A special role for the API (PostgREST). It has very limited access, and is used to validate a JWT and then "change into" another role determined by the JWT verification.',
},
{
name: 'authenticated',
description:
'For "authenticated access." This is the role which the API (PostgREST) will use when a user is logged in.',
},
{
name: 'service_role',
description:
'For elevated access. This role is used by the API (PostgREST) to bypass Row Level Security.',
},
{
name: 'supabase_auth_admin',
description:
'Used by the Auth middleware to connect to the database and run migration. Access is scoped to the auth schema.',
},
{
name: 'supabase_storage_admin',
description:
'Used by the Auth middleware to connect to the database and run migration. Access is scoped to the storage schema.',
},
{ name: 'dashboard_user', description: 'For running commands via the Supabase UI.' },
{
name: 'supabase_admin',
description:
'An internal role Supabase uses for administrative tasks, such as running upgrades and automations.',
},
] as const

View File

@@ -20,6 +20,7 @@ import {
cn,
CodeBlock,
} from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { hasIndexRecommendations } from './index-advisor.utils'
import { IndexSuggestionIcon } from './IndexSuggestionIcon'
@@ -28,6 +29,7 @@ import { QueryIndexes } from './QueryIndexes'
import {
QUERY_PERFORMANCE_COLUMNS,
QUERY_PERFORMANCE_REPORT_TYPES,
QUERY_PERFORMANCE_ROLE_DESCRIPTION,
} from './QueryPerformance.constants'
import { useQueryPerformanceSort } from './hooks/useQueryPerformanceSort'
@@ -138,14 +140,6 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance
)
}
if (col.id === 'rolname') {
return (
<div className="w-full flex flex-col justify-center font-mono text-xs">
<p>{value || 'n/a'}</p>
</div>
)
}
if (col.id === 'prop_total_time') {
const percentage = props.row.prop_total_time || 0
const fillWidth = Math.min(percentage, 100)
@@ -159,7 +153,13 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance
opacity: 0.04,
}}
/>
<p>{value ? `${value.toFixed(1)}%` : 'n/a'}</p>
{value ? (
<p className={cn(value.toFixed(1) === '0.0' && 'text-foreground-lighter')}>
{value.toFixed(1)}%
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
@@ -175,8 +175,99 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance
if (col.id === 'total_time') {
return (
<div className="w-full flex flex-col justify-center text-xs">
{isTime && typeof value === 'number' && !isNaN(value) && isFinite(value) && (
<p>{(value / 1000).toFixed(2) + 's' || 'n/a'}</p>
{isTime && typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
<p
className={cn((value / 1000).toFixed(2) === '0.00' && 'text-foreground-lighter')}
>
{(value / 1000).toFixed(2) + 's'}
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
if (col.id === 'calls') {
return (
<div className="w-full flex flex-col justify-center text-xs">
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
<p className={cn(value === 0 && 'text-foreground-lighter')}>
{value.toLocaleString()}
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
if (col.id === 'max_time' || col.id === 'mean_time' || col.id === 'min_time') {
return (
<div className="w-full flex flex-col justify-center text-xs">
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
<p className={cn(value.toFixed(0) === '0' && 'text-foreground-lighter')}>
{value.toFixed(0)}ms
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
if (col.id === 'rows_read') {
return (
<div className="w-full flex flex-col justify-center text-xs">
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
<p className={cn(value === 0 && 'text-foreground-lighter')}>
{value.toLocaleString()}
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
const cacheHitRateToNumber = (value: number | string) => {
if (typeof value === 'number') return value
return parseFloat(value.toString().replace('%', '')) || 0
}
if (col.id === 'cache_hit_rate') {
return (
<div className="w-full flex flex-col justify-center text-xs">
{typeof value === 'string' ? (
<p
className={cn(
cacheHitRateToNumber(value).toFixed(2) === '0.00' && 'text-foreground-lighter'
)}
>
{cacheHitRateToNumber(value).toFixed(2)}%
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
if (col.id === 'rolname') {
return (
<div className="w-full flex flex-col justify-center">
{value ? (
<span className="flex items-center gap-x-1">
<p className="font-mono text-xs">{value}</p>
<InfoTooltip align="end" alignOffset={-12} className="w-56">
{
QUERY_PERFORMANCE_ROLE_DESCRIPTION.find((role) => role.name === value)
?.description
}
</InfoTooltip>
</span>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)

View File

@@ -369,7 +369,17 @@ select
-- min_time,
-- max_time,
-- mean_time,
statements.rows / statements.calls as avg_rows${
statements.rows / statements.calls as avg_rows,
statements.rows as rows_read,
case
when (statements.shared_blks_hit + statements.shared_blks_read) > 0
then round(
(statements.shared_blks_hit * 100.0) /
(statements.shared_blks_hit + statements.shared_blks_read),
2
)
else 0
end as cache_hit_rate${
runIndexAdvisor
? `,
case
@@ -513,6 +523,15 @@ select
-- 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
? `,