From cba8ca919da0d3488bd0641c0faa4353cd1e9237 Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Tue, 8 Apr 2025 14:11:59 +0800 Subject: [PATCH] init --- .../QueryInsights/QueryInsights.tsx | 183 +++++++++++++++ .../QueryInsights/QueryInsightsPage.tsx | 48 ++++ .../QueryInsights/components/MetricsChart.tsx | 214 ++++++++++++++++++ .../components/QueryInsightsBarChart.tsx | 128 +++++++++++ .../components/QueryInsightsChart.tsx | 1 + .../QueryInsights/components/QueryList.tsx | 128 +++++++++++ .../AdvisorsLayout/AdvisorsMenu.utils.ts | 6 + apps/studio/data/query-insights/keys.ts | 8 + .../query-insights/query-insights-query.ts | 180 +++++++++++++++ .../pages/project/[ref]/advisors/query.tsx | 29 +++ 10 files changed, 925 insertions(+) create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsPage.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/components/QueryInsightsBarChart.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/components/QueryInsightsChart.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/components/QueryList.tsx create mode 100644 apps/studio/data/query-insights/keys.ts create mode 100644 apps/studio/data/query-insights/query-insights-query.ts create mode 100644 apps/studio/pages/project/[ref]/advisors/query.tsx diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx new file mode 100644 index 00000000000..1db94719024 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { DateRangePicker } from 'components/ui/DateRangePicker' +import { TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_, cn } from 'ui' +import dayjs from 'dayjs' +import { InformationCircleIcon } from '@heroicons/react/16/solid' +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { MetricsChart } from './components/MetricsChart' +import { QueryList } from './components/QueryList' +import { + useQueryInsightsMetrics, + useQueryInsightsQueries, +} from 'data/query-insights/query-insights-query' +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' + +const METRICS: { id: MetricType; label: string; description: string }[] = [ + { + id: 'query_latency', + label: 'Query Latency', + description: 'Average query execution time in milliseconds', + }, + { + id: 'rows_read', + label: 'Rows Read', + description: 'Number of rows read by queries', + }, + { + id: 'rows_written', + label: 'Rows Written', + description: 'Number of rows written by queries', + }, + { + id: 'queries_per_second', + label: 'Queries per Second', + description: 'Number of queries executed per second', + }, +] + +export const QueryInsights = () => { + const router = useRouter() + const { ref } = useParams() + const { project } = useProjectContext() + const state = useDatabaseSelectorStateSnapshot() + const { data: databases } = useReadReplicasQuery({ projectRef: ref }) + + const [selectedMetric, setSelectedMetric] = useState('query_latency') + const [selectedTimeRange, setSelectedTimeRange] = useState('24h') + const [timeRange, setTimeRange] = useState({ + period_start: { + date: dayjs().subtract(24, 'hour').format('YYYY-MM-DD HH:mm:ssZ'), + time_period: '24h', + }, + period_end: { + date: dayjs().format('YYYY-MM-DD HH:mm:ssZ'), + time_period: 'now', + }, + }) + + const { data: metricsData, isLoading: isLoadingMetrics } = useQueryInsightsMetrics( + ref, + selectedMetric, + timeRange.period_start.date, + timeRange.period_end.date + ) + + const { data: queriesData, isLoading: isLoadingQueries } = useQueryInsightsQueries( + ref, + timeRange.period_start.date, + timeRange.period_end.date + ) + + const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) + + useEffect(() => { + // Update the time range when selectedTimeRange changes + switch (selectedTimeRange) { + case '24h': + setTimeRange({ + period_start: { + date: dayjs().subtract(24, 'hour').format('YYYY-MM-DD HH:mm:ssZ'), + time_period: '24h', + }, + period_end: { + date: dayjs().format('YYYY-MM-DD HH:mm:ssZ'), + time_period: 'now', + }, + }) + break + case '7d': + setTimeRange({ + period_start: { + date: dayjs().subtract(7, 'day').format('YYYY-MM-DD HH:mm:ssZ'), + time_period: '7d', + }, + period_end: { + date: dayjs().format('YYYY-MM-DD HH:mm:ssZ'), + time_period: 'now', + }, + }) + break + case '30d': + setTimeRange({ + period_start: { + date: dayjs().subtract(30, 'day').format('YYYY-MM-DD HH:mm:ssZ'), + time_period: '30d', + }, + period_end: { + date: dayjs().format('YYYY-MM-DD HH:mm:ssZ'), + time_period: 'now', + }, + }) + break + } + }, [selectedTimeRange]) + + return ( +
+
+
+

Query Metrics

+

+ Monitor and analyze query performance over time +

+
+ { + setSelectedTimeRange(period_start.time_period) + setTimeRange({ period_start, period_end }) + }} + options={[ + { + key: '24h', + label: 'Last 24 hours', + interval: '1h', + }, + { + key: '7d', + label: 'Last 7 days', + interval: '1d', + }, + { + key: '30d', + label: 'Last 30 days', + interval: '1d', + }, + ]} + /> +
+ + setSelectedMetric(value as MetricType)} + className="space-y-4" + > + + {METRICS.map((metric) => ( + + {metric.label} + + ))} + + +
+ +
+
+ +
+

Recent Queries

+

Detailed view of recent query executions

+
+ + +
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsPage.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsPage.tsx new file mode 100644 index 00000000000..5050ebcbc6c --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsPage.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { FormHeader } from 'components/ui/Forms/FormHeader' +import DatabaseSelector from 'components/ui/DatabaseSelector' +import { DateRangePicker } from 'components/ui/DateRangePicker' +import { MetricsOverview } from './components/MetricsOverview' +import { QueryList } from './components/QueryList' +import dayjs from 'dayjs' + +export type MetricType = 'rows_read' | 'rows_written' | 'query_latency' | 'queries_per_second' + +const QueryInsightsPage = () => { + const router = useRouter() + const { ref } = useParams() + const { project } = useProjectContext() + + const [selectedMetric, setSelectedMetric] = useState('rows_read') + const [timeRange, setTimeRange] = useState({ + startDate: dayjs().subtract(24, 'hour'), + endDate: dayjs(), + }) + + return ( +
+ } + /> + +
+ + +
+
+ ) +} + +export default QueryInsightsPage diff --git a/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx b/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx new file mode 100644 index 00000000000..9628e633551 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/components/MetricsChart.tsx @@ -0,0 +1,214 @@ +import { LogsBarChart } from 'ui-patterns/LogsBarChart' +import { QueryInsightsMetric } from 'data/query-insights/query-insights-query' +import dayjs from 'dayjs' +import { Line, LineChart, XAxis, YAxis, CartesianGrid, Area, AreaChart } from 'recharts' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + cn, +} from 'ui' +import { useState } from 'react' + +interface MetricsChartProps { + data: QueryInsightsMetric[] | undefined + metric: string + isLoading: boolean +} + +export const MetricsChart = ({ data, metric, isLoading }: MetricsChartProps) => { + const [visibleMetrics, setVisibleMetrics] = useState({ + p50: true, + p95: true, + p99: true, + p99_9: true, + }) + + if (isLoading) { + return ( +
+
Loading metrics...
+
+ ) + } + + if (!Array.isArray(data) || data.length === 0) { + return ( +
+
No data available
+
+ ) + } + + if (metric === 'query_latency') { + const chartData = data.map((point) => ({ + timestamp: dayjs(point.timestamp).format('HH:mm'), + p50: point.p50, + p95: point.p95, + p99: point.p99, + p99_9: point.p99_9, + })) + + const config = { + p50: { + label: 'p50', + color: 'hsl(var(--brand-default))', + }, + p95: { + label: 'p95', + color: 'hsl(var(--foreground-default))', + }, + p99: { + label: 'p99', + color: 'hsl(var(--warning-default))', + }, + p99_9: { + label: 'p99.9', + color: 'hsl(var(--destructive-default))', + }, + } satisfies ChartConfig + + return ( +
+
+ {Object.entries(config).map(([key, value]) => { + const metricValue = Number( + chartData[chartData.length - 1]?.[key as keyof (typeof chartData)[0]] + ) + const formattedValue = !isNaN(metricValue) ? `${Math.round(metricValue)} ms` : '0 ms' + + return ( + + ) + })} +
+ + + + {Object.entries(config).map(([key, value]) => ( + + + + + ))} + + + + + } + /> + } /> + {visibleMetrics.p50 && ( + + )} + {visibleMetrics.p95 && ( + + )} + {visibleMetrics.p99 && ( + + )} + {visibleMetrics.p99_9 && ( + + )} + + +
+ ) + } + + // 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, + })) + + return ( + +
No data available
+ + } + /> + ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/components/QueryInsightsBarChart.tsx b/apps/studio/components/interfaces/QueryInsights/components/QueryInsightsBarChart.tsx new file mode 100644 index 00000000000..6577025d6b7 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/components/QueryInsightsBarChart.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import { Line, XAxis, YAxis, Tooltip, Area, ComposedChart } from 'recharts' +import { ChartContainer, ChartConfig } from 'components/ui/Charts' +import { CHART_COLORS } from 'components/ui/Charts/Charts.constants' +import dayjs from 'dayjs' +import { cn } from 'ui' + +export interface QueryInsightsChartDatum { + timestamp: string + p50: number + p95: number + p99: number + p999: number +} + +export const QueryInsightsLineChart = ({ + data, + onPointClick, + EmptyState, + DateTimeFormat = 'MMM D, HH:mm', +}: { + data: QueryInsightsChartDatum[] + onPointClick?: (datum: QueryInsightsChartDatum, tooltipData?: Record) => void + EmptyState?: React.ReactNode + DateTimeFormat?: string +}) => { + const [focusDataIndex, setFocusDataIndex] = useState(null) + + if (data.length === 0) { + if (EmptyState) return EmptyState + return null + } + + const startDate = dayjs(data[0]['timestamp']).format(DateTimeFormat) + const endDate = dayjs(data[data?.length - 1]?.['timestamp']).format(DateTimeFormat) + + return ( +
+ + { + if (e.activeTooltipIndex !== focusDataIndex) { + setFocusDataIndex(e.activeTooltipIndex) + } + }} + onMouseLeave={() => setFocusDataIndex(null)} + onClick={(tooltipData: Record) => { + const datum = tooltipData?.activePayload?.[0]?.payload + if (onPointClick) onPointClick(datum, tooltipData) + }} + > + + + + + + + + dayjs(v).format(DateTimeFormat)} + stroke={CHART_COLORS.AXIS} + fontSize={10} + tickLine={false} + /> + `${v}ms`} + stroke={CHART_COLORS.AXIS} + fontSize={10} + tickLine={false} + /> + dayjs(v).format(DateTimeFormat)} + formatter={(value: number) => [`${value}ms`]} + /> + + + + + + + + + + {data && ( +
+ {startDate} + {endDate} +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/components/QueryInsightsChart.tsx b/apps/studio/components/interfaces/QueryInsights/components/QueryInsightsChart.tsx new file mode 100644 index 00000000000..0519ecba6ea --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/components/QueryInsightsChart.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/components/interfaces/QueryInsights/components/QueryList.tsx b/apps/studio/components/interfaces/QueryInsights/components/QueryList.tsx new file mode 100644 index 00000000000..6baaa2da544 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/components/QueryList.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react' +import { Clock, Database } from 'lucide-react' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { Button } from 'ui' + +dayjs.extend(relativeTime) + +interface Query { + query_id: string + query: string + total_time: number + calls: number + rows: number + shared_blks_read: number + shared_blks_hit: number + mean_exec_time: number + database: string + timestamp: string +} + +interface QueryListProps { + queries?: Query[] + isLoading?: boolean +} + +export const QueryList = ({ queries = [], isLoading = false }: QueryListProps) => { + const [expandedQueries, setExpandedQueries] = useState>(new Set()) + + const toggleQueryExpansion = (queryId: string) => { + const newExpanded = new Set(expandedQueries) + if (newExpanded.has(queryId)) { + newExpanded.delete(queryId) + } else { + newExpanded.add(queryId) + } + setExpandedQueries(newExpanded) + } + + if (isLoading) { + return ( +
+
Loading queries...
+
+ ) + } + + if (queries.length === 0) { + return ( +
+ No queries found for the selected time range +
+ ) + } + + return ( +
+ {queries.map((query) => ( +
+
+
+
+
+ {expandedQueries.has(query.query_id) ? ( + query.query + ) : ( + <> + {query.query.slice(0, 100)} + {query.query.length > 100 && '...'} + + )} +
+
+
+ + {query.database} +
+
+ + {dayjs(query.timestamp).fromNow()} +
+
+
+ {query.query.length > 100 && ( + + )} +
+
+
+
Total time
+
{query.total_time.toFixed(2)}ms
+
+
+
Calls
+
{query.calls}
+
+
+
Rows
+
+ {query.rows >= 1000 ? `${(query.rows / 1000).toFixed(1)}k` : query.rows} +
+
+
+
Cache hit ratio
+
+ {( + (query.shared_blks_hit / (query.shared_blks_hit + query.shared_blks_read)) * + 100 + ).toFixed(1)} + % +
+
+
+
+
+ ))} +
+ ) +} diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.ts b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.ts index ee22c2d7ba5..7bc74d19119 100644 --- a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.ts +++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.ts @@ -30,6 +30,12 @@ export const generateAdvisorsMenu = ( url: `/project/${ref}/advisors/query-performance`, items: [], }, + { + name: 'Query Insights', + key: 'query-insights', + url: `/project/${ref}/advisors/query`, + items: [], + }, ], }, ...(IS_PLATFORM && features?.advisorRules diff --git a/apps/studio/data/query-insights/keys.ts b/apps/studio/data/query-insights/keys.ts new file mode 100644 index 00000000000..75cdb8d5dd7 --- /dev/null +++ b/apps/studio/data/query-insights/keys.ts @@ -0,0 +1,8 @@ +import { useParams } from 'common' + +export const queryInsightsKeys = { + metrics: (projectRef: string | undefined, metric: string, startTime: string, endTime: string) => + ['query-insights', 'metrics', projectRef, metric, startTime, endTime] as const, + queries: (projectRef: string | undefined, startTime: string, endTime: string) => + ['query-insights', 'queries', projectRef, startTime, endTime] as const, +} diff --git a/apps/studio/data/query-insights/query-insights-query.ts b/apps/studio/data/query-insights/query-insights-query.ts new file mode 100644 index 00000000000..a64a9f784e8 --- /dev/null +++ b/apps/studio/data/query-insights/query-insights-query.ts @@ -0,0 +1,180 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { executeSql } from '../sql/execute-sql-query' +import { queryInsightsKeys } from './keys' + +export type QueryInsightsMetric = { + timestamp: string + value?: number + database: string + rows?: number + shared_blks_read?: number + shared_blks_hit?: number + mean_exec_time?: number + total_exec_time?: number + p50?: number + p95?: number + p99?: number + p99_9?: number +} + +export type QueryInsightsQuery = { + query_id: string + query: string + total_time: number + calls: number + rows: number + shared_blks_read: number + shared_blks_hit: number + mean_exec_time: number + database: string + timestamp: string +} + +export type QueryInsightsMetrics = { + queries: { + total: number + total_time: number + avg_time: number + total_rows: number + } + cache: { + hit_ratio: number + hits: number + misses: number + } + errors: { + total: number + } +} + +const getMetricsSql = (metric: string, startTime: string, endTime: string) => { + switch (metric) { + case 'rows_read': + return /* SQL */ ` + SELECT + bucket_start_time as timestamp, + SUM(rows_retrieved) as value, + datname as database + FROM pg_stat_monitor + WHERE bucket_start_time >= '${startTime}' + AND bucket_start_time <= '${endTime}' + GROUP BY bucket_start_time, datname + ORDER BY bucket_start_time ASC + ` + case 'rows_written': + return /* SQL */ ` + SELECT + bucket_start_time as timestamp, + SUM(rows_retrieved) as value, + datname as database + FROM pg_stat_monitor + WHERE bucket_start_time >= '${startTime}' + AND bucket_start_time <= '${endTime}' + GROUP BY bucket_start_time, datname + ORDER BY bucket_start_time ASC + ` + case 'query_latency': + return /* SQL */ ` + SELECT + bucket_start_time as timestamp, + percentile_cont(0.50) WITHIN GROUP (ORDER BY mean_exec_time) as p50, + percentile_cont(0.95) WITHIN GROUP (ORDER BY mean_exec_time) as p95, + percentile_cont(0.99) WITHIN GROUP (ORDER BY mean_exec_time) as p99, + percentile_cont(0.999) WITHIN GROUP (ORDER BY mean_exec_time) as p99_9, + datname as database + FROM pg_stat_monitor + WHERE bucket_start_time >= '${startTime}' + AND bucket_start_time <= '${endTime}' + GROUP BY bucket_start_time, datname + ORDER BY bucket_start_time ASC + ` + case 'queries_per_second': + return /* SQL */ ` + SELECT + bucket_start_time as timestamp, + COUNT(*) as value, + datname as database + FROM pg_stat_monitor + WHERE bucket_start_time >= '${startTime}' + AND bucket_start_time <= '${endTime}' + GROUP BY bucket_start_time, datname + ORDER BY bucket_start_time ASC + ` + default: + return /* SQL */ ` + SELECT + bucket_start_time as timestamp, + SUM(rows_retrieved) as value, + datname as database + FROM pg_stat_monitor + WHERE bucket_start_time >= '${startTime}' + AND bucket_start_time <= '${endTime}' + GROUP BY bucket_start_time, datname + ORDER BY bucket_start_time ASC + ` + } +} + +const getQueriesSql = (startTime: string, endTime: string) => /* SQL */ ` + SELECT + queryid as query_id, + query, + total_exec_time as total_time, + calls, + rows_retrieved as rows, + shared_blks_read, + shared_blks_hit, + mean_exec_time, + datname as database, + bucket_start_time as timestamp + FROM pg_stat_monitor + WHERE bucket_start_time >= '${startTime}' + AND bucket_start_time <= '${endTime}' + ORDER BY calls DESC + LIMIT 100 +` + +export function useQueryInsightsMetrics( + projectRef: string | undefined, + metric: string, + startTime: string, + endTime: string, + options?: UseQueryOptions +) { + return useQuery({ + queryKey: queryInsightsKeys.metrics(projectRef, metric, startTime, endTime), + queryFn: async () => { + if (!projectRef) throw new Error('Project ref is required') + + const { result } = await executeSql({ + projectRef, + sql: getMetricsSql(metric, startTime, endTime), + }) + + return result as QueryInsightsMetric[] + }, + ...options, + }) +} + +export function useQueryInsightsQueries( + projectRef: string | undefined, + startTime: string, + endTime: string, + options?: UseQueryOptions +) { + return useQuery({ + queryKey: queryInsightsKeys.queries(projectRef, startTime, endTime), + queryFn: async () => { + if (!projectRef) throw new Error('Project ref is required') + + const { result } = await executeSql({ + projectRef, + sql: getQueriesSql(startTime, endTime), + }) + + return result as QueryInsightsQuery[] + }, + ...options, + }) +} diff --git a/apps/studio/pages/project/[ref]/advisors/query.tsx b/apps/studio/pages/project/[ref]/advisors/query.tsx new file mode 100644 index 00000000000..3e63d848dc8 --- /dev/null +++ b/apps/studio/pages/project/[ref]/advisors/query.tsx @@ -0,0 +1,29 @@ +import { QueryInsights } from 'components/interfaces/QueryInsights/QueryInsights' +import AdvisorsLayout from 'components/layouts/AdvisorsLayout/AdvisorsLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import DatabaseSelector from 'components/ui/DatabaseSelector' +import { FormHeader } from 'components/ui/Forms/FormHeader' +import type { NextPageWithLayout } from 'types' + +const QueryInsightsPage: NextPageWithLayout = () => { + return ( +
+ } + /> + +
+ ) +} + +QueryInsightsPage.getLayout = (page) => ( + + {page} + +) + +export default QueryInsightsPage