diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx index ca32d0806c5..6da70befeeb 100644 --- a/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx @@ -17,7 +17,12 @@ import { import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' -export type MetricType = 'rows_read' | 'rows_written' | 'query_latency' | 'queries_per_second' +export type MetricType = + | 'rows_read' + | 'rows_written' + | 'query_latency' + | 'queries_per_second' + | 'cache_hits' const METRICS: { id: MetricType; label: string; description: string }[] = [ { @@ -40,6 +45,11 @@ const METRICS: { id: MetricType; label: string; description: string }[] = [ label: 'Queries per Second', description: 'Number of queries executed per second', }, + { + id: 'cache_hits', + label: 'Cache Hits', + description: 'Statistics for shared buffer cache usage', + }, ] export const QueryInsights = () => { diff --git a/apps/studio/components/interfaces/QueryInsights/chartConfigs.ts b/apps/studio/components/interfaces/QueryInsights/chartConfigs.ts new file mode 100644 index 00000000000..7f5adde46b4 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/chartConfigs.ts @@ -0,0 +1,281 @@ +import { QueryInsightsMetric } from 'data/query-insights/query-insights-query' +import { formatLatency, formatMetricValue } from './QueryInsights.utils' +import dayjs from 'dayjs' + +export interface ChartConfigType { + [key: string]: { + label: string + color: string + formatter: (value: number) => string + } +} + +// Define return types for each chart configuration +export interface RowsReadChartData { + timestamp: string + rows: number + query_rows: number +} + +export interface QueryLatencyChartData { + timestamp: string + p50: number | undefined + p95: number | undefined + p99: number | undefined + p99_9: number | undefined + query_latency: number +} + +export interface CacheHitsChartData { + timestamp: string + cache_hit_ratio: number + cache_miss_ratio: number + shared_blks_hit: number + shared_blks_read: number + shared_blks_dirtied: number + shared_blks_written: number +} + +export interface GenericChartData { + timestamp: string + ok_count: number + warning_count: number + error_count: number +} + +export interface ChartConfigResult { + chartData: T[] + config: ChartConfigType +} + +/** + * Prepares data and configuration for the Rows Read chart + */ +export function getRowsReadConfig( + data: QueryInsightsMetric[], + queryRowsData?: QueryInsightsMetric[] +): ChartConfigResult { + // 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 (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 { chartData, config } +} + +/** + * Prepares data and configuration for the Query Latency chart + */ +export function getQueryLatencyConfig( + data: QueryInsightsMetric[], + queryLatencyData?: QueryInsightsMetric[] +): ChartConfigResult { + // Create the baseline chart data from the metrics + const chartData = data.map((point) => { + // Keep the original timestamp to maintain 5-minute intervals + const timestamp = point.timestamp + return { + timestamp, + p50: point.p50, + p95: point.p95, + p99: point.p99, + p99_9: point.p99_9, + // Initialize query latency as 0 + query_latency: 0, + } + }) + + // Sort chart data by timestamp + chartData.sort((a, b) => { + return dayjs(a.timestamp).diff(dayjs(b.timestamp)) + }) + + // If we have query latency data, add it to the chart + if (queryLatencyData && queryLatencyData.length > 0) { + // Create a map of timestamps to query latency values + const queryLatencyMap = new Map() + queryLatencyData.forEach((point) => { + queryLatencyMap.set(point.timestamp, point.value || 0) + }) + + // Add query latency data to each chart data point + chartData.forEach((point) => { + if (queryLatencyMap.has(point.timestamp)) { + point.query_latency = queryLatencyMap.get(point.timestamp)! + } + }) + + // Add any missing query latency points + queryLatencyData.forEach((point) => { + if (!chartData.find((d) => d.timestamp === point.timestamp)) { + chartData.push({ + timestamp: point.timestamp, + p50: 0, + p95: 0, + p99: 0, + p99_9: 0, + query_latency: point.value || 0, + }) + } + }) + + // Re-sort chart data by timestamp + chartData.sort((a, b) => dayjs(a.timestamp).diff(dayjs(b.timestamp))) + } + + const config: ChartConfigType = { + p50: { + label: 'p50', + color: '#4B5563', + formatter: formatLatency, + }, + p95: { + label: 'p95', + color: '#6B7280', + formatter: formatLatency, + }, + p99: { + label: 'p99', + color: '#9CA3AF', + formatter: formatLatency, + }, + p99_9: { + label: 'p99.9', + color: '#D1D5DB', + formatter: formatLatency, + }, + query_latency: { + label: 'Query latency', + color: '#3B82F6', + formatter: formatLatency, + }, + } + + return { chartData, config } +} + +/** + * Prepares data and configuration for the Cache Hits chart + */ +export function getCacheHitsConfig( + data: QueryInsightsMetric[], + queryCacheData?: QueryInsightsMetric[] +): ChartConfigResult { + // Create the baseline chart data from the metrics + const chartData = data.map((point) => { + const hit = point.shared_blks_hit ?? 0 + const read = point.shared_blks_read ?? 0 + const total = hit + read + + // Calculate percentages, handling division by zero + const cache_hit_ratio = total > 0 ? (hit / total) * 100 : 0 + // Cache miss is the inverse of cache hit + const cache_miss_ratio = total > 0 ? 100 - cache_hit_ratio : 0 + + return { + timestamp: point.timestamp, + shared_blks_hit: hit, + shared_blks_read: read, + shared_blks_dirtied: point.shared_blks_dirtied ?? 0, + shared_blks_written: point.shared_blks_written ?? 0, + cache_hit_ratio, + cache_miss_ratio, + } + }) + + // Sort chart data by timestamp + chartData.sort((a, b) => dayjs(a.timestamp).diff(dayjs(b.timestamp))) + + // Format percentages with % sign + const percentFormatter = (value: number) => `${value.toFixed(1)}%` + + const config: ChartConfigType = { + cache_hit_ratio: { + label: 'Cache Hit Ratio', + color: '#3ECF8E', // Green + formatter: percentFormatter, + }, + cache_miss_ratio: { + label: 'Cache Miss Ratio', + color: '#EF4444', // Red/danger + formatter: percentFormatter, + }, + shared_blks_dirtied: { + label: 'Blocks Modified', + color: '#9BA6B0', // Gray + formatter: formatMetricValue, + }, + shared_blks_written: { + label: 'Blocks Written', + color: '#6366F1', // Indigo + formatter: formatMetricValue, + }, + } + + return { chartData, config } +} + +/** + * Prepares data and configuration for generic metric charts like rows_written and queries_per_second + */ +export function getGenericMetricConfig(data: QueryInsightsMetric[]): { + chartData: GenericChartData[] +} { + // For other metrics, prepare data for the bar chart + const chartData = data.map((point) => ({ + timestamp: dayjs(point.timestamp).toISOString(), + ok_count: point.value ?? 0, + warning_count: 0, + error_count: 0, + })) + + return { chartData } +} diff --git a/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx b/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx index c33340354fa..7c1fc1ae04f 100644 --- a/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx +++ b/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx @@ -11,7 +11,7 @@ import { ChartLegendContent, cn, } from 'ui' -import { useState } from 'react' +import { useState, useMemo } from 'react' import { useParams } from 'common' import { Button } from 'ui' import { MetricType } from '../QueryInsights' @@ -19,6 +19,17 @@ import { useSingleQueryLatency } from 'data/query-insights/single-query-latency- import { useSingleQueryRows } from 'data/query-insights/single-query-latency-query' import { motion, AnimatePresence } from 'framer-motion' import { formatLatency, formatMetricValue } from '../QueryInsights.utils' +import { + getRowsReadConfig, + getQueryLatencyConfig, + getGenericMetricConfig, + ChartConfigType, + RowsReadChartData, + QueryLatencyChartData, + GenericChartData, + ChartConfigResult, + getCacheHitsConfig, +} from '../chartConfigs' interface MetricsChartProps { data: QueryInsightsMetric[] @@ -29,14 +40,6 @@ interface MetricsChartProps { selectedQuery: QueryInsightsQuery | null } -interface ChartConfigType { - [key: string]: { - label: string - color: string - formatter: (value: number) => string - } -} - export function MetricsChart({ data, metric, @@ -54,6 +57,12 @@ export function MetricsChart({ query_latency: boolean rows: boolean query_rows: boolean + shared_blks_hit: boolean + shared_blks_read: boolean + shared_blks_dirtied: boolean + shared_blks_written: boolean + cache_hit_ratio: boolean + cache_miss_ratio: boolean }>({ p50: true, p95: true, @@ -62,6 +71,12 @@ export function MetricsChart({ query_latency: true, rows: true, query_rows: true, + shared_blks_hit: true, + shared_blks_read: true, + shared_blks_dirtied: true, + shared_blks_written: true, + cache_hit_ratio: true, + cache_miss_ratio: true, }) // Fetch single query latency data if a query is selected @@ -82,6 +97,27 @@ export function MetricsChart({ enabled: !!selectedQuery && metric === 'rows_read', }) + // Derive chart configuration based on the selected metric + const chartConfig = useMemo(() => { + if (!Array.isArray(data) || data.length === 0) { + return null + } + + switch (metric) { + case 'rows_read': + return getRowsReadConfig(data, queryRowsData) + case 'query_latency': + return getQueryLatencyConfig(data, queryLatencyData) + case 'cache_hits': + return getCacheHitsConfig(data) + case 'rows_written': + case 'queries_per_second': + return getGenericMetricConfig(data) + default: + return null + } + }, [data, metric, queryLatencyData, queryRowsData]) + if (isLoading) { return (
@@ -98,7 +134,7 @@ export function MetricsChart({ ) } - if (!Array.isArray(data) || data.length === 0) { + if (!Array.isArray(data) || data.length === 0 || !chartConfig) { return (
({ - 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, - }, - } + // Render charts based on metric type + if (metric === 'rows_read' || metric === 'query_latency' || metric === 'cache_hits') { + const config = (chartConfig as ChartConfigResult).config + const chartData = (chartConfig as ChartConfigResult).chartData return (
@@ -174,238 +161,8 @@ export function MetricsChart({
{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) => { - // Keep the original timestamp to maintain 5-minute intervals - const timestamp = point.timestamp - return { - timestamp, - p50: point.p50, - p95: point.p95, - p99: point.p99, - p99_9: point.p99_9, - // Initialize query latency as 0 instead of null - query_latency: 0, - } - }) - - // Sort chart data by timestamp - chartData.sort((a, b) => { - return dayjs(a.timestamp).diff(dayjs(b.timestamp)) - }) - - // If we have query latency data, add it to the chart - if (selectedQuery && queryLatencyData && queryLatencyData.length > 0) { - // Create a map of timestamps to query latency values - const queryLatencyMap = new Map() - queryLatencyData.forEach((point) => { - queryLatencyMap.set(point.timestamp, point.value || 0) - }) - - // Add query latency data to each chart data point - chartData.forEach((point) => { - if (queryLatencyMap.has(point.timestamp)) { - point.query_latency = queryLatencyMap.get(point.timestamp)! - } - }) - - // Add any missing query latency points - queryLatencyData.forEach((point) => { - if (!chartData.find((d) => d.timestamp === point.timestamp)) { - chartData.push({ - timestamp: point.timestamp, - p50: 0, - p95: 0, - p99: 0, - p99_9: 0, - query_latency: point.value || 0, - }) - } - }) - - // Re-sort chart data by timestamp - chartData.sort((a, b) => dayjs(a.timestamp).diff(dayjs(b.timestamp))) - } - - const config = { - p50: { - label: 'p50', - color: '#4B5563', - formatter: formatLatency, - }, - p95: { - label: 'p95', - color: '#6B7280', - formatter: formatLatency, - }, - p99: { - label: 'p99', - color: '#9CA3AF', - formatter: formatLatency, - }, - p99_9: { - label: 'p99.9', - color: '#D1D5DB', - formatter: formatLatency, - }, - query_latency: { - label: 'Query latency', - color: '#3B82F6', - formatter: formatLatency, - }, - } satisfies ChartConfigType - - return ( -
- - {/* {selectedQuery && ( - -
- Selected Query:{' '} - - {selectedQuery.query.substring(0, 80)}... - -
- - Mean Time: {Math.round(selectedQuery.mean_exec_time)} ms - - - Total Calls: {selectedQuery.calls.toLocaleString()} - - {isLoadingQueryLatency && Loading query latency data...} - {Boolean(queryLatencyError) && ( - Error loading query latency - )} -
-
-
- )} */} -
-
-
- - {Object.entries(config).map(([key, value]) => { - // Only show query latency button if we have a selected query - if (key === 'query_latency' && !selectedQuery) { + // Only show specific buttons based on conditions + if ((key === 'query_rows' || key === 'query_latency') && !selectedQuery) { return null } @@ -413,8 +170,8 @@ export function MetricsChart({ chartData[chartData.length - 1]?.[key as keyof (typeof chartData)[0]] ) const formattedValue = !isNaN(metricValue) - ? `${Math.round(metricValue)} ms` - : '0 ms' + ? (value as any).formatter(metricValue) + : '0' return ( - {value.label}: {formattedValue} + {(value as any).label}: {formattedValue} ) @@ -461,8 +218,8 @@ export function MetricsChart({ {Object.entries(config).map(([key, value]) => ( - - + + ))} @@ -477,81 +234,199 @@ export function MetricsChart({ tickFormatter={(value) => dayjs(value).format('HH:mm')} /> `${value}%` : formatMetricValue + } + domain={ + metric === 'cache_hits' + ? [0, 100] + : metric === 'query_latency' + ? ['auto', 'auto'] + : ['dataMin', 'dataMax'] + } + allowDataOverflow={false} + width={metric === 'query_latency' ? 80 : undefined} + tickCount={metric === 'query_latency' ? 5 : undefined} + yAxisId="left" /> + {metric === 'cache_hits' && ( + + )} } /> } /> - {visibleMetrics.p50 && ( - + + {/* Render series based on metric type */} + {metric === 'rows_read' && ( + <> + {visibleMetrics.rows && ( + + )} + {selectedQuery && visibleMetrics.query_rows && ( + + )} + )} - {visibleMetrics.p95 && ( - + + {metric === 'query_latency' && ( + <> + {visibleMetrics.p50 && ( + + )} + {visibleMetrics.p95 && ( + + )} + {visibleMetrics.p99 && ( + + )} + {visibleMetrics.p99_9 && ( + + )} + {selectedQuery && visibleMetrics.query_latency && ( + + )} + )} - {visibleMetrics.p99 && ( - - )} - {visibleMetrics.p99_9 && ( - - )} - {selectedQuery && visibleMetrics.query_latency && ( - + + {metric === 'cache_hits' && ( + <> + {visibleMetrics.cache_hit_ratio && ( + + )} + {visibleMetrics.cache_miss_ratio && ( + + )} + {visibleMetrics.shared_blks_dirtied && ( + + )} + {visibleMetrics.shared_blks_written && ( + + )} + )} @@ -562,167 +437,27 @@ export function MetricsChart({ } // For other metrics, use the bar chart - const chartData = data.map((point) => ({ - timestamp: dayjs(point.timestamp).toISOString(), - ok_count: point.value ?? 0, - warning_count: 0, - 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 - } - } + const { chartData } = chartConfig as { chartData: GenericChartData[] } return ( -
-
{toggleButtons()}
-
- - -
No data available
-
- } - /> - -
+
+ + +
No data available
+
+ } + /> +
) } diff --git a/apps/studio/data/query-insights/query-insights-query.ts b/apps/studio/data/query-insights/query-insights-query.ts index 0d2bd90a23b..3be7627fb9d 100644 --- a/apps/studio/data/query-insights/query-insights-query.ts +++ b/apps/studio/data/query-insights/query-insights-query.ts @@ -10,6 +10,8 @@ export type QueryInsightsMetric = { database: string shared_blks_read?: number shared_blks_hit?: number + shared_blks_dirtied?: number + shared_blks_written?: number mean_exec_time?: number total_exec_time?: number p50?: number @@ -119,6 +121,22 @@ const getMetricsSql = (metric: string, startTime: string, endTime: string) => { GROUP BY bucket_start_time, datname ORDER BY timestamp ASC ` + case 'cache_hits': + return /* SQL */ ` + SELECT + bucket_start_time as timestamp, + SUM(shared_blks_hit) as shared_blks_hit, + SUM(shared_blks_read) as shared_blks_read, + SUM(shared_blks_dirtied) as shared_blks_dirtied, + SUM(shared_blks_written) as shared_blks_written, + 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 + ` default: return /* SQL */ ` SELECT