diff --git a/apps/studio/components/interfaces/Reports/ReportHeader.tsx b/apps/studio/components/interfaces/Reports/ReportHeader.tsx index bfe2a5fbd3d..39a68e44057 100644 --- a/apps/studio/components/interfaces/Reports/ReportHeader.tsx +++ b/apps/studio/components/interfaces/Reports/ReportHeader.tsx @@ -9,20 +9,6 @@ interface Props { const ReportHeader: React.FC = ({ title, onRefresh, isLoading }) => (

{title}

-
) export default ReportHeader diff --git a/apps/studio/components/interfaces/Reports/ReportQueryPerformanceTableRow.tsx b/apps/studio/components/interfaces/Reports/ReportQueryPerformanceTableRow.tsx new file mode 100644 index 00000000000..83cec5b027a --- /dev/null +++ b/apps/studio/components/interfaces/Reports/ReportQueryPerformanceTableRow.tsx @@ -0,0 +1,51 @@ +import Table from 'components/to-be-cleaned/Table' +import React from 'react' +import { cn } from 'ui' +import { Editor } from '@monaco-editor/react' + +type Props = { + sql: string + colSpan: number + children: React.ReactNode +} + +const ReportQueryPerformanceTableRow = ({ sql, colSpan, children }: Props) => { + const [expanded, setExpanded] = React.useState(false) + + return ( + <> + setExpanded(!expanded)}>{children} + + {expanded && ( + +
+ +
+ + )} + + + ) +} + +export default ReportQueryPerformanceTableRow diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index 8785c5a05ff..9de4827504a 100644 --- a/apps/studio/components/interfaces/Reports/Reports.constants.ts +++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts @@ -33,7 +33,7 @@ export const DEFAULT_QUERY_PARAMS = { iso_timestamp_end: REPORTS_DATEPICKER_HELPERS[0].calcTo(), } -const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true) => { +export const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true) => { if (filters.length === 0) return '' const conditions = filters .map((filter) => { @@ -276,9 +276,8 @@ limit 12 queries: { mostFrequentlyInvoked: { queryType: 'db', - sql: (_params) => ` + sql: (_params, where, orderBy) => ` -- Most frequently called queries --- A limit of 100 has been added below select auth.rolname, statements.query, @@ -296,13 +295,14 @@ select statements.rows / statements.calls as avg_rows from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid - order by - statements.calls desc + ${where || ''} + ${orderBy || 'order by statements.calls desc'} limit 10;`, }, mostTimeConsuming: { queryType: 'db', - sql: (_params) => `-- A limit of 100 has been added below + sql: (_, where, orderBy) => ` +-- Most time consuming queries select auth.rolname, statements.query, @@ -311,14 +311,14 @@ select to_char(((statements.total_exec_time + statements.total_plan_time)/sum(statements.total_exec_time + statements.total_plan_time) OVER()) * 100, 'FM90D0') || '%' AS prop_total_time from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid - order by - total_time desc + ${where || ''} + ${orderBy || 'order by total_time desc'} limit 10;`, }, slowestExecutionTime: { queryType: 'db', - sql: (_params) => `-- Slowest queries by max execution time --- A limit of 100 has been added below + sql: (_params, where, orderBy) => ` +-- Slowest queries by max execution time select auth.rolname, statements.query, @@ -336,8 +336,8 @@ select statements.rows / statements.calls as avg_rows from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid - order by - max_time desc + ${where || ''} + ${orderBy || 'order by max_time desc'} limit 10`, }, queryHitRate: { diff --git a/apps/studio/components/interfaces/Reports/Reports.queries.ts b/apps/studio/components/interfaces/Reports/Reports.queries.ts new file mode 100644 index 00000000000..6c76223b286 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/Reports.queries.ts @@ -0,0 +1,33 @@ +import { PRESET_CONFIG } from './Reports.constants' +import { Presets } from './Reports.types' +import useDbQuery from 'hooks/analytics/useDbQuery' + +type QueryPerformanceQueryOpts = { + searchQuery: string + preset: 'mostFrequentlyInvoked' | 'mostTimeConsuming' | 'slowestExecutionTime' | 'queryHitRate' + orderBy: string | 'lat_asc' | 'lat_desc' +} +export const useQueryPerformanceQuery = ({ + preset, + orderBy, + searchQuery, +}: 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 = searchQuery + ? `WHERE auth.rolname ~ '${searchQuery}' OR statements.query ~ '${searchQuery}'` + : '' + const orderBySql = orderBy === 'lat_asc' ? 'ORDER BY total_time asc' : 'ORDER BY total_time desc' + + const sql = baseSQL.sql([], whereSql, orderBySql) + + // console.log('DEBUG Using sql query: ', sql) + + return useDbQuery(sql, undefined, whereSql, orderBySql) +} diff --git a/apps/studio/components/interfaces/Reports/Reports.types.ts b/apps/studio/components/interfaces/Reports/Reports.types.ts index a99fdf1478e..b1f5c8da7be 100644 --- a/apps/studio/components/interfaces/Reports/Reports.types.ts +++ b/apps/studio/components/interfaces/Reports/Reports.types.ts @@ -20,7 +20,7 @@ export type BaseQueries = Record export interface ReportQuery { queryType: ReportQueryType - sql: (filters: ReportFilterItem[]) => string + sql: (filters: ReportFilterItem[], where?: string, orderBy?: string) => string } export type ReportQueryType = 'db' | 'logs' @@ -45,4 +45,5 @@ export interface ReportFilterItem { key: string value: string | number compare: 'matches' | 'is' + query?: string } diff --git a/apps/studio/components/ui/CopyButton.tsx b/apps/studio/components/ui/CopyButton.tsx index 505c31ace4c..35e36d2997a 100644 --- a/apps/studio/components/ui/CopyButton.tsx +++ b/apps/studio/components/ui/CopyButton.tsx @@ -5,8 +5,18 @@ import { useEffect, useState } from 'react' export interface CopyButtonProps extends ButtonProps { text: string iconOnly?: boolean + copyLabel?: string + copiedLabel?: string } -const CopyButton = ({ text, iconOnly = false, children, onClick, ...props }: CopyButtonProps) => { +const CopyButton = ({ + text, + iconOnly = false, + children, + onClick, + copyLabel = 'Copy', + copiedLabel = 'Copied', + ...props +}: CopyButtonProps) => { const [showCopied, setShowCopied] = useState(false) useEffect(() => { @@ -31,7 +41,7 @@ const CopyButton = ({ text, iconOnly = false, children, onClick, ...props }: Cop } {...props} > - {!iconOnly && <>{children ?? (showCopied ? 'Copied' : 'Copy')}} + {!iconOnly && <>{children ?? (showCopied ? copiedLabel : copyLabel)}} ) } diff --git a/apps/studio/hooks/analytics/useDbQuery.tsx b/apps/studio/hooks/analytics/useDbQuery.tsx index 86f81b79be1..c0d2d99ea34 100644 --- a/apps/studio/hooks/analytics/useDbQuery.tsx +++ b/apps/studio/hooks/analytics/useDbQuery.tsx @@ -21,8 +21,10 @@ export interface DbQueryHook { } const useDbQuery = ( - sql: ReportQuery['sql'], - params: BaseReportParams = DEFAULT_QUERY_PARAMS + sql: ReportQuery['sql'] | string, + params: BaseReportParams = DEFAULT_QUERY_PARAMS, + where?: string, + orderBy?: string ): DbQueryHook => { const { project } = useProjectContext() @@ -35,7 +37,7 @@ const useDbQuery = ( isRefetching, refetch, } = useQuery( - ['projects', project?.ref, 'db', { ...params, sql: resolvedSql }], + ['projects', project?.ref, 'db', { ...params, sql: resolvedSql }, where, orderBy], ({ signal }) => { return executeSql( { diff --git a/apps/studio/pages/project/[ref]/reports/query-performance.tsx b/apps/studio/pages/project/[ref]/reports/query-performance.tsx index 25121909a5d..a4be321eece 100644 --- a/apps/studio/pages/project/[ref]/reports/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/reports/query-performance.tsx @@ -1,46 +1,75 @@ import { useParams } from 'common' import ReportHeader from 'components/interfaces/Reports/ReportHeader' -import ReportPadding from 'components/interfaces/Reports/ReportPadding' +import ReportQueryPerformanceTableRow from 'components/interfaces/Reports/ReportQueryPerformanceTableRow' import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants' +import { useQueryPerformanceQuery } from 'components/interfaces/Reports/Reports.queries' import { Presets } from 'components/interfaces/Reports/Reports.types' import { queriesFactory } from 'components/interfaces/Reports/Reports.utils' import { ReportsLayout } from 'components/layouts' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import Table from 'components/to-be-cleaned/Table' -import CopyButton from 'components/ui/CopyButton' import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { executeSql } from 'data/sql/execute-sql-query' import { useFlag } from 'hooks' +import { sortBy } from 'lodash' import { observer } from 'mobx-react-lite' import Link from 'next/link' +import { useRouter } from 'next/router' import { useState } from 'react' import ReactMarkdown from 'react-markdown' import { NextPageWithLayout } from 'types' -import { Accordion, Button, IconAlertCircle, IconCheckCircle, Tabs } from 'ui' +import { + Accordion, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + IconAlertCircle, + IconArrowDown, + IconArrowUp, + IconCheckCircle, + IconList, + IconRefreshCw, + IconSearch, + Input, + Tabs, +} from 'ui' +type QueryPerformancePreset = 'time' | 'frequent' | 'slowest' const QueryPerformanceReport: NextPageWithLayout = () => { const { project } = useProjectContext() const [showResetgPgStatStatements, setShowResetgPgStatStatements] = useState(false) const tableIndexEfficiencyEnabled = useFlag('tableIndexEfficiency') const config = PRESET_CONFIG[Presets.QUERY_PERFORMANCE] const { ref: projectRef } = useParams() + const router = useRouter() const hooks = queriesFactory(config.queries, projectRef ?? 'default') - const mostFrequentlyInvoked = hooks.mostFrequentlyInvoked() - const mostTimeConsuming = hooks.mostTimeConsuming() - const slowestExecutionTime = hooks.slowestExecutionTime() const queryHitRate = hooks.queryHitRate() - const isLoading = [ - mostFrequentlyInvoked.isLoading, - mostTimeConsuming.isLoading, - slowestExecutionTime.isLoading, - queryHitRate.isLoading, - ].every((value) => value) + const orderBy = (router.query.sort as 'lat_desc' | 'lat_asc') || 'lat_desc' + const searchQuery = (router.query.search as string) || '' + const presetMap = { + time: 'mostTimeConsuming', + frequent: 'mostFrequentlyInvoked', + slowest: 'slowestExecutionTime', + } as const + const preset = presetMap[router.query.preset as QueryPerformancePreset] || 'mostTimeConsuming' + + const queryPerformanceQuery = useQueryPerformanceQuery({ + searchQuery, + orderBy, + preset, + }) + + const isLoading = [queryPerformanceQuery.isLoading, queryHitRate.isLoading].every( + (value) => value + ) const handleRefresh = async () => { - mostFrequentlyInvoked.runQuery() - mostTimeConsuming.runQuery() - slowestExecutionTime.runQuery() + queryPerformanceQuery.runQuery() queryHitRate.runQuery() } @@ -98,7 +127,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => { const helperTextClassNames = 'prose text-sm max-w-2xl text-foreground-light' return ( - +
{tableIndexEfficiencyEnabled && ( { : dangerAlert}
- {(queryHitRate?.data![0]?.ratio * 100).toFixed(2)} + {queryHitRate?.data && (queryHitRate?.data[0]?.ratio * 100).toFixed(2)} %
@@ -146,7 +175,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => { : dangerAlert}
- {(queryHitRate?.data![1]?.ratio * 100).toFixed(2)} + {queryHitRate?.data && (queryHitRate?.data[1]?.ratio * 100).toFixed(2)} %
@@ -199,179 +228,180 @@ const QueryPerformanceReport: NextPageWithLayout = () => { />
- - + { + // To reset the search and sort query params when switching tabs + const { sort, search, ...rest } = router.query + router.push({ + ...router, + query: { + ...rest, + preset: e, + }, + }) + }} + > +
{TimeConsumingHelperText} -
+
+ - Role - Time Consumed - Calls - Total Time - Query + Role + Query + Calls + Time Consumed + Total Time (Latency) } body={ - !isLoading && mostTimeConsuming && mostTimeConsuming?.data ? ( - mostTimeConsuming?.data?.map((item, i) => { + !queryPerformanceQuery.isLoading ? ( + queryPerformanceQuery?.data?.map((item, i) => { return ( - - + + {item.rolname} - + +

{item.query}

+
+ {item.calls} + {item.prop_total_time} - - {item.calls} - - + {item.total_time.toFixed(2)}ms - -

{item.query}

- -
-
+ ) }) ) : ( - <> + ) } /> - +
{MostFrequentHelperText} -
+
+
- {/* source */} - Role - Avg. Roles - Calls - Max Time - Mean Time - Min Time - Total Time - Query + Role + Query + Avg. Roles + Calls + Max Time + Mean Time + Min Time + Total Time (Latency) } body={ - !isLoading && mostFrequentlyInvoked && mostFrequentlyInvoked?.data ? ( - mostFrequentlyInvoked.data?.map((item, i) => { + queryPerformanceQuery.isLoading ? ( + + ) : ( + queryPerformanceQuery.data?.map((item, i) => { return ( - - + + {item.rolname} - - {item.avg_rows} + +

{item.query}

- - {item.calls} + {item.avg_rows} + {item.calls} + + {item.max_time?.toFixed(2)}ms - - {item.max_time.toFixed(2)}ms + + {item.mean_time?.toFixed(2)}ms - - {item.mean_time.toFixed(2)}ms + + {item.min_time?.toFixed(2)}ms - - {item.min_time.toFixed(2)}ms + + {item.total_time?.toFixed(2)}ms - - {item.total_time.toFixed(2)}ms - - -

{item.query}

- -
-
+ ) }) - ) : ( - <> ) } /> - +
{SlowestExecutionHelperText} -
+
+
Role + QueryAvg RowsCallsMax TimeMean TimeMin Time - Total Time - Query + Total Time (Latency) } body={ - !isLoading && slowestExecutionTime && slowestExecutionTime?.data ? ( - slowestExecutionTime.data?.map((item, i) => { + queryPerformanceQuery.isLoading ? ( + + ) : ( + queryPerformanceQuery.data?.map((item, i) => { return ( - - + + {item.rolname} - - {item.avg_rows} + +

{item.query}

- - {item.calls} + {item.avg_rows} + {item.calls} + {item.max_time?.toFixed(2)}ms + {item.mean_time?.toFixed(2)}ms + {item.min_time?.toFixed(2)}ms + + {item.total_time?.toFixed(2)}ms - - {item.max_time.toFixed(2)}ms - - - {item.mean_time.toFixed(2)}ms - - - {item.min_time.toFixed(2)}ms - - - {item.total_time.toFixed(2)}ms - - -

{item.query}

- -
-
+ ) }) - ) : ( - <> ) } /> @@ -380,16 +410,6 @@ const QueryPerformanceReport: NextPageWithLayout = () => { - - ) -} - -const QueryActions = ({ sql, className }: { sql: string; className: string }) => { - if (sql.includes('insufficient privilege')) return null - - return ( -
-
) } @@ -399,3 +419,149 @@ QueryPerformanceReport.getLayout = (page) => ( ) export default observer(QueryPerformanceReport) + +function QueryPerformanceFilterBar({ + isLoading, + onRefreshClick, +}: { + isLoading: boolean + onRefreshClick: () => void +}) { + const router = useRouter() + const defaultSearchQueryValue = router.query.search ? String(router.query.search) : '' + const defaultSortByValue = router.query.sort ? String(router.query.sort) : 'lat_desc' + const [searchInputVal, setSearchInputVal] = useState(defaultSearchQueryValue) + const [sortByValue, setSortByValue] = useState(defaultSortByValue) + + 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' + } + } + + function onSortChange(sort: string) { + setSortByValue(sort) + router.push({ + ...router, + query: { + ...router.query, + sort, + }, + }) + } + + const ButtonIcon = sortByValue === 'lat_desc' ? IconArrowDown : IconArrowUp + + return ( + <> +
+ { + e.preventDefault() + const formData = new FormData(e.target as HTMLFormElement) + const searchQuery = formData.get('search') + + if (!searchQuery || typeof searchQuery !== 'string') { + // if user has deleted the search query, remove it from the url + const { search, ...rest } = router.query + router.push({ + ...router, + query: { + ...rest, + }, + }) + return + } + + router.push({ + ...router, + query: { + ...router.query, + search: searchQuery, + }, + }) + }} + > + setSearchInputVal(e.target.value)} + autoComplete="off" + icon={ +
+ +
+ } + actions={ + searchInputVal !== '' && ( + + ) + } + /> + + + + + + + + Sort by latency - high to low + + + Sort by latency - low to high + + + + + +
+ +
+
+ + ) +} + +function QueryPerformanceLoadingRow({ colSpan }: { colSpan: number }) { + return ( + <> + {Array(4) + .fill('') + .map((_, i) => ( +
+ + + ))} + + ) +} diff --git a/package-lock.json b/package-lock.json index 1e747be0e60..0ec2b67f9d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11589,6 +11589,14 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, + "node_modules/@tailwindcss/line-clamp": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz", + "integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==", + "peerDependencies": { + "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" + } + }, "node_modules/@tailwindcss/typography": { "version": "0.5.10", "license": "MIT", @@ -37890,6 +37898,7 @@ "@mertasan/tailwindcss-variables": "^2.2.3", "@radix-ui/colors": "^0.1.8", "@tailwindcss/forms": "^0.5.0", + "@tailwindcss/line-clamp": "^0.4.4", "@tailwindcss/typography": "^0.5.9", "deepmerge": "^4.2.2", "eslint-config-next": "^14.1.0", diff --git a/packages/config/package.json b/packages/config/package.json index ffd42f7d271..95760c49806 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -10,6 +10,7 @@ "@mertasan/tailwindcss-variables": "^2.2.3", "@radix-ui/colors": "^0.1.8", "@tailwindcss/forms": "^0.5.0", + "@tailwindcss/line-clamp": "^0.4.4", "@tailwindcss/typography": "^0.5.9", "deepmerge": "^4.2.2", "eslint-config-next": "^14.1.0", diff --git a/packages/config/tailwind.config.js b/packages/config/tailwind.config.js index 5a632876328..6e408f37383 100644 --- a/packages/config/tailwind.config.js +++ b/packages/config/tailwind.config.js @@ -392,7 +392,7 @@ const uiConfig = ui({ // shadcn defaults END }, }, - plugins: [require('@tailwindcss/typography'), require('tailwindcss-animate')], + plugins: [require('@tailwindcss/typography'), require('tailwindcss-animate'), require('@tailwindcss/line-clamp')], }) function arrayMergeFn(destinationArray, sourceArray) {
+ +