diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsights.utils.d.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsights.utils.d.ts new file mode 100644 index 00000000000..152f55707b4 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsights.utils.d.ts @@ -0,0 +1,11 @@ +/** + * Formats a latency value in milliseconds to a human-readable string + * with appropriate units (μs, ms, or s) based on the magnitude. + */ +export function formatLatency(value: number): string + +/** + * Formats large numbers with thousands separators for better readability + * Used for metrics like row counts, query calls, and other statistical values. + */ +export function formatMetricValue(value: number): string diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsights.utils.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsights.utils.ts new file mode 100644 index 00000000000..5dd6f8418ba --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsights.utils.ts @@ -0,0 +1,29 @@ +/** + * Formats a latency value in milliseconds to a human-readable string + * with appropriate units (μs, ms, or s) based on the magnitude. + * + * @param value - The latency value in milliseconds + * @returns Formatted string with appropriate unit + */ +export function formatLatency(value: number): string { + if (value === 0) return '0 ms' + if (value < 1) return `${(value * 1000).toFixed(2)} μs` + if (value < 1000) return `${value.toFixed(2)} ms` + return `${(value / 1000).toFixed(2)} s` +} + +/** + * Formats large numbers with thousands separators for better readability + * Used for metrics like row counts, query calls, and other statistical values + * that can grow to large numbers. + * + * Examples: + * - 1000 -> "1,000" + * - 1000000 -> "1,000,000" + * + * @param value - The number to format + * @returns Formatted string with thousands separators + */ +export function formatMetricValue(value: number): string { + return new Intl.NumberFormat('en-US').format(value) +} diff --git a/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx b/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx index f64b31e0bc8..c33340354fa 100644 --- a/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx +++ b/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx @@ -16,7 +16,9 @@ import { useParams } from 'common' import { Button } from 'ui' import { MetricType } from '../QueryInsights' import { useSingleQueryLatency } from 'data/query-insights/single-query-latency-query' +import { useSingleQueryRows } from 'data/query-insights/single-query-latency-query' import { motion, AnimatePresence } from 'framer-motion' +import { formatLatency, formatMetricValue } from '../QueryInsights.utils' interface MetricsChartProps { data: QueryInsightsMetric[] @@ -24,7 +26,15 @@ interface MetricsChartProps { isLoading: boolean startTime: string endTime: string - selectedQuery?: QueryInsightsQuery | null + selectedQuery: QueryInsightsQuery | null +} + +interface ChartConfigType { + [key: string]: { + label: string + color: string + formatter: (value: number) => string + } } export function MetricsChart({ @@ -36,12 +46,22 @@ export function MetricsChart({ selectedQuery, }: MetricsChartProps) { const { ref } = useParams() - const [visibleMetrics, setVisibleMetrics] = useState({ + const [visibleMetrics, setVisibleMetrics] = useState<{ + p50: boolean + p95: boolean + p99: boolean + p99_9: boolean + query_latency: boolean + rows: boolean + query_rows: boolean + }>({ p50: true, p95: true, p99: true, p99_9: true, query_latency: true, + rows: true, + query_rows: true, }) // Fetch single query latency data if a query is selected @@ -49,8 +69,17 @@ export function MetricsChart({ data: queryLatencyData, isLoading: isLoadingQueryLatency, error: queryLatencyError, - } = useSingleQueryLatency(ref, selectedQuery?.query_id, startTime, endTime, { - enabled: !!selectedQuery, + } = useSingleQueryLatency(ref, selectedQuery?.query_id?.toString(), startTime, endTime, { + enabled: !!selectedQuery && metric === 'query_latency', + }) + + // Fetch single query rows data if a query is selected + const { + data: queryRowsData, + isLoading: isLoadingQueryRows, + error: queryRowsError, + } = useSingleQueryRows(ref, selectedQuery?.query_id?.toString(), startTime, endTime, { + enabled: !!selectedQuery && metric === 'rows_read', }) if (isLoading) { @@ -85,6 +114,178 @@ export function MetricsChart({ ) } + if (metric === 'rows_read') { + // Create the baseline chart data from the metrics + const chartData = data.map((point) => ({ + timestamp: point.timestamp, + rows: Number(point.value) ?? 0, + query_rows: 0, + })) + + // Sort chart data by timestamp + chartData.sort((a, b) => dayjs(a.timestamp).diff(dayjs(b.timestamp))) + + // If we have query rows data, add it to the chart + if (selectedQuery && queryRowsData && queryRowsData.length > 0) { + // Create a map of timestamps to query rows values + const queryRowsMap = new Map() + queryRowsData.forEach((point) => { + queryRowsMap.set(point.timestamp, Number(point.value) || 0) + }) + + // Add query rows data to each chart data point + chartData.forEach((point) => { + if (queryRowsMap.has(point.timestamp)) { + point.query_rows = queryRowsMap.get(point.timestamp)! + } + }) + + // Add any missing query rows points + queryRowsData.forEach((point) => { + if (!chartData.find((d) => d.timestamp === point.timestamp)) { + chartData.push({ + timestamp: point.timestamp, + rows: 0, + query_rows: Number(point.value) || 0, + }) + } + }) + + // Re-sort chart data by timestamp + chartData.sort((a, b) => dayjs(a.timestamp).diff(dayjs(b.timestamp))) + } + + const config: ChartConfigType = { + rows: { + label: 'Rows Read', + color: '#3ECF8E', + formatter: formatMetricValue, + }, + query_rows: { + label: 'Query Rows', + color: '#9BA6B0', + formatter: formatMetricValue, + }, + } + + return ( +
+
+
+ + {Object.entries(config).map(([key, value]) => { + // Only show query rows button if we have a selected query + if (key === 'query_rows' && !selectedQuery) { + return null + } + + const metricValue = Number( + chartData[chartData.length - 1]?.[key as keyof (typeof chartData)[0]] + ) + const formattedValue = !isNaN(metricValue) ? value.formatter(metricValue) : '0' + + return ( + + setVisibleMetrics((prev) => ({ + ...prev, + [key]: !prev[key as keyof typeof visibleMetrics], + })) + } + > +
+ + {value.label}: {formattedValue} + + + ) + })} + +
+
+ + + + {Object.entries(config).map(([key, value]) => ( + + + + + ))} + + + dayjs(value).format('HH:mm')} + /> + + } + /> + } /> + {visibleMetrics.rows && ( + + )} + {selectedQuery && visibleMetrics.query_rows && ( + + )} + + +
+
+
+ ) + } + if (metric === 'query_latency') { // Create the baseline chart data from the metrics const chartData = data.map((point) => { @@ -142,25 +343,30 @@ export function MetricsChart({ const config = { p50: { label: 'p50', - color: 'hsl(var(--brand-default))', + color: '#4B5563', + formatter: formatLatency, }, p95: { label: 'p95', - color: 'hsl(var(--foreground-default))', + color: '#6B7280', + formatter: formatLatency, }, p99: { label: 'p99', - color: 'hsl(var(--warning-default))', + color: '#9CA3AF', + formatter: formatLatency, }, p99_9: { label: 'p99.9', - color: 'hsl(var(--destructive-default))', + color: '#D1D5DB', + formatter: formatLatency, }, query_latency: { - label: 'Query Latency', - color: 'hsl(var(--foreground-default))', + label: 'Query latency', + color: '#3B82F6', + formatter: formatLatency, }, - } satisfies ChartConfig + } satisfies ChartConfigType return (
@@ -270,7 +476,13 @@ export function MetricsChart({ tickMargin={8} tickFormatter={(value) => dayjs(value).format('HH:mm')} /> - + } @@ -284,7 +496,7 @@ export function MetricsChart({ fillOpacity={selectedQuery && visibleMetrics.query_latency ? 0.05 : 0.4} stroke={config.p50.color} strokeOpacity={selectedQuery && visibleMetrics.query_latency ? 0.1 : 0.8} - strokeWidth={2} + strokeWidth={1} dot={false} animationDuration={100} /> @@ -297,7 +509,7 @@ export function MetricsChart({ fillOpacity={selectedQuery && visibleMetrics.query_latency ? 0.05 : 0.4} stroke={config.p95.color} strokeOpacity={selectedQuery && visibleMetrics.query_latency ? 0.1 : 0.8} - strokeWidth={2} + strokeWidth={1} dot={false} animationDuration={100} /> @@ -310,7 +522,7 @@ export function MetricsChart({ fillOpacity={selectedQuery && visibleMetrics.query_latency ? 0.05 : 0.4} stroke={config.p99.color} strokeOpacity={selectedQuery && visibleMetrics.query_latency ? 0.1 : 0.8} - strokeWidth={2} + strokeWidth={1} dot={false} animationDuration={100} /> @@ -323,7 +535,7 @@ export function MetricsChart({ fillOpacity={selectedQuery && visibleMetrics.query_latency ? 0.05 : 0.4} stroke={config.p99_9.color} strokeOpacity={selectedQuery && visibleMetrics.query_latency ? 0.1 : 0.8} - strokeWidth={2} + strokeWidth={1} dot={false} animationDuration={100} /> @@ -357,25 +569,160 @@ export function MetricsChart({ error_count: 0, })) + const toggleButtons = () => { + switch (metric as MetricType) { + case 'query_latency': + return ( + <> + + + + + {selectedQuery && ( + + )} + + ) + + case 'rows_read': + return ( + <> + + {selectedQuery && ( + + )} + + ) + + case 'rows_written': + case 'queries_per_second': + return ( + + ) + + default: + return null + } + } + return ( -
- - -
No data available
-
- } - /> - +
+
{toggleButtons()}
+
+ + +
No data available
+
+ } + /> + +
) } diff --git a/apps/studio/components/interfaces/QueryInsights/components/QueryList.tsx b/apps/studio/components/interfaces/QueryInsights/components/QueryList.tsx index 91a7965dbc0..3b9028c8dcb 100644 --- a/apps/studio/components/interfaces/QueryInsights/components/QueryList.tsx +++ b/apps/studio/components/interfaces/QueryInsights/components/QueryList.tsx @@ -123,13 +123,71 @@ export const QueryList = ({ queries, isLoading, onQuerySelect, selectedQuery }: ), }, { - key: 'rows', - name: 'Rows', + key: 'rows_read', + name: 'Rows Read', resizable: true, minWidth: 100, headerCellClass: '', renderCell: (props) => ( -
{props.row.rows.toLocaleString()}
+
+ {((props.row.rows_read ?? 0) > 0 ? props.row.rows_read : 0).toLocaleString()} +
+ ), + }, + { + key: 'rows_insert', + name: 'Rows Insert', + resizable: true, + minWidth: 100, + headerCellClass: '', + renderCell: (props) => ( +
+ {((props.row.rows_insert ?? 0) > 0 ? props.row.rows_insert : 0).toLocaleString()} +
+ ), + }, + { + key: 'rows_update', + name: 'Rows Update', + resizable: true, + minWidth: 100, + headerCellClass: '', + renderCell: (props) => ( +
+ {((props.row.rows_update ?? 0) > 0 ? props.row.rows_update : 0).toLocaleString()} +
+ ), + }, + { + key: 'rows_delete', + name: 'Rows Delete', + resizable: true, + minWidth: 100, + headerCellClass: '', + renderCell: (props) => ( +
+ {((props.row.rows_delete ?? 0) > 0 ? props.row.rows_delete : 0).toLocaleString()} +
), }, // { diff --git a/apps/studio/data/query-insights/query-insights-query.ts b/apps/studio/data/query-insights/query-insights-query.ts index 91e3568bce0..c9e639a05b2 100644 --- a/apps/studio/data/query-insights/query-insights-query.ts +++ b/apps/studio/data/query-insights/query-insights-query.ts @@ -5,8 +5,9 @@ import { queryInsightsKeys } from './keys' export type QueryInsightsMetric = { timestamp: string value?: number - database: string rows?: number + cmd_type_text?: string + database: string shared_blks_read?: number shared_blks_hit?: number mean_exec_time?: number @@ -22,7 +23,10 @@ export type QueryInsightsQuery = { query: string total_time: number calls: number - rows: number + rows_read: number + rows_insert: number + rows_update: number + rows_delete: number shared_blks_read: number shared_blks_hit: number mean_exec_time: number @@ -54,28 +58,29 @@ const getMetricsSql = (metric: string, startTime: string, endTime: string) => { return /* SQL */ ` SELECT bucket_start_time as timestamp, - SUM(rows) / 300 as value, -- Convert to per-second rate (5 mins = 300 seconds) + SUM(rows) as value, datname as database FROM pg_stat_monitor WHERE bucket_start_time >= '${startTime}'::timestamptz AND bucket_start_time <= '${endTime}'::timestamptz - AND bucket_done = true -- Only include completed buckets - GROUP BY bucket_start_time, datname - ORDER BY timestamp ASC - ` - case 'rows_written': - return /* SQL */ ` - SELECT - bucket_start_time as timestamp, - SUM(rows) / 300 as value, -- Convert to per-second rate (5 mins = 300 seconds) - datname as database - FROM pg_stat_monitor - WHERE bucket_start_time >= '${startTime}'::timestamptz - AND bucket_start_time <= '${endTime}'::timestamptz - AND bucket_done = true -- Only include completed buckets + AND bucket_done = true + AND cmd_type = 1 GROUP BY bucket_start_time, datname ORDER BY timestamp ASC ` + // case 'rows_written': + // return /* SQL */ ` + // SELECT + // bucket_start_time as timestamp, + // SUM(rows) / 300 as value, -- Convert to per-second rate (5 mins = 300 seconds) + // datname as database + // FROM pg_stat_monitor + // WHERE bucket_start_time >= '${startTime}'::timestamptz + // AND bucket_start_time <= '${endTime}'::timestamptz + // AND bucket_done = true -- Only include completed buckets + // GROUP BY bucket_start_time, datname + // ORDER BY timestamp ASC + // ` case 'query_latency': return /* SQL */ ` SELECT @@ -127,7 +132,10 @@ const getQueriesSql = (startTime: string, endTime: string) => /* SQL */ ` COALESCE(MAX(top_query), MAX(query)) as query, SUM(total_exec_time) as total_time, SUM(calls) as calls, - SUM(rows) as rows, + SUM(CASE WHEN cmd_type = 1 THEN rows ELSE 0 END) as rows_read, + SUM(CASE WHEN cmd_type = 2 THEN rows ELSE 0 END) as rows_insert, + SUM(CASE WHEN cmd_type = 3 THEN rows ELSE 0 END) as rows_update, + SUM(CASE WHEN cmd_type = 4 THEN rows ELSE 0 END) as rows_delete, SUM(shared_blks_read) as shared_blks_read, SUM(shared_blks_hit) as shared_blks_hit, SUM(total_exec_time) / NULLIF(SUM(calls), 0) as mean_exec_time, @@ -137,9 +145,9 @@ const getQueriesSql = (startTime: string, endTime: string) => /* SQL */ ` FROM pg_stat_monitor WHERE bucket_start_time >= '${startTime}' AND bucket_start_time <= '${endTime}' - AND bucket_done = true -- Only include completed buckets + AND bucket_done = true GROUP BY queryid - ORDER BY SUM(calls) DESC + ORDER BY mean_exec_time DESC LIMIT 100 ` diff --git a/apps/studio/data/query-insights/single-query-latency-query.ts b/apps/studio/data/query-insights/single-query-latency-query.ts index 4313a7d1d7d..53b1c83a7cc 100644 --- a/apps/studio/data/query-insights/single-query-latency-query.ts +++ b/apps/studio/data/query-insights/single-query-latency-query.ts @@ -30,6 +30,21 @@ const getSingleQueryLatencySql = ( ORDER BY timestamp ASC ` +const getSingleQueryRowsSql = (queryId: string, startTime: string, endTime: string) => /* SQL */ ` + SELECT + bucket_start_time as timestamp, + SUM(rows) as value, + datname as database + FROM pg_stat_monitor + WHERE bucket_start_time >= '${startTime}'::timestamptz + AND bucket_start_time <= '${endTime}'::timestamptz + AND bucket_done = true + AND queryid = ${queryId}::bigint + AND cmd_type = 1 + GROUP BY bucket_start_time, datname + ORDER BY timestamp ASC +` + export function useSingleQueryLatency( projectRef: string | undefined, queryId: string | undefined, @@ -55,3 +70,28 @@ export function useSingleQueryLatency( ...options, }) } + +export function useSingleQueryRows( + projectRef: string | undefined, + queryId: string | undefined, + startTime: string, + endTime: string, + options?: UseQueryOptions +) { + return useQuery({ + queryKey: [...singleQueryLatencyKeys.base, 'rows', projectRef, queryId, startTime, endTime], + queryFn: async () => { + if (!projectRef) throw new Error('Project ref is required') + if (!queryId) return [] + + const { result } = await executeSql({ + projectRef, + sql: getSingleQueryRowsSql(queryId, startTime, endTime), + }) + + return result as QueryInsightsMetric[] + }, + enabled: !!queryId && !!projectRef, + ...options, + }) +}