From 7ed8ab83a8359e044c2d4af0765124624129d8d4 Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:09:26 +0000 Subject: [PATCH] feat(studio): query insights improvements (#43109) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? This introduces Query Insights. It's the first edition of possible future updates. This takes our old prototype and builds upon it for a more action driven insights view. --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Ali Waseem --- .../aws-marketplace/getting-started.mdx | 3 - .../QueryInsights/QueryInsights.constants.ts | 66 ++ .../QueryInsights/QueryInsights.tsx | 96 +++ .../QueryInsights/QueryInsights.types.ts | 38 ++ .../QueryInsightsChart.constants.ts | 22 + .../QueryInsightsChart/QueryInsightsChart.tsx | 259 ++++++++ .../QueryInsightsChart.utils.test.ts | 32 + .../QueryInsightsChart.utils.ts | 12 + .../QueryInsightsChartTooltip.tsx | 35 + .../QueryInsightsHealth.constants.ts | 24 + .../QueryInsightsHealth.tsx | 61 ++ .../QueryInsightsHealth.types.ts | 11 + .../QueryInsightsHealth.utils.test.ts | 19 + .../QueryInsightsHealth.utils.ts | 8 + .../QueryInsightsHealthMetric.tsx | 31 + .../QueryInsightsHealthScore.tsx | 33 + .../QueryInsightsHealthScoreSkeleton.tsx | 9 + .../QueryInsightsDetailSheet.tsx | 162 +++++ .../QueryInsightsTable.constants.ts | 44 ++ .../QueryInsightsTable/QueryInsightsTable.tsx | 587 +++++++++++++++++ .../QueryInsightsTable.types.ts | 2 + .../QueryInsightsTable.utils.test.ts | 139 ++++ .../QueryInsightsTable.utils.ts | 170 +++++ .../QueryInsightsTableRow.tsx | 182 ++++++ .../hooks/useQueryInsightsIssues.ts | 21 + .../useQueryInsightsIssues.utils.test.ts | 100 +++ .../hooks/useQueryInsightsIssues.utils.ts | 37 ++ .../hooks/useQueryInsightsMetrics.ts | 23 + .../hooks/useQueryInsightsScore.ts | 49 ++ .../hooks/useQueryInsightsTableColumns.tsx | 599 ++++++++++++++++++ .../hooks/useSupamonitorIndexAdvisor.ts | 62 ++ .../utils/supamonitor.utils.test.ts} | 4 +- .../utils/supamonitor.utils.ts} | 53 +- .../QueryPerformance/QueryPerformance.ai.ts | 91 +++ .../QueryPerformance.constants.ts | 38 -- .../QueryPerformance/QueryPerformance.tsx | 15 - .../QueryPerformance.types.ts | 41 +- .../QueryPerformanceChart.tsx | 2 +- .../WithSupamonitor/WithSupamonitor.tsx | 142 ----- .../hooks/useSupamonitorStatus.ts | 3 - .../ObservabilityLayout/ObservabilityMenu.tsx | 22 +- .../studio/components/ui/DatabaseSelector.tsx | 6 +- apps/studio/components/ui/FilterPopover.tsx | 22 +- apps/studio/data/database/keys.ts | 18 +- .../retrieve-index-advisor-result-query.ts | 20 +- .../[ref]/observability/query-insights.tsx | 72 +++ .../[ref]/observability/query-performance.tsx | 32 +- apps/studio/styles/main.scss | 2 + 48 files changed, 3218 insertions(+), 301 deletions(-) create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsights.constants.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsights.types.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.constants.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.utils.test.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.utils.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChartTooltip.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.constants.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.types.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.utils.test.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.utils.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthMetric.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScore.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScoreSkeleton.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsDetailSheet.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.constants.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.types.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.utils.test.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.utils.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTableRow.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.test.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsMetrics.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsScore.ts create mode 100644 apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx create mode 100644 apps/studio/components/interfaces/QueryInsights/hooks/useSupamonitorIndexAdvisor.ts rename apps/studio/components/interfaces/{QueryPerformance/WithSupamonitor/WithSupamonitor.utils.test.ts => QueryInsights/utils/supamonitor.utils.test.ts} (98%) rename apps/studio/components/interfaces/{QueryPerformance/WithSupamonitor/WithSupamonitor.utils.ts => QueryInsights/utils/supamonitor.utils.ts} (71%) delete mode 100644 apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.tsx create mode 100644 apps/studio/pages/project/[ref]/observability/query-insights.tsx diff --git a/apps/docs/content/guides/platform/aws-marketplace/getting-started.mdx b/apps/docs/content/guides/platform/aws-marketplace/getting-started.mdx index 30b23d5403..858aa8081b 100644 --- a/apps/docs/content/guides/platform/aws-marketplace/getting-started.mdx +++ b/apps/docs/content/guides/platform/aws-marketplace/getting-started.mdx @@ -67,7 +67,6 @@ src="/docs/img/guides/platform/aws-marketplace-listing-subscribe.png" width={2270} height={632} /> - @@ -80,7 +79,6 @@ src="/docs/img/guides/platform/aws-marketplace-listing-success.png" width={1944} height={1254} /> - @@ -96,7 +94,6 @@ src={{ width={3048} height={1058} /> - diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsights.constants.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsights.constants.ts new file mode 100644 index 0000000000..79802d6375 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsights.constants.ts @@ -0,0 +1,66 @@ +export const SUPAMONITOR_EXCLUDED_ROLES = [ + 'supabase_admin', + 'supabase_auth_admin', + 'supabase_storage_admin', + 'supabase_realtime_admin', + 'pgbouncer', + 'dashboard_user', +] as const + +export const SUPAMONITOR_EXCLUDED_APP_NAMES = ['supabase-dashboard', 'mgmt-api'] as const + +export const TRANSACTION_CONTROL_REGEX = + /^\s*(BEGIN|COMMIT|ROLLBACK|SET\s|RESET\s|DISCARD|DEALLOCATE|SHOW\s)/i + +export const SCHEMA_INTROSPECTION_REGEX = + /\bFROM\s+(?:pg_catalog\.|information_schema\.|pg_class\b|pg_attribute\b|pg_type\b|pg_namespace\b)/i + +export const getSupamonitorLogsQuery = (startTime: string, endTime: string) => { + // Validate and canonicalize to ISO 8601 UTC before embedding in SQL. + // new Date().toISOString() throws RangeError on invalid input and always + // produces "YYYY-MM-DDTHH:mm:ss.mmmZ" which contains no SQL special characters. + const safeStart = new Date(startTime).toISOString() + const safeEnd = new Date(endTime).toISOString() + + return ` +-- This query is run by Supabase Query Insights to aggregate pg_stat_statements +-- data collected by the supamonitor extension. It reads from Logflare and groups +-- execution metrics (timing, call counts, percentiles) by query and minute so +-- the dashboard can surface slow queries, high-call patterns, and planning overhead. +-- If you see this query in your logs, it is a read-only analytics query and safe to ignore. +select + TIMESTAMP_TRUNC(sml.timestamp, MINUTE) as timestamp, + CAST(sml_parsed.application_name AS STRING) as application_name, + SUM(sml_parsed.calls) as calls, + CAST(sml_parsed.database_name AS STRING) as database_name, + CAST(sml_parsed.query AS STRING) as query, + sml_parsed.query_id as query_id, + SUM(sml_parsed.total_exec_time) as total_exec_time, + SUM(sml_parsed.total_plan_time) as total_plan_time, + CAST(sml_parsed.user_name AS STRING) as user_name, + CASE WHEN SUM(sml_parsed.calls) > 0 + THEN SUM(sml_parsed.total_exec_time) / SUM(sml_parsed.calls) + ELSE 0 + END as mean_exec_time, + MIN(NULLIF(sml_parsed.total_exec_time, 0)) as min_exec_time, + MAX(sml_parsed.total_exec_time) as max_exec_time, + CASE WHEN SUM(sml_parsed.calls) > 0 + THEN SUM(sml_parsed.total_plan_time) / SUM(sml_parsed.calls) + ELSE 0 + END as mean_plan_time, + MIN(NULLIF(sml_parsed.total_plan_time, 0)) as min_plan_time, + MAX(sml_parsed.total_plan_time) as max_plan_time, + APPROX_QUANTILES(sml_parsed.total_exec_time, 100)[OFFSET(50)] as p50_exec_time, + APPROX_QUANTILES(sml_parsed.total_exec_time, 100)[OFFSET(95)] as p95_exec_time, + APPROX_QUANTILES(sml_parsed.total_plan_time, 100)[OFFSET(50)] as p50_plan_time, + APPROX_QUANTILES(sml_parsed.total_plan_time, 100)[OFFSET(95)] as p95_plan_time +from supamonitor_logs as sml +cross join unnest(sml.metadata) as sml_metadata +cross join unnest(sml_metadata.supamonitor) as sml_parsed +WHERE sml.event_message = 'log' + AND sml.timestamp >= CAST('${safeStart}' AS TIMESTAMP) + AND sml.timestamp <= CAST('${safeEnd}' AS TIMESTAMP) +GROUP BY timestamp, user_name, database_name, application_name, query_id, query +ORDER BY timestamp DESC +`.trim() +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx new file mode 100644 index 0000000000..e1506a7666 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsights.tsx @@ -0,0 +1,96 @@ +import { useMemo, useState } from 'react' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' + +import { useParams } from 'common' +import useLogsQuery from 'hooks/analytics/useLogsQuery' +import { getSupamonitorLogsQuery } from './QueryInsights.constants' +import { + parseSupamonitorLogs, + filterSystemLogs, + transformLogsToChartData, + aggregateLogsByQuery, +} from './utils/supamonitor.utils' + +import { QueryInsightsHealth } from './QueryInsightsHealth/QueryInsightsHealth' +import { QueryInsightsChart } from './QueryInsightsChart/QueryInsightsChart' +import { QueryInsightsTable } from './QueryInsightsTable/QueryInsightsTable' +import { useSupamonitorIndexAdvisor } from './hooks/useSupamonitorIndexAdvisor' + +dayjs.extend(utc) + +interface QueryInsightsProps { + dateRange?: { + period_start: { date: string; time_period: string } + period_end: { date: string; time_period: string } + interval: string + } +} + +export const QueryInsights = ({ dateRange }: QueryInsightsProps) => { + const { ref } = useParams() + + const effectiveDateRange = useMemo(() => { + if (dateRange) { + return { + iso_timestamp_start: dateRange.period_start.date, + iso_timestamp_end: dateRange.period_end.date, + } + } + const end = dayjs.utc() + const start = end.subtract(1, 'hour') + return { + iso_timestamp_start: start.toISOString(), + iso_timestamp_end: end.toISOString(), + } + }, [dateRange]) + + const sql = useMemo( + () => + getSupamonitorLogsQuery( + effectiveDateRange.iso_timestamp_start, + effectiveDateRange.iso_timestamp_end + ), + [effectiveDateRange] + ) + + const { logData, isLoading } = useLogsQuery(ref as string, { + sql, + iso_timestamp_start: effectiveDateRange.iso_timestamp_start, + iso_timestamp_end: effectiveDateRange.iso_timestamp_end, + }) + + const [selectedQuery, setSelectedQuery] = useState(null) + + const parsedLogs = useMemo(() => parseSupamonitorLogs(logData || []), [logData]) + const filteredLogs = useMemo(() => filterSystemLogs(parsedLogs), [parsedLogs]) + const chartData = useMemo(() => transformLogsToChartData(filteredLogs), [filteredLogs]) + const selectedChartData = useMemo( + () => + selectedQuery + ? transformLogsToChartData( + filteredLogs.filter((log) => log.query?.replace(/\s+/g, ' ').trim() === selectedQuery) + ) + : undefined, + [filteredLogs, selectedQuery] + ) + const aggregatedData = useMemo(() => aggregateLogsByQuery(filteredLogs), [filteredLogs]) + const enrichedData = useSupamonitorIndexAdvisor(aggregatedData) + + return ( +
+ + + +
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsights.types.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsights.types.ts new file mode 100644 index 0000000000..43dfa0c16a --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsights.types.ts @@ -0,0 +1,38 @@ +export interface ChartDataPoint { + period_start: number + timestamp: string + query_latency: number + mean_time: number + min_time: number + max_time: number + stddev_time: number + p50_time: number + p95_time: number + rows_read: number + calls: number + cache_hits: number + cache_misses: number +} + +export interface ParsedLogEntry { + timestamp?: string + application_name?: string + calls?: number + database_name?: string + query?: string + query_id?: number + total_exec_time?: number + total_plan_time?: number + user_name?: string + mean_exec_time?: number + mean_plan_time?: number + min_exec_time?: number + max_exec_time?: number + min_plan_time?: number + max_plan_time?: number + p50_exec_time?: number + p95_exec_time?: number + p50_plan_time?: number + p95_plan_time?: number + [key: string]: any +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.constants.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.constants.ts new file mode 100644 index 0000000000..c00550bc82 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.constants.ts @@ -0,0 +1,22 @@ +import { CHART_COLORS } from 'components/ui/Charts/Charts.constants' + +export const CHART_TABS = [ + { id: 'query_latency', label: 'Query latency' }, + { id: 'rows_read', label: 'Rows read' }, + { id: 'calls', label: 'Calls' }, + { id: 'cache_hits', label: 'Cache hits' }, +] + +export const LEGEND_ITEMS: Record = { + query_latency: [ + { label: 'P50', color: 'hsl(var(--chart-4))', dataKey: 'p50' }, + { label: 'P95', color: CHART_COLORS.GREEN_1, dataKey: 'p95' }, + ], + rows_read: [{ label: 'Rows Read', color: CHART_COLORS.GREEN_1, dataKey: 'rows_read' }], + calls: [{ label: 'Calls', color: CHART_COLORS.GREEN_1, dataKey: 'calls' }], + cache_hits: [{ label: 'Cache Hits', color: '#10B981', dataKey: 'cache_hits' }], +} + +export const CHART_TYPE = 'linear' + +export const SEL_COLOR = 'hsl(var(--chart-blue))' diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx new file mode 100644 index 0000000000..197e5f78d0 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx @@ -0,0 +1,259 @@ +import { useMemo, useState } from 'react' +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, cn } from 'ui' +import { Loader2 } from 'lucide-react' +import type { ChartDataPoint } from '../QueryInsights.types' +import { useTheme } from 'next-themes' +import { QueryInsightsChartTooltip } from './QueryInsightsChartTooltip' +import { CHART_TABS, LEGEND_ITEMS, CHART_TYPE, SEL_COLOR } from './QueryInsightsChart.constants' +import { formatTime } from './QueryInsightsChart.utils' + +interface QueryInsightsChartProps { + chartData: ChartDataPoint[] + selectedChartData?: ChartDataPoint[] + isLoading: boolean +} + +export const QueryInsightsChart = ({ + chartData, + selectedChartData, + isLoading, +}: QueryInsightsChartProps) => { + const [selectedMetric, setSelectedMetric] = useState('query_latency') + const [hiddenSeries, setHiddenSeries] = useState>(new Set()) + const { resolvedTheme } = useTheme() + const isDarkMode = resolvedTheme?.includes('dark') + + const data = useMemo(() => { + const normalize = (ts: number) => (ts > 1e13 ? Math.floor(ts / 1000) : ts) + const selByTime = new Map((selectedChartData ?? []).map((d) => [normalize(d.period_start), d])) + + return chartData.map((d) => { + const t = normalize(d.period_start) + const sel = selByTime.get(t) + return { + time: t, + p50: d.p50_time, + p95: d.p95_time, + rows_read: d.rows_read, + calls: d.calls, + cache_hits: d.cache_hits, + sel_p50: sel?.p50_time, + sel_rows_read: sel?.rows_read, + sel_calls: sel?.calls, + sel_cache_hits: sel?.cache_hits, + } + }) + }, [chartData, selectedChartData]) + + const filteredData = useMemo(() => { + if (hiddenSeries.size === 0) return data + return data.map((point) => { + const filtered = { ...point } as Record + hiddenSeries.forEach((key) => { + filtered[key] = undefined + }) + return filtered + }) + }, [data, hiddenSeries]) + + const toggleSeries = (dataKey: string) => { + setHiddenSeries((prev) => { + const next = new Set(prev) + if (next.has(dataKey)) { + next.delete(dataKey) + } else { + next.add(dataKey) + } + return next + }) + } + + const hasSelection = !!selectedChartData && selectedChartData.length > 0 + const selDataKey = selectedMetric === 'query_latency' ? 'sel_p50' : `sel_${selectedMetric}` + const legendItems = LEGEND_ITEMS[selectedMetric] ?? [] + + return ( +
+ + + {CHART_TABS.map((tab) => ( + + {tab.label} + + ))} + + + +
+ {legendItems.map((item: { dataKey: string; label: string; color: string }) => ( + + ))} + {hasSelection && ( + + )} +
+
+
+ {isLoading ? ( +
+ +
+ ) : data.length === 0 ? ( +
+

No data available

+
+ ) : ( + + + + {legendItems.map((item) => ( + + + + + ))} + {hasSelection && ( + + + + + )} + + + + selectedMetric === 'query_latency' + ? `${Math.round(v)}ms` + : `${Math.round(v)}` + } + mirror={true} + /> + } + cursor={{ + stroke: isDarkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)', + strokeWidth: 1, + }} + /> + + {legendItems.map((item) => ( + + ))} + {hasSelection && ( + + )} + + + )} +
+ + {data.length > 0 && ( +
+ {formatTime(data[0].time)} + {formatTime(data[data.length - 1].time)} +
+ )} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.utils.test.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.utils.test.ts new file mode 100644 index 0000000000..7b88857b46 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.utils.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { isTimeMetric, formatTime } from './QueryInsightsChart.utils' + +describe('isTimeMetric', () => { + it('returns true for p50 and p95', () => { + expect(isTimeMetric('p50')).toBe(true) + expect(isTimeMetric('p95')).toBe(true) + }) + + it('returns false for other keys', () => { + expect(isTimeMetric('calls')).toBe(false) + expect(isTimeMetric('count')).toBe(false) + expect(isTimeMetric('')).toBe(false) + expect(isTimeMetric('P50')).toBe(false) + }) +}) + +describe('formatTime', () => { + it('formats a timestamp into a human-readable date string', () => { + const ts = new Date('2024-01-15T14:30:00Z').getTime() + const result = formatTime(ts) + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('includes the month and day', () => { + const ts = new Date('2024-06-01T10:00:00Z').getTime() + const result = formatTime(ts) + expect(result).toMatch(/Jun/) + expect(result).toMatch(/1/) + }) +}) diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.utils.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.utils.ts new file mode 100644 index 0000000000..7bb52a9eac --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.utils.ts @@ -0,0 +1,12 @@ +export const isTimeMetric = (dataKey: string) => dataKey.endsWith('p50') || dataKey.endsWith('p95') + +export const formatTime = (value: number) => { + const d = new Date(value) + return d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChartTooltip.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChartTooltip.tsx new file mode 100644 index 0000000000..52eeec2e9b --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChartTooltip.tsx @@ -0,0 +1,35 @@ +import dayjs from 'dayjs' +import type { TooltipProps } from 'recharts' +import { formatDuration } from '../QueryInsightsTable/QueryInsightsTable.utils' +import { isTimeMetric } from './QueryInsightsChart.utils' + +export const QueryInsightsChartTooltip = ({ active, payload }: TooltipProps) => { + if (!active || !payload?.length) return null + + const time = payload[0]?.payload?.time + const localTimeZone = dayjs.tz.guess() + + return ( +
+

{localTimeZone}

+

{dayjs(time).format('MMM D, hh:mm:ssa')}

+
+ {payload.map((entry, index) => ( +
+ + + + {entry.name} + + {typeof entry.value === 'number' + ? isTimeMetric(typeof entry.dataKey === 'string' ? entry.dataKey : '') + ? formatDuration(entry.value) + : entry.value.toLocaleString() + : entry.value} + +
+ ))} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.constants.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.constants.ts new file mode 100644 index 0000000000..923420cc27 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.constants.ts @@ -0,0 +1,24 @@ +import type { HealthLevel } from './QueryInsightsHealth.types' + +export const SLOW_QUERY_THRESHOLD_MS = 200 +export const HIGH_CALL_THRESHOLD = 100 + +export const SCORE_DEDUCTIONS = { + error: 20, + indexHighCalls: 15, + indexLowCalls: 8, + slowHighCalls: 10, + slowLowCalls: 5, +} as const + +export const HEALTH_LEVELS: Record = { + healthy: { label: 'Healthy', min: 70 }, + warning: { label: 'Needs attention', min: 40 }, + critical: { label: 'Critical', min: 0 }, +} + +export const HEALTH_COLORS: Record = { + healthy: 'hsl(var(--brand-default))', + warning: 'hsl(var(--warning-default))', + critical: 'hsl(var(--destructive-default))', +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.tsx new file mode 100644 index 0000000000..2c7a78bd8e --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.tsx @@ -0,0 +1,61 @@ +import { useQueryInsightsIssues } from '../hooks/useQueryInsightsIssues' +import { useQueryInsightsScore } from '../hooks/useQueryInsightsScore' +import { useQueryInsightsMetrics } from '../hooks/useQueryInsightsMetrics' +import { HEALTH_COLORS, HEALTH_LEVELS } from './QueryInsightsHealth.constants' +import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types' +import { QueryInsightsHealthMetric } from './QueryInsightsHealthMetric' +import { QueryInsightsHealthScore } from './QueryInsightsHealthScore' +import { QueryInsightsHealthScoreSkeleton } from './QueryInsightsHealthScoreSkeleton' + +interface QueryInsightsHealthProps { + data: QueryPerformanceRow[] + isLoading: boolean +} + +export const QueryInsightsHealth = ({ data, isLoading }: QueryInsightsHealthProps) => { + const { errors, indexIssues, slowQueries } = useQueryInsightsIssues(data) + const { score, level } = useQueryInsightsScore({ errors, indexIssues, slowQueries }) + const { avgP95, totalCalls, totalRowsRead, cacheHitRate } = useQueryInsightsMetrics(data) + + const color = HEALTH_COLORS[level] + const label = HEALTH_LEVELS[level].label + + return ( +
+
+ {isLoading ? ( + + ) : ( + + )} +
+
+
+ + + + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.types.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.types.ts new file mode 100644 index 0000000000..2a469533dc --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.types.ts @@ -0,0 +1,11 @@ +import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types' + +export type IssueType = 'error' | 'index' | 'slow' | null + +export type HealthLevel = 'healthy' | 'warning' | 'critical' + +export interface ClassifiedQuery extends QueryPerformanceRow { + issueType: IssueType + hint: string + queryType: string | null +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.utils.test.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.utils.test.ts new file mode 100644 index 0000000000..bb10db89b1 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.utils.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { getHealthLevel } from './QueryInsightsHealth.utils' + +describe('getHealthLevel', () => { + it('returns healthy for scores >= 70', () => { + expect(getHealthLevel(100)).toBe('healthy') + expect(getHealthLevel(70)).toBe('healthy') + }) + + it('returns warning for scores between 40 and 69', () => { + expect(getHealthLevel(69)).toBe('warning') + expect(getHealthLevel(40)).toBe('warning') + }) + + it('returns critical for scores below 40', () => { + expect(getHealthLevel(39)).toBe('critical') + expect(getHealthLevel(0)).toBe('critical') + }) +}) diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.utils.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.utils.ts new file mode 100644 index 0000000000..ed78d729b2 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.utils.ts @@ -0,0 +1,8 @@ +import { HEALTH_LEVELS } from './QueryInsightsHealth.constants' +import type { HealthLevel } from './QueryInsightsHealth.types' + +export const getHealthLevel = (score: number): HealthLevel => { + if (score >= HEALTH_LEVELS.healthy.min) return 'healthy' + if (score >= HEALTH_LEVELS.warning.min) return 'warning' + return 'critical' +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthMetric.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthMetric.tsx new file mode 100644 index 0000000000..7dcda59b57 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthMetric.tsx @@ -0,0 +1,31 @@ +import { cn } from 'ui' + +interface QueryInsightsHealthMetricProps { + label: string + value: number | string | undefined + className?: string + isLoading?: boolean +} + +export const QueryInsightsHealthMetric = ({ + label, + value, + className, + isLoading, +}: QueryInsightsHealthMetricProps) => { + return ( +
+ {label} + {isLoading ? ( +
+ ) : ( + {value} + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScore.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScore.tsx new file mode 100644 index 0000000000..6c69d1eb00 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScore.tsx @@ -0,0 +1,33 @@ +export const QueryInsightsHealthScore = ({ + score, + color, + label, +}: { + score: number + color: string + label: string +}) => ( + <> +
+
+ {score} +
+
+
+ + Health Score + + + {label} + +
+ +) diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScoreSkeleton.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScoreSkeleton.tsx new file mode 100644 index 0000000000..8e9d0db057 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealthScoreSkeleton.tsx @@ -0,0 +1,9 @@ +export const QueryInsightsHealthScoreSkeleton = () => ( + <> +
+
+
+
+
+ +) diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsDetailSheet.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsDetailSheet.tsx new file mode 100644 index 0000000000..29905dd805 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsDetailSheet.tsx @@ -0,0 +1,162 @@ +import { useRef } from 'react' +import { Loader2 } from 'lucide-react' +import { + AiIconAnimation, + Button, + Sheet, + SheetContent, + SheetDescription, + SheetTitle, + Tabs_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, + TabsContent_Shadcn_, +} from 'ui' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { ExplainVisualizer } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer' +import { QueryDetail } from '../../QueryPerformance/QueryDetail' +import { QueryIndexes } from '../../QueryPerformance/QueryIndexes' +import { buildExplainOptimizationPrompt } from '../../QueryPerformance/QueryPerformance.ai' +import type { QueryPlanRow } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.types' +import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types' + +interface QueryInsightsDetailSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + activeSheetRow: ClassifiedQuery | undefined + sheetView: 'details' | 'indexes' | 'explain' + onSheetViewChange: (view: 'details' | 'indexes' | 'explain') => void + onClose: () => void + dataGridContainerRef: React.RefObject + triageContainerRef: React.RefObject + explainLoadingQuery: string | null + explainResults: Record +} + +export const QueryInsightsDetailSheet = ({ + open, + onOpenChange, + activeSheetRow, + sheetView, + onSheetViewChange, + onClose, + dataGridContainerRef, + triageContainerRef, + explainLoadingQuery, + explainResults, +}: QueryInsightsDetailSheetProps) => { + const { openSidebar } = useSidebarManagerSnapshot() + const aiSnap = useAiAssistantStateSnapshot() + + return ( + + Query details + Query Insights Details & Indexes + { + if ( + dataGridContainerRef.current?.contains(event.target as Node) || + triageContainerRef.current?.contains(event.target as Node) + ) { + event.preventDefault() + } + }} + > + onSheetViewChange(v as 'details' | 'indexes' | 'explain')} + > +
+ + + Query details + + + Indexes + + {activeSheetRow?.issueType !== 'error' && ( + + Explain + + )} + +
+ + {activeSheetRow && ( + onSheetViewChange('indexes')} + onClose={onClose} + /> + )} + + + {activeSheetRow && } + + + {explainLoadingQuery ? ( +
+ Running EXPLAIN ANALYZE... +
+ ) : activeSheetRow && explainResults[activeSheetRow.query]?.length > 0 ? ( + <> +
+

EXPLAIN ANALYZE output

+ +
+
+ +
+ + ) : ( +
+ No explain results available. +
+ )} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.constants.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.constants.ts new file mode 100644 index 0000000000..56d0fc3444 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.constants.ts @@ -0,0 +1,44 @@ +import { CircleAlert, Lightbulb } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +export const ISSUE_DOT_COLORS: Record< + string, + { border: string; background: string; color: string } +> = { + error: { + border: 'border-destructive-500', + background: 'bg-destructive-200', + color: 'text-destructive-600', + }, + index: { + border: 'border-warning-500', + background: 'bg-warning-200', + color: 'text-warning-600', + }, + slow: { + border: 'border-strong', + background: 'bg-alternative dark:bg-muted', + color: 'text-foreground-lighter', + }, +} + +export const ISSUE_ICONS: Record = { + error: CircleAlert, + index: Lightbulb, + slow: CircleAlert, +} + +export const QUERY_INSIGHTS_EXPLORER_COLUMNS = [ + { id: 'query', name: 'Query', description: undefined, minWidth: 500 }, + { id: 'prop_total_time', name: 'Time consumed', description: undefined, minWidth: 150 }, + { id: 'calls', name: 'Calls', 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: 'rows_read', name: 'Rows processed', description: undefined, minWidth: 130 }, + { id: 'cache_hit_rate', name: 'Cache hit rate', description: undefined, minWidth: 130 }, + { id: 'rolname', name: 'Role', description: undefined, minWidth: 200 }, + { id: 'application_name', name: 'Source', description: undefined, minWidth: 200 }, +] as const + +export const NON_SORTABLE_COLUMNS = [] as const diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.tsx new file mode 100644 index 0000000000..d6b500ce8c --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.tsx @@ -0,0 +1,587 @@ +import { useParams } from 'common' +import type { QueryPlanRow } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.types' +import { FilterPill } from 'components/interfaces/QueryPerformance/components/FilterPill' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { FilterPopover } from 'components/ui/FilterPopover' +import TwoOptionToggle from 'components/ui/TwoOptionToggle' +import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' +import { wrapWithRollback } from 'data/sql/utils/transaction' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { Search, TextSearch, X } from 'lucide-react' +import { useRouter } from 'next/router' +import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +// eslint-disable-next-line no-restricted-imports +import DataGrid, { DataGridHandle, Row } from 'react-data-grid' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { Button, cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { buildQueryInsightFixPrompt } from '../../QueryPerformance/QueryPerformance.ai' +import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types' +import { useQueryInsightsIssues } from '../hooks/useQueryInsightsIssues' +import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types' +import { QueryInsightsDetailSheet } from './QueryInsightsDetailSheet' +import type { IssueFilter, Mode } from './QueryInsightsTable.types' +import { + formatDuration, + getColumnName, + getQueryType, + getTableName, +} from './QueryInsightsTable.utils' +import { useQueryInsightsTableColumns } from '../hooks/useQueryInsightsTableColumns' + +interface QueryInsightsTableProps { + data: QueryPerformanceRow[] + isLoading: boolean + currentSelectedQuery?: string | null + onCurrentSelectQuery?: (query: string | null) => void +} + +export const QueryInsightsTable = ({ + data, + isLoading, + currentSelectedQuery, + onCurrentSelectQuery, +}: QueryInsightsTableProps) => { + const [mode, setMode] = useState('triage') + const [filter, setFilter] = useState('all') + const [ + { search: urlSearch, sort: urlSortCol, order: urlSortOrder, source: urlSource }, + setQueryStates, + ] = useQueryStates({ + search: parseAsString.withDefault(''), + sort: parseAsString, + order: parseAsString, + source: parseAsArrayOf(parseAsString).withDefault([]), + }) + const [searchQuery, setSearchQuery] = useState(urlSearch || '') + const appNameFilter = urlSource + const setAppNameFilter = (names: string[]) => + setQueryStates({ source: names.length ? names : null }) + + const appNameOptions = useMemo(() => { + const names = Array.from( + new Set(data.map((r) => r.application_name).filter(Boolean)) + ) as string[] + return names.map((name) => ({ value: name, label: name })) + }, [data]) + + const filteredData = useMemo(() => { + if (appNameFilter.length === 0) return data + return data.filter((r) => appNameFilter.includes(r.application_name ?? '')) + }, [data, appNameFilter]) + + const { classified, errors, indexIssues, slowQueries } = useQueryInsightsIssues(filteredData) + const [selectedRow, setSelectedRow] = useState() + const [selectedTriageRow, setSelectedTriageRow] = useState() + const [sheetView, setSheetView] = useState<'details' | 'indexes' | 'explain'>('details') + const gridRef = useRef(null) + const triageGridRef = useRef(null) + const dataGridContainerRef = useRef(null) + const triageContainerRef = useRef(null) + const scrollContainerRef = useRef(null) + const [triageContainerWidth, setTriageContainerWidth] = useState(0) + const sort = useMemo<{ column: string; order: 'asc' | 'desc' }>( + () => + urlSortCol && urlSortOrder && ['asc', 'desc'].includes(urlSortOrder) + ? { column: urlSortCol, order: urlSortOrder as 'asc' | 'desc' } + : { column: 'prop_total_time', order: 'desc' }, + [urlSortCol, urlSortOrder] + ) + const setSort = useCallback( + (config: { column: string; order: 'asc' | 'desc' } | null) => + setQueryStates( + config ? { sort: config.column, order: config.order } : { sort: null, order: null } + ), + [setQueryStates] + ) + + const [explainResults, setExplainResults] = useState>({}) + const [explainLoadingQuery, setExplainLoadingQuery] = useState(null) + + const { ref } = useParams() + const router = useRouter() + const { openSidebar } = useSidebarManagerSnapshot() + const aiSnap = useAiAssistantStateSnapshot() + + const { data: project } = useSelectedProjectQuery() + + const { mutate: executeExplain } = useExecuteSqlMutation() + + const triageItems = useMemo(() => classified.filter((q) => q.issueType !== null), [classified]) + + const filteredTriageItems = useMemo(() => { + const filtered = + filter === 'all' ? triageItems : triageItems.filter((q) => q.issueType === filter) + return filtered.map((item) => ({ + ...item, + queryType: getQueryType(item.query), + })) + }, [triageItems, filter]) + + const explorerItems = useMemo(() => { + let items = [...classified] + + if (searchQuery.trim()) { + const searchLower = searchQuery.toLowerCase() + items = items.filter((item) => { + const queryType = getQueryType(item.query) ?? '' + const tableName = getTableName(item.query) ?? '' + const columnName = getColumnName(item.query) ?? '' + const appName = item.application_name ?? '' + const query = item.query ?? '' + + return ( + queryType.toLowerCase().includes(searchLower) || + tableName.toLowerCase().includes(searchLower) || + columnName.toLowerCase().includes(searchLower) || + appName.toLowerCase().includes(searchLower) || + query.toLowerCase().includes(searchLower) + ) + }) + } + + if (sort) { + items.sort((a, b) => { + if (sort.column === 'query') { + const aDate = a.first_seen ? new Date(a.first_seen).getTime() : 0 + const bDate = b.first_seen ? new Date(b.first_seen).getTime() : 0 + return sort.order === 'asc' ? aDate - bDate : bDate - aDate + } + + const aValue: unknown = a[sort.column as keyof typeof a] + const bValue: unknown = b[sort.column as keyof typeof b] + + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sort.order === 'asc' ? aValue - bValue : bValue - aValue + } + + return 0 + }) + } + + return items + }, [classified, searchQuery, sort]) + + const activeSheetRow: ClassifiedQuery | undefined = useMemo(() => { + if (mode === 'triage') { + return selectedTriageRow !== undefined ? filteredTriageItems[selectedTriageRow] : undefined + } + return selectedRow !== undefined ? (explorerItems[selectedRow] as ClassifiedQuery) : undefined + }, [mode, selectedTriageRow, selectedRow, filteredTriageItems, explorerItems]) + + const runExplain = useCallback( + (query: string) => { + if (explainResults[query]) return + if (explainLoadingQuery) return + const requestQuery = query + setExplainLoadingQuery(requestQuery) + executeExplain( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + sql: wrapWithRollback(`EXPLAIN ANALYZE ${requestQuery}`), + }, + { + onSuccess(data) { + setExplainResults((prev) => ({ ...prev, [requestQuery]: data.result })) + setExplainLoadingQuery(null) + }, + onError() { + setExplainLoadingQuery(null) + }, + } + ) + }, + [explainResults, explainLoadingQuery, executeExplain, project] + ) + + const handleGoToLogs = useCallback(() => { + router.push(`/project/${ref}/logs/postgres-logs`) + }, [router, ref]) + + const handleAiSuggestedFix = useCallback( + (item: ClassifiedQuery) => { + const { query, prompt } = buildQueryInsightFixPrompt(item) + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + aiSnap.newChat({ + sqlSnippets: [{ label: 'Query', content: query }], + initialMessage: prompt, + }) + }, + [openSidebar, aiSnap] + ) + + useEffect(() => { + if (sheetView === 'explain' && activeSheetRow?.query) { + runExplain(activeSheetRow.query) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sheetView, activeSheetRow?.query]) + + useEffect(() => { + const currentPath = router.asPath.split('?')[0] + const handleRouteChange = (url: string) => { + if (url.split('?')[0] !== currentPath) { + setQueryStates({ search: null }) + onCurrentSelectQuery?.(null) + } + } + router.events.on('routeChangeStart', handleRouteChange) + return () => router.events.off('routeChangeStart', handleRouteChange) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const timeConsumedWidth = useMemo(() => { + if (!explorerItems.length) return 150 + let maxWidth = 150 + explorerItems.forEach((row) => { + const pct = row.prop_total_time || 0 + const total = row.total_time || 0 + if (pct && total) { + const text = `${pct.toFixed(1)}% / ${formatDuration(total)}` + maxWidth = Math.max(maxWidth, text.length * 8 + 40) + } + }) + return Math.min(maxWidth, 300) + }, [explorerItems]) + + const triageQueryColWidth = useMemo(() => { + if (!triageContainerWidth) return 380 + const fixed = timeConsumedWidth + 100 + 300 + 4 + return Math.max(380, triageContainerWidth - fixed - 120) + }, [triageContainerWidth, timeConsumedWidth]) + + const { columns, triageColumns } = useQueryInsightsTableColumns({ + sort, + setSort, + timeConsumedWidth, + triageQueryColWidth, + gridRef, + setSelectedRow, + setSelectedTriageRow, + setSheetView, + handleGoToLogs, + handleAiSuggestedFix, + }) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!explorerItems.length || selectedRow === undefined) return + + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return + + event.stopPropagation() + + let nextIndex = selectedRow + if (event.key === 'ArrowUp' && selectedRow > 0) { + nextIndex = selectedRow - 1 + } else if (event.key === 'ArrowDown' && selectedRow < explorerItems.length - 1) { + nextIndex = selectedRow + 1 + } + + if (nextIndex !== selectedRow) { + setSelectedRow(nextIndex) + gridRef.current?.scrollToCell({ idx: 0, rowIdx: nextIndex }) + } + }, + [explorerItems, selectedRow] + ) + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown, true) + return () => { + window.removeEventListener('keydown', handleKeyDown, true) + } + }, [handleKeyDown]) + + useEffect(() => { + setSelectedRow(undefined) + }, [searchQuery, sort]) + + useEffect(() => { + if (mode === 'triage') { + triageGridRef.current?.scrollToCell({ idx: 0, rowIdx: 0 }) + } else { + gridRef.current?.scrollToCell({ idx: 0, rowIdx: 0 }) + } + }, [mode]) + + useEffect(() => { + const el = triageContainerRef.current + if (!el) return + const observer = new ResizeObserver(([entry]) => { + setTriageContainerWidth(entry.contentRect.width) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (urlSearch !== searchQuery) { + setQueryStates({ search: searchQuery || null }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]) + + const errorCount = errors.length + const indexCount = indexIssues.length + const slowCount = slowQueries.length + + return ( +
+
+
+
+ + {appNameFilter.length > 0 ? ( + setAppNameFilter([])} + /> + ) : ( + + )} +
+ +
+ {mode === 'triage' ? ( + setFilter(v as IssueFilter)}> + + + All{triageItems.length > 0 && ` (${triageItems.length})`} + + + Errors{errorCount > 0 && ` (${errorCount})`} + + + Index{indexCount > 0 && ` (${indexCount})`} + + + Slow{slowCount > 0 && ` (${slowCount})`} + + + + ) : ( + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + name="search" + id="search" + placeholder="Search queries..." + className="w-64" + actions={[ + searchQuery && ( +
+
+
+ +
+
+ +
+ {isLoading ? ( +
+ +
+ ) : mode === 'triage' ? ( +
+ { + const isSelected = idx === selectedTriageRow + const query = filteredTriageItems[idx]?.query + const isCharted = currentSelectedQuery ? currentSelectedQuery === query : false + return [ + `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : isCharted ? 'bg-surface-200 dark:bg-surface-200' : 'bg-200 hover:bg-surface-200'} cursor-pointer`, + '[&>div:first-child]:border-l-4 [&>div:first-child]:pl-5 [&>div:last-child]:pr-6', + `${isSelected || isCharted ? '[&>div:first-child]:border-l-foreground' : '[&>div:first-child]:border-l-transparent'}`, + '[&>.rdg-cell]:box-border [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none [&>.rdg-cell]:py-3', + '[&>.rdg-cell.column-prop_total_time]:relative', + ].join(' ') + }} + renderers={{ + renderRow(idx, props) { + return ( + { + event.stopPropagation() + if (typeof idx === 'number' && idx >= 0) { + const query = filteredTriageItems[idx]?.query + if (query && onCurrentSelectQuery) { + onCurrentSelectQuery(currentSelectedQuery === query ? null : query) + } + } + }} + /> + ) + }, + noRowsFallback: ( +
+ +
+

No issues found

+

+ {data.length === 0 + ? 'No query data available yet' + : 'No issues detected for the selected filter'} +

+
+
+ ), + }} + /> +
+ ) : ( +
+ { + const isSelected = idx === selectedRow + const query = explorerItems[idx]?.query + const isCharted = currentSelectedQuery ? currentSelectedQuery === query : false + return [ + `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : isCharted ? 'bg-surface-200 dark:bg-surface-200' : 'bg-200 hover:bg-surface-200'} cursor-pointer`, + '[&>div:first-child]:border-l-4 [&>div:first-child]:pl-5 [&>div:last-child]:pr-6', + `${isSelected || isCharted ? '[&>div:first-child]:border-l-foreground' : '[&>div:first-child]:border-l-transparent'}`, + '[&>.rdg-cell]:box-border [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none [&>.rdg-cell]:py-3', + '[&>.rdg-cell.column-prop_total_time]:relative', + ].join(' ') + }} + renderers={{ + renderRow(idx, props) { + return ( + { + event.stopPropagation() + if (typeof idx === 'number' && idx >= 0) { + const query = explorerItems[idx]?.query + if (query && onCurrentSelectQuery) { + onCurrentSelectQuery(currentSelectedQuery === query ? null : query) + } + } + }} + /> + ) + }, + noRowsFallback: ( +
+ +
+

No queries found

+

+ {searchQuery.trim() + ? 'No queries match your search criteria' + : 'No query data available yet'} +

+
+
+ ), + }} + /> +
+ )} +
+ + { + if (!open) { + setSelectedTriageRow(undefined) + setSelectedRow(undefined) + } + }} + activeSheetRow={activeSheetRow} + sheetView={sheetView} + onSheetViewChange={setSheetView} + onClose={() => { + setSelectedTriageRow(undefined) + setSelectedRow(undefined) + }} + dataGridContainerRef={dataGridContainerRef} + triageContainerRef={triageContainerRef} + explainLoadingQuery={explainLoadingQuery} + explainResults={explainResults} + /> +
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.types.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.types.ts new file mode 100644 index 0000000000..932e77ff45 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.types.ts @@ -0,0 +1,2 @@ +export type Mode = 'triage' | 'explorer' +export type IssueFilter = 'all' | 'error' | 'index' | 'slow' diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.utils.test.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.utils.test.ts new file mode 100644 index 0000000000..5b5ad17140 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.utils.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest' +import { + formatDuration, + getQueryType, + getTableName, + getColumnName, + formatQueryDisplay, +} from './QueryInsightsTable.utils' + +describe('formatDuration', () => { + it('returns ms for values under 1000', () => { + expect(formatDuration(0)).toBe('0ms') + expect(formatDuration(500)).toBe('500ms') + expect(formatDuration(999)).toBe('999ms') + }) + + it('delegates to formatDurationLong for values >= 1000', () => { + expect(formatDuration(1000)).toBe('1.00s') + expect(formatDuration(60000)).toBe('1m') + }) +}) + +describe('getQueryType', () => { + it('returns null for empty/null/undefined input', () => { + expect(getQueryType(null)).toBeNull() + expect(getQueryType(undefined)).toBeNull() + expect(getQueryType('')).toBeNull() + }) + + it('returns the SQL keyword for standard statement types', () => { + expect(getQueryType('SELECT * FROM users')).toBe('SELECT') + expect(getQueryType('INSERT INTO orders VALUES (1)')).toBe('INSERT') + expect(getQueryType('UPDATE users SET name = $1')).toBe('UPDATE') + expect(getQueryType('DELETE FROM logs')).toBe('DELETE') + expect(getQueryType('CREATE TABLE foo (id int)')).toBe('CREATE') + expect(getQueryType('DROP TABLE foo')).toBe('DROP') + expect(getQueryType('ALTER TABLE foo ADD COLUMN bar text')).toBe('ALTER') + expect(getQueryType('TRUNCATE foo')).toBe('TRUNCATE') + }) + + it('returns WITH for simple CTEs', () => { + expect(getQueryType('WITH cte AS (SELECT 1) SELECT * FROM cte')).toBe('WITH') + }) + + it('is case-insensitive', () => { + expect(getQueryType('select * from users')).toBe('SELECT') + expect(getQueryType('insert into foo values (1)')).toBe('INSERT') + }) +}) + +describe('getTableName', () => { + it('returns null for empty/null/undefined input', () => { + expect(getTableName(null)).toBeNull() + expect(getTableName(undefined)).toBeNull() + expect(getTableName('')).toBeNull() + }) + + it('extracts table from SELECT FROM', () => { + expect(getTableName('SELECT * FROM users')).toBe('users') + expect(getTableName('SELECT id FROM public.orders WHERE id = 1')).toBe('orders') + }) + + it('extracts table from INSERT INTO', () => { + expect(getTableName('INSERT INTO orders (id) VALUES (1)')).toBe('orders') + }) + + it('extracts table from UPDATE', () => { + expect(getTableName('UPDATE users SET name = $1 WHERE id = 1')).toBe('users') + }) + + it('extracts table from DELETE FROM', () => { + expect(getTableName('DELETE FROM logs WHERE id = 1')).toBe('logs') + }) + + it('extracts table from CREATE TABLE', () => { + expect(getTableName('CREATE TABLE foo (id int)')).toBe('foo') + expect(getTableName('CREATE TABLE IF NOT EXISTS bar (id int)')).toBe('bar') + }) + + it('extracts table from ALTER TABLE', () => { + expect(getTableName('ALTER TABLE users ADD COLUMN email text')).toBe('users') + }) + + it('extracts table from DROP TABLE', () => { + expect(getTableName('DROP TABLE IF EXISTS old_table')).toBe('old_table') + }) + + it('extracts table from TRUNCATE', () => { + expect(getTableName('TRUNCATE TABLE logs')).toBe('logs') + expect(getTableName('TRUNCATE logs')).toBe('logs') + }) + + it('strips schema prefix', () => { + expect(getTableName('SELECT * FROM public.users')).toBe('users') + }) + + it('strips quotes', () => { + expect(getTableName('SELECT * FROM "my_table"')).toBe('my_table') + }) +}) + +describe('getColumnName', () => { + it('returns null for empty/null/undefined input', () => { + expect(getColumnName(null)).toBeNull() + expect(getColumnName(undefined)).toBeNull() + expect(getColumnName('')).toBeNull() + }) + + it('extracts column from WHERE clause', () => { + expect(getColumnName('SELECT * FROM users WHERE id = 1')).toBe('id') + }) + + it('extracts column from ORDER BY when no WHERE clause', () => { + expect(getColumnName('SELECT * FROM users ORDER BY created_at')).toBe('created_at') + }) + + it('extracts column from GROUP BY', () => { + expect(getColumnName('SELECT status, count(*) FROM orders GROUP BY status')).toBe('status') + }) + + it('extracts column from UPDATE SET when no WHERE clause', () => { + expect(getColumnName('UPDATE users SET email = $1')).toBe('email') + }) + + it('extracts first column from INSERT INTO', () => { + expect(getColumnName('INSERT INTO users (id, name) VALUES ($1, $2)')).toBe('id') + }) +}) + +describe('formatQueryDisplay', () => { + it('formats all three parts', () => { + expect(formatQueryDisplay('SELECT', 'users', 'id')).toBe('SELECT in users, id') + }) + + it('uses dash placeholders for null values', () => { + expect(formatQueryDisplay(null, null, null)).toBe('– in –, –') + expect(formatQueryDisplay('SELECT', null, null)).toBe('SELECT in –, –') + }) +}) diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.utils.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.utils.ts new file mode 100644 index 0000000000..51e7814d52 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.utils.ts @@ -0,0 +1,170 @@ +import { formatDuration as formatDurationLong } from '../../QueryPerformance/QueryPerformance.utils' + +export const formatDuration = (ms: number) => { + if (ms < 1000) { + return `${Math.round(ms)}ms` + } + return formatDurationLong(ms) +} + +export const getQueryType = (query: string | undefined | null): string | null => { + if (!query) return null + const trimmed = query.trim() + const firstWord = trimmed.split(/\s+/)[0]?.toUpperCase() + + const sqlTypes = [ + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'CREATE', + 'DROP', + 'ALTER', + 'TRUNCATE', + 'WITH', + ] + + if (firstWord && sqlTypes.includes(firstWord)) { + return firstWord + } + + return null +} + +const cleanIdentifier = (identifier?: string): string | null => { + if (!identifier) return null + return ( + identifier + .replace(/["`']/g, '') + .replace(/^[\w]+\./, '') + .trim() || null + ) +} + +export const getTableName = (query: string | undefined | null): string | null => { + if (!query) return null + const trimmed = query.trim() + + let match = trimmed.match( + /FROM\s+(?:(?(?:"[^"]+"|[\w]+)\.)?(?(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.table) { + return cleanIdentifier(match.groups.table) + } + + match = trimmed.match( + /INSERT\s+INTO\s+(?:(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.table) { + return cleanIdentifier(match.groups.table) + } + + match = trimmed.match(/UPDATE\s+(?:(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"[^"]+"|[\w]+)))/i) + if (match?.groups?.table) { + return cleanIdentifier(match.groups.table) + } + + match = trimmed.match( + /DELETE\s+FROM\s+(?:(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.table) { + return cleanIdentifier(match.groups.table) + } + + match = trimmed.match( + /CREATE\s+(?:TEMPORARY\s+|TEMP\s+|UNLOGGED\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.table) { + return cleanIdentifier(match.groups.table) + } + + match = trimmed.match( + /ALTER\s+TABLE\s+(?:(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.table) { + return cleanIdentifier(match.groups.table) + } + + match = trimmed.match( + /DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.table) { + return cleanIdentifier(match.groups.table) + } + + match = trimmed.match( + /TRUNCATE\s+(?:TABLE\s+)?(?:(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.table) { + return cleanIdentifier(match.groups.table) + } + + if (trimmed.toUpperCase().startsWith('WITH')) { + match = trimmed.match( + /WITH\s+[\s\S]*?\s+FROM\s+(?:(?(?:"[^"]+"|[\w]+)\.)?(?
(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.table) { + return cleanIdentifier(match.groups.table) + } + } + + return null +} + +export const getColumnName = (query: string | undefined | null): string | null => { + if (!query) return null + const trimmed = query.trim() + + let match = trimmed.match( + /WHERE\s+(?:(?
(?:"[^"]+"|[\w]+)\.)?(?(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.column) { + return cleanIdentifier(match?.groups?.column) + } + + match = trimmed.match( + /SELECT\s+(?:\*\s+FROM|(?:(?
(?:"[^"]+"|[\w]+)\.)?(?(?:"[^"]+"|[\w]+))(?:\s*,\s*[\w.]+)*)\s+FROM)/i + ) + if (match?.groups?.column && match.groups.column.toUpperCase() !== '*') { + return cleanIdentifier(match.groups.column) + } + + match = trimmed.match( + /ORDER\s+BY\s+(?:(?
(?:"[^"]+"|[\w]+)\.)?(?(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.column) { + return cleanIdentifier(match.groups.column) + } + + match = trimmed.match( + /GROUP\s+BY\s+(?:(?
(?:"[^"]+"|[\w]+)\.)?(?(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.column) { + return cleanIdentifier(match.groups.column) + } + + match = trimmed.match( + /UPDATE\s+[\w.]+\s+SET\s+(?:(?
(?:"[^"]+"|[\w]+)\.)?(?(?:"[^"]+"|[\w]+)))/i + ) + if (match?.groups?.column) { + return cleanIdentifier(match.groups.column) + } + + match = trimmed.match(/INSERT\s+INTO\s+[\w.]+\s*\((?(?:"[^"]+"|[\w]+))/i) + if (match?.groups?.column) { + return cleanIdentifier(match.groups.column) + } + + return null +} + +export const formatQueryDisplay = ( + queryType: string | null, + tableName: string | null, + columnName: string | null +): string => { + const type = queryType ?? '–' + const table = tableName ?? '–' + const column = columnName ?? '–' + return `${type} in ${table}, ${column}` +} diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTableRow.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTableRow.tsx new file mode 100644 index 0000000000..436159ee6a --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTableRow.tsx @@ -0,0 +1,182 @@ +import { Loader2 } from 'lucide-react' +import { AiIconAnimation, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types' +import { ISSUE_DOT_COLORS, ISSUE_ICONS } from './QueryInsightsTable.constants' +import { formatDuration, getTableName, getColumnName } from './QueryInsightsTable.utils' + +interface QueryInsightsTableRowProps { + item: ClassifiedQuery + onRowClick?: () => void + onGoToLogs?: () => void + onCreateIndex?: () => void + onExplain?: () => void + onAiSuggestedFix?: () => void + isExplainLoading?: boolean +} + +export const QueryInsightsTableRow = ({ + item, + onRowClick, + onGoToLogs, + onCreateIndex, + onExplain, + onAiSuggestedFix, + isExplainLoading, +}: QueryInsightsTableRowProps) => { + const IssueIcon = item.issueType ? ISSUE_ICONS[item.issueType] : null + + return ( +
+ {item.issueType && IssueIcon && ( +
+ +
+ )} + +
+

+ {item.queryType ?? '–'} + {getTableName(item.query) && ( + <> + {' '} + in {getTableName(item.query)} + + )} + {getColumnName(item.query) && ( + <> + , {getColumnName(item.query)} + + )} +

+

+ {item.hint} +

+
+ +
+ + +
+ = 1000 && 'text-destructive-600' + )} + > + {formatDuration(item.mean_time)} + + + avg + +
+
+ + Average execution time per call. High mean time means individual runs are slow — + directly felt by users. + +
+ + + +
+ + {item.prop_total_time.toFixed(1)}% + + + of db + +
+
+ + Percentage of total database execution time. Fixing high-impact queries has the biggest + overall effect on your database. + +
+ + + +
+ {item.calls.toLocaleString()} + + calls + +
+
+ + Number of times this query ran in the selected time window. + +
+
+ +
+ + + {(item.issueType === 'index' || item.issueType === 'slow') && ( + + )} + + {item.issueType === 'index' && ( + + )} + + {(item.issueType === 'error' || item.issueType === 'slow') && ( + + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.ts b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.ts new file mode 100644 index 0000000000..6061ff6b08 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.ts @@ -0,0 +1,21 @@ +import { useMemo } from 'react' + +import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types' +import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types' +import { getQueryType } from '../QueryInsightsTable/QueryInsightsTable.utils' +import { classifyQuery } from './useQueryInsightsIssues.utils' + +export function useQueryInsightsIssues(data: QueryPerformanceRow[]) { + return useMemo(() => { + const classified: ClassifiedQuery[] = data.map((row) => { + const { issueType, hint } = classifyQuery(row) + return { ...row, issueType, hint, queryType: getQueryType(row.query) } + }) + + const errors = classified.filter((q) => q.issueType === 'error') + const indexIssues = classified.filter((q) => q.issueType === 'index') + const slowQueries = classified.filter((q) => q.issueType === 'slow') + + return { classified, errors, indexIssues, slowQueries } + }, [data]) +} diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.test.ts b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.test.ts new file mode 100644 index 0000000000..3f2922c8df --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest' +import { classifyQuery } from './useQueryInsightsIssues.utils' +import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types' + +vi.mock('../../QueryPerformance/IndexAdvisor/index-advisor.utils', () => ({ + hasIndexRecommendations: vi.fn(), +})) + +import { hasIndexRecommendations } from '../../QueryPerformance/IndexAdvisor/index-advisor.utils' + +const baseRow: QueryPerformanceRow = { + query: 'SELECT * FROM users', + calls: 10, + mean_time: 50, + min_time: 10, + max_time: 200, + total_time: 500, + prop_total_time: 5, + rows_read: 100, + cache_hit_rate: 1, + rolname: 'postgres', + application_name: 'test', + index_advisor_result: null, + _total_cache_hits: 0, + _total_cache_misses: 0, +} + +describe('classifyQuery', () => { + it('returns error when index_advisor_result has errors', () => { + const row = { + ...baseRow, + index_advisor_result: { + errors: ['some error'], + index_statements: [], + startup_cost_before: 0, + startup_cost_after: 0, + total_cost_before: 0, + total_cost_after: 0, + }, + } + const result = classifyQuery(row) + expect(result.issueType).toBe('error') + expect(result.hint).toBe('some error') + }) + + it('returns index when hasIndexRecommendations is true', () => { + vi.mocked(hasIndexRecommendations).mockReturnValue(true) + const row = { + ...baseRow, + index_advisor_result: { + errors: [], + index_statements: ['CREATE INDEX ...'], + startup_cost_before: 0, + startup_cost_after: 0, + total_cost_before: 0, + total_cost_after: 0, + }, + } + const result = classifyQuery(row) + expect(result.issueType).toBe('index') + expect(result.hint).toContain('Missing index') + vi.mocked(hasIndexRecommendations).mockReset() + }) + + it('returns slow when mean_time exceeds threshold', () => { + vi.mocked(hasIndexRecommendations).mockReturnValue(false) + const row = { ...baseRow, mean_time: 300 } + const result = classifyQuery(row) + expect(result.issueType).toBe('slow') + expect(result.hint).toBe('Abnormally slow query detected') + vi.mocked(hasIndexRecommendations).mockReset() + }) + + it('returns null issue for healthy queries', () => { + vi.mocked(hasIndexRecommendations).mockReturnValue(false) + const row = { ...baseRow, mean_time: 50 } + const result = classifyQuery(row) + expect(result.issueType).toBeNull() + expect(result.hint).toBe('') + vi.mocked(hasIndexRecommendations).mockReset() + }) + + it('errors take priority over index recommendations', () => { + vi.mocked(hasIndexRecommendations).mockReturnValue(true) + const row = { + ...baseRow, + index_advisor_result: { + errors: ['critical error'], + index_statements: ['CREATE INDEX ...'], + startup_cost_before: 0, + startup_cost_after: 0, + total_cost_before: 0, + total_cost_after: 0, + }, + } + const result = classifyQuery(row) + expect(result.issueType).toBe('error') + vi.mocked(hasIndexRecommendations).mockReset() + }) +}) diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.ts b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.ts new file mode 100644 index 0000000000..5a71597685 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.ts @@ -0,0 +1,37 @@ +import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types' +import { hasIndexRecommendations } from '../../QueryPerformance/IndexAdvisor/index-advisor.utils' +import { SLOW_QUERY_THRESHOLD_MS } from '../QueryInsightsHealth/QueryInsightsHealth.constants' +import type { IssueType } from '../QueryInsightsHealth/QueryInsightsHealth.types' + +export function classifyQuery(row: QueryPerformanceRow): { issueType: IssueType; hint: string } { + // undefined means index advisor is still loading — defer index classification only to avoid + // flickering between 'slow' and 'index' as results arrive, but still classify slow queries + if (row.index_advisor_result === undefined) { + if (row.mean_time > SLOW_QUERY_THRESHOLD_MS) { + return { issueType: 'slow', hint: 'Abnormally slow query detected' } + } + return { issueType: null, hint: '' } + } + + const advisorErrors = row.index_advisor_result?.errors + if (advisorErrors && advisorErrors.length > 0) { + return { issueType: 'error', hint: advisorErrors[0] } + } + + if (hasIndexRecommendations(row.index_advisor_result, true)) { + const statements = row.index_advisor_result?.index_statements ?? [] + return { + issueType: 'index', + hint: `Missing index: ${statements[0] ?? 'Index suggestion available'}`, + } + } + + if (row.mean_time > SLOW_QUERY_THRESHOLD_MS) { + return { + issueType: 'slow', + hint: `Abnormally slow query detected`, + } + } + + return { issueType: null, hint: '' } +} diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsMetrics.ts b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsMetrics.ts new file mode 100644 index 0000000000..6391d5bb35 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsMetrics.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react' +import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types' + +export const useQueryInsightsMetrics = (data: QueryPerformanceRow[]) => { + const avgP95 = useMemo(() => { + const rows = data.filter((r) => (r.p95_time ?? 0) > 0) + if (rows.length === 0) return 0 + return Math.round(rows.reduce((sum, r) => sum + (r.p95_time ?? 0), 0) / rows.length) + }, [data]) + + const totalCalls = useMemo(() => data.reduce((sum, r) => sum + r.calls, 0), [data]) + + const totalRowsRead = useMemo(() => data.reduce((sum, r) => sum + r.rows_read, 0), [data]) + + const cacheHitRate = useMemo(() => { + const hits = data.reduce((sum, r) => sum + (r._total_cache_hits ?? 0), 0) + const misses = data.reduce((sum, r) => sum + (r._total_cache_misses ?? 0), 0) + const total = hits + misses + return total > 0 ? ((hits / total) * 100).toFixed(2) + '%' : '–' + }, [data]) + + return { avgP95, totalCalls, totalRowsRead, cacheHitRate } +} diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsScore.ts b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsScore.ts new file mode 100644 index 0000000000..b4f7c24ea4 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsScore.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react' + +import { + SCORE_DEDUCTIONS, + HIGH_CALL_THRESHOLD, +} from '../QueryInsightsHealth/QueryInsightsHealth.constants' +import { getHealthLevel } from '../QueryInsightsHealth/QueryInsightsHealth.utils' +import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types' + +export function useQueryInsightsScore({ + errors, + indexIssues, + slowQueries, +}: { + errors: ClassifiedQuery[] + indexIssues: ClassifiedQuery[] + slowQueries: ClassifiedQuery[] +}) { + return useMemo(() => { + let score = 100 + + score -= errors.length * SCORE_DEDUCTIONS.error + + score -= indexIssues.reduce( + (acc, q) => + acc + + (q.calls > HIGH_CALL_THRESHOLD + ? SCORE_DEDUCTIONS.indexHighCalls + : SCORE_DEDUCTIONS.indexLowCalls), + 0 + ) + + score -= slowQueries.reduce( + (acc, q) => + acc + + (q.calls > HIGH_CALL_THRESHOLD / 2 + ? SCORE_DEDUCTIONS.slowHighCalls + : SCORE_DEDUCTIONS.slowLowCalls), + 0 + ) + + score = Math.max(0, Math.min(100, score)) + + return { + score, + level: getHealthLevel(score), + } + }, [errors, indexIssues, slowQueries]) +} diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx new file mode 100644 index 0000000000..137e3b9b2d --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx @@ -0,0 +1,599 @@ +import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, ExternalLink, ScanSearch } from 'lucide-react' +import { type RefObject, useMemo } from 'react' +// eslint-disable-next-line no-restricted-imports +import { type Column, type DataGridHandle } from 'react-data-grid' +import { + Button, + cn, + CodeBlock, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import { InfoTooltip } from 'ui-patterns/info-tooltip' + +import { buildQueryInsightFixPrompt } from '../../QueryPerformance/QueryPerformance.ai' +import { QUERY_PERFORMANCE_ROLE_DESCRIPTION } from '../../QueryPerformance/QueryPerformance.constants' +import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types' +import { + ISSUE_DOT_COLORS, + ISSUE_ICONS, + NON_SORTABLE_COLUMNS, + QUERY_INSIGHTS_EXPLORER_COLUMNS, +} from '../QueryInsightsTable/QueryInsightsTable.constants' +import { + formatDuration, + getColumnName, + getTableName, +} from '../QueryInsightsTable/QueryInsightsTable.utils' + +interface UseQueryInsightsTableColumnsParams { + sort: { column: string; order: 'asc' | 'desc' } + setSort: (config: { column: string; order: 'asc' | 'desc' } | null) => void + timeConsumedWidth: number + triageQueryColWidth: number + gridRef: RefObject + setSelectedRow: (idx: number) => void + setSelectedTriageRow: (idx: number | undefined) => void + setSheetView: (view: 'details' | 'indexes' | 'explain') => void + handleGoToLogs: () => void + handleAiSuggestedFix: (item: ClassifiedQuery) => void +} + +export function useQueryInsightsTableColumns({ + sort, + setSort, + timeConsumedWidth, + triageQueryColWidth, + gridRef, + setSelectedRow, + setSelectedTriageRow, + setSheetView, + handleGoToLogs, + handleAiSuggestedFix, +}: UseQueryInsightsTableColumnsParams): { + columns: Column[] + triageColumns: Column[] +} { + const columns = useMemo(() => { + return QUERY_INSIGHTS_EXPLORER_COLUMNS.map((col) => { + const isSortable = !NON_SORTABLE_COLUMNS.includes(col.id as never) + + const result: Column = { + key: col.id, + name: col.name, + cellClass: `column-${col.id}`, + resizable: true, + minWidth: col.id === 'prop_total_time' ? timeConsumedWidth : col.minWidth ?? 120, + sortable: isSortable, + headerCellClass: 'first:pl-6 cursor-pointer', + renderHeaderCell: () => { + return ( +
+
+

{col.name}

+ {col.description && ( +

{col.description}

+ )} +
+ + {isSortable && ( + + +
+ ) + }, + renderCell: (props) => { + const row = props.row + const value = row[col.id] + + if (col.id === 'query') { + const IssueIcon = row.issueType ? ISSUE_ICONS[row.issueType] : null + return ( +
+
+ {row.issueType && IssueIcon && ( + + +
+ +
+
+ {row.hint && ( + + {row.hint} + + )} +
+ )} +
+ + } + size="tiny" + type="default" + onClick={(e: React.MouseEvent) => { + e.stopPropagation() + setSelectedRow(props.rowIdx) + setSheetView('details') + gridRef.current?.scrollToCell({ idx: 0, rowIdx: props.rowIdx }) + }} + className="p-1 flex-shrink-0 -translate-x-2 group-hover:flex hidden" + /> +
+ ) + } + + if (col.id === 'prop_total_time') { + const percentage = row.prop_total_time || 0 + const totalTime = row.total_time || 0 + const fillWidth = Math.min(percentage, 100) + return ( +
+
+ {percentage && totalTime ? ( + + + {percentage.toFixed(1)}% + + / + + {formatDuration(totalTime)} + + + ) : ( +

+ )} +
+ ) + } + + if (col.id === 'calls') { + return ( +
+ {typeof value === 'number' && !isNaN(value) && isFinite(value) ? ( +

+ {value.toLocaleString()} +

+ ) : ( +

+ )} +
+ ) + } + + if (col.id === 'max_time' || col.id === 'mean_time' || col.id === 'min_time') { + return ( +
+ {typeof value === 'number' && !isNaN(value) && isFinite(value) ? ( +

+ {Math.round(value).toLocaleString()}ms +

+ ) : ( +

+ )} +
+ ) + } + + if (col.id === 'rows_read') { + return ( +
+ {typeof value === 'number' && !isNaN(value) && isFinite(value) ? ( +

+ {value.toLocaleString()} +

+ ) : ( +

+ )} +
+ ) + } + + if (col.id === 'cache_hit_rate') { + const num = typeof value === 'number' ? value : parseFloat(value ?? '0') + return ( +
+ {typeof num === 'number' && !isNaN(num) && isFinite(num) ? ( +

+ {num.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + % +

+ ) : ( +

+ )} +
+ ) + } + + if (col.id === 'rolname') { + return ( +
+ {value ? ( + +

{value}

+ + { + QUERY_PERFORMANCE_ROLE_DESCRIPTION.find((r) => r.name === value) + ?.description + } + +
+ ) : ( +

+ )} +
+ ) + } + + if (col.id === 'application_name') { + return ( +
+ {value ? ( +

{value}

+ ) : ( +

+ )} +
+ ) + } + + return null + }, + } + return result + }) + }, [sort, setSort, timeConsumedWidth, gridRef, setSelectedRow, setSheetView]) + + const triageColumns = useMemo( + (): Column[] => [ + { + key: 'query', + name: 'Query', + minWidth: triageQueryColWidth, + width: triageQueryColWidth, + resizable: true, + headerCellClass: 'first:pl-6 cursor-default', + renderHeaderCell: () => ( +
+

Query

+
+ ), + renderCell: (props) => { + const row = props.row as ClassifiedQuery + const IssueIcon = row.issueType ? ISSUE_ICONS[row.issueType] : null + return ( +
+
+ {row.issueType && IssueIcon && ( +
+ +
+ )} +
+
+

+ {row.queryType ?? '–'} + {getTableName(row.query) && ( + <> + {' '} + in {getTableName(row.query)} + + )} + {getColumnName(row.query) && ( + <> + , {getColumnName(row.query)} + + )} +

+

+ {row.hint} +

+
+ } + size="tiny" + type="default" + onClick={(e: React.MouseEvent) => { + e.stopPropagation() + setSelectedTriageRow(props.rowIdx) + setSheetView('details') + }} + className="p-1 flex-shrink-0 group-hover:flex hidden" + /> +
+ ) + }, + }, + { + key: 'prop_total_time', + name: 'Time consumed', + minWidth: timeConsumedWidth, + resizable: true, + cellClass: 'column-prop_total_time', + headerCellClass: 'cursor-default', + renderHeaderCell: () => ( +
+

Time consumed

+
+ ), + renderCell: (props) => { + const row = props.row as ClassifiedQuery + const percentage = row.prop_total_time || 0 + const totalTime = row.total_time || 0 + const fillWidth = Math.min(percentage, 100) + return ( +
+
+ {percentage && totalTime ? ( + + + {percentage.toFixed(1)}% + + / + + {formatDuration(totalTime)} + + + ) : ( +

+ )} +
+ ) + }, + }, + { + key: 'calls', + name: 'Calls', + minWidth: 90, + resizable: true, + headerCellClass: 'cursor-default', + renderHeaderCell: () => ( +
+

Calls

+
+ ), + renderCell: (props) => { + const value = (props.row as ClassifiedQuery).calls + return ( +
+ {typeof value === 'number' && !isNaN(value) && isFinite(value) ? ( +

+ {value.toLocaleString()} +

+ ) : ( +

+ )} +
+ ) + }, + }, + { + key: 'mean_time', + name: 'Mean time', + minWidth: 90, + resizable: true, + headerCellClass: 'cursor-default', + renderHeaderCell: () => ( +
+

Mean time

+
+ ), + renderCell: (props) => { + const value = (props.row as ClassifiedQuery).mean_time + return ( +
+ {typeof value === 'number' && !isNaN(value) && isFinite(value) ? ( +

+ {formatDuration(value)} +

+ ) : ( +

+ )} +
+ ) + }, + }, + { + key: 'actions', + name: 'Actions', + minWidth: 200, + resizable: false, + headerCellClass: 'cursor-default', + renderHeaderCell: () => ( +
+

Actions

+
+ ), + renderCell: (props) => { + const row = props.row as ClassifiedQuery + return ( +
+ {!row.issueType && ( + + )} + {row.issueType === 'index' && ( +
e.stopPropagation()}> + + + +
+ )} + {(row.issueType === 'error' || row.issueType === 'slow') && ( +
e.stopPropagation()}> + buildQueryInsightFixPrompt(row).prompt} + onOpenAssistant={() => handleAiSuggestedFix(row)} + copyLabel="Copy Markdown" + extraDropdownItems={ + <> + handleGoToLogs()} className="gap-2"> + + Go to Logs + + {row.issueType === 'slow' && ( + { + setSelectedTriageRow(props.rowIdx) + setSheetView('explain') + }} + className="gap-2" + > + + Explain + + )} + + + } + /> +
+ )} +
+ ) + }, + }, + ], + [ + triageQueryColWidth, + timeConsumedWidth, + handleGoToLogs, + handleAiSuggestedFix, + setSelectedTriageRow, + setSheetView, + ] + ) + + return { columns, triageColumns } +} diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useSupamonitorIndexAdvisor.ts b/apps/studio/components/interfaces/QueryInsights/hooks/useSupamonitorIndexAdvisor.ts new file mode 100644 index 0000000000..59a635f358 --- /dev/null +++ b/apps/studio/components/interfaces/QueryInsights/hooks/useSupamonitorIndexAdvisor.ts @@ -0,0 +1,62 @@ +import { useQueries } from '@tanstack/react-query' + +import { databaseKeys } from 'data/database/keys' +import { getIndexAdvisorResult } from 'data/database/retrieve-index-advisor-result-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useIndexAdvisorStatus } from '../../QueryPerformance/hooks/useIsIndexAdvisorStatus' +import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types' + +function isEligibleQuery(query: string): boolean { + const lower = query.trim().toLowerCase() + if (!lower.startsWith('select') && !lower.startsWith('with')) return false + // Dollar-quoted string literals (e.g. $$...$$ or $tag$...$tag$) break the single-quoted + // SQL embedding in getIndexAdvisorResult. Plain parameter markers ($1, $2, …) are fine. + if (/\$([A-Za-z_][A-Za-z0-9_]*)?\$/.test(query)) return false + return true +} + +export function useSupamonitorIndexAdvisor(rows: QueryPerformanceRow[]): QueryPerformanceRow[] { + const { data: project } = useSelectedProjectQuery() + const { isIndexAdvisorEnabled } = useIndexAdvisorStatus() + + const eligibleQueries = rows.map((r) => r.query).filter(isEligibleQuery) + + const results = useQueries({ + queries: eligibleQueries.map((query) => ({ + queryKey: databaseKeys.indexAdvisorFromQuery( + project?.ref, + query, + project?.connectionString ?? undefined + ), + queryFn: () => + getIndexAdvisorResult({ + projectRef: project?.ref, + connectionString: project?.connectionString, + query, + }), + enabled: isIndexAdvisorEnabled && !!project?.ref, + retry: false, + staleTime: Infinity, + })), + }) + + if (!isIndexAdvisorEnabled) return rows + + const resultByQuery = new Map( + eligibleQueries.map((query, i) => { + const result = results[i] + // Only treat as ready when status is definitively success or error. + // undefined/pending means data hasn't arrived yet — store undefined so + // classifyQuery can defer slow classification and avoid flicker. + const isReady = result?.status === 'success' || result?.status === 'error' + return [query, isReady ? result.data ?? null : undefined] + }) + ) + + return rows.map((row) => ({ + ...row, + index_advisor_result: resultByQuery.has(row.query) + ? resultByQuery.get(row.query) + : row.index_advisor_result ?? null, + })) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.test.ts b/apps/studio/components/interfaces/QueryInsights/utils/supamonitor.utils.test.ts similarity index 98% rename from apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.test.ts rename to apps/studio/components/interfaces/QueryInsights/utils/supamonitor.utils.test.ts index 802f65f722..22d1dd0c3e 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.test.ts +++ b/apps/studio/components/interfaces/QueryInsights/utils/supamonitor.utils.test.ts @@ -3,8 +3,8 @@ import { parseSupamonitorLogs, transformLogsToChartData, aggregateLogsByQuery, -} from './WithSupamonitor.utils' -import { ParsedLogEntry } from '../QueryPerformance.types' +} from './supamonitor.utils' +import type { ParsedLogEntry } from '../QueryInsights.types' const makeSampleLog = (overrides: Partial = {}): any => ({ timestamp: '2025-01-01T00:00:00Z', diff --git a/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.ts b/apps/studio/components/interfaces/QueryInsights/utils/supamonitor.utils.ts similarity index 71% rename from apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.ts rename to apps/studio/components/interfaces/QueryInsights/utils/supamonitor.utils.ts index 73711e9be8..1b324c9f97 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.ts +++ b/apps/studio/components/interfaces/QueryInsights/utils/supamonitor.utils.ts @@ -1,4 +1,30 @@ -import { QueryPerformanceRow, ChartDataPoint, ParsedLogEntry } from '../QueryPerformance.types' +import type { QueryPerformanceRow } from '../../QueryPerformance/QueryPerformance.types' +import type { ChartDataPoint, ParsedLogEntry } from '../QueryInsights.types' +import { + SUPAMONITOR_EXCLUDED_ROLES, + SUPAMONITOR_EXCLUDED_APP_NAMES, + TRANSACTION_CONTROL_REGEX, + SCHEMA_INTROSPECTION_REGEX, +} from '../QueryInsights.constants' + +export function filterSystemLogs( + logs: ParsedLogEntry[], + { includeIntrospection = false }: { includeIntrospection?: boolean } = {} +): ParsedLogEntry[] { + return logs.filter((log) => { + if (log.user_name && (SUPAMONITOR_EXCLUDED_ROLES as readonly string[]).includes(log.user_name)) + return false + if ( + log.application_name && + (SUPAMONITOR_EXCLUDED_APP_NAMES as readonly string[]).includes(log.application_name) + ) + return false + if (log.query && TRANSACTION_CONTROL_REGEX.test(log.query)) return false + if (!includeIntrospection && log.query && SCHEMA_INTROSPECTION_REGEX.test(log.query)) + return false + return true + }) +} export function parseSupamonitorLogs(logData: any[]): ParsedLogEntry[] { if (!logData || logData.length === 0) return [] @@ -87,22 +113,39 @@ export function aggregateLogsByQuery(parsedLogs: ParsedLogEntry[]): QueryPerform let totalCalls = 0 let totalExecTime = 0 let totalPlanTime = 0 + let p95Sum = 0 + let p95Count = 0 let minTime = Infinity let maxTime = -Infinity const rolname = logs[0]?.user_name || '' const applicationName = logs[0]?.application_name || '' + let firstSeen = logs[0]?.timestamp ?? '' logs.forEach((log) => { + if (log.timestamp && (!firstSeen || log.timestamp < firstSeen)) firstSeen = log.timestamp const logCalls = parseInt(String(log.calls ?? 0), 10) totalCalls += logCalls totalExecTime += parseFloat(String(log.total_exec_time ?? 0)) totalPlanTime += parseFloat(String(log.total_plan_time ?? 0)) - minTime = Math.min(minTime, (log.min_exec_time ?? 0) + (log.min_plan_time ?? 0)) - maxTime = Math.max(maxTime, (log.max_exec_time ?? 0) + (log.max_plan_time ?? 0)) + const logP95 = + parseFloat(String(log.p95_exec_time ?? 0)) + parseFloat(String(log.p95_plan_time ?? 0)) + if (logP95 > 0) { + p95Sum += logP95 + p95Count++ + } + minTime = Math.min( + minTime, + parseFloat(String(log.min_exec_time ?? 0)) + parseFloat(String(log.min_plan_time ?? 0)) + ) + maxTime = Math.max( + maxTime, + parseFloat(String(log.max_exec_time ?? 0)) + parseFloat(String(log.max_plan_time ?? 0)) + ) }) const totalTime = totalExecTime + totalPlanTime const avgMeanTime = totalCalls > 0 ? totalTime / totalCalls : 0 + const avgP95Time = p95Count > 0 ? p95Sum / p95Count : 0 const finalMinTime = minTime === Infinity ? 0 : minTime const finalMaxTime = maxTime === -Infinity ? 0 : maxTime @@ -112,8 +155,10 @@ export function aggregateLogsByQuery(parsedLogs: ParsedLogEntry[]): QueryPerform query, rolname, applicationName, + firstSeen, count, avgMeanTime, + avgP95Time, minTime: finalMinTime, maxTime: finalMaxTime, totalCalls, @@ -130,6 +175,7 @@ export function aggregateLogsByQuery(parsedLogs: ParsedLogEntry[]): QueryPerform application_name: stats.applicationName, calls: stats.totalCalls, mean_time: stats.avgMeanTime, + p95_time: stats.avgP95Time, min_time: stats.minTime, max_time: stats.maxTime, total_time: stats.totalTime, @@ -140,6 +186,7 @@ export function aggregateLogsByQuery(parsedLogs: ParsedLogEntry[]): QueryPerform _total_cache_hits: 0, _total_cache_misses: 0, _count: stats.count, + first_seen: stats.firstSeen, }) }) diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.ai.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.ai.ts index 1a20f2981c..4d61579941 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.ai.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.ai.ts @@ -1,4 +1,10 @@ import { QueryPerformanceRow } from './QueryPerformance.types' +import type { QueryPlanRow } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.types' +import type { ClassifiedQuery } from 'components/interfaces/QueryInsights/QueryInsightsHealth/QueryInsightsHealth.types' +import { + getTableName, + getColumnName, +} from 'components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.utils' export interface QueryExplanationPrompt { query: string @@ -47,3 +53,88 @@ Keep your response concise and focused on actionable insights. We can continue t prompt, } } + +export function buildQueryInsightFixPrompt(item: ClassifiedQuery): QueryExplanationPrompt { + const stats = [ + `Mean time: ${Math.round(item.mean_time).toLocaleString()}ms`, + `Total time: ${Math.round(item.total_time).toLocaleString()}ms`, + `Calls: ${item.calls.toLocaleString()}`, + ].join('\n') + + const issueContext: Record, string> = { + slow: `This query is running slowly (mean ${Math.round(item.mean_time)}ms). The goal is to reduce mean execution time.`, + index: `This query is missing an index. The planner is doing a sequential scan where an index scan would be faster.`, + error: `This query has a performance error or anti-pattern: ${item.hint}`, + } + + const context = item.issueType ? issueContext[item.issueType] : '' + + const tableName = getTableName(item.query) + const columnName = getColumnName(item.query) + const queryContext = [tableName && `Table: ${tableName}`, columnName && `Column: ${columnName}`] + .filter(Boolean) + .join('\n') + + const prompt = `You are a PostgreSQL performance expert. A query has been flagged in our Query Insights triage view. + +**Issue:** ${item.hint} +${context} + +**Performance stats:** +${stats} +${queryContext ? `\n**Query context:**\n${queryContext}` : ''} + +**Full query:** +\`\`\`sql +${item.query} +\`\`\` + +Your task is to provide a concrete fix the user can run directly in their SQL Editor. + +Respond with: +1. A short diagnosis (1-2 sentences max) explaining why the query is slow or problematic +2. The exact SQL to fix it — this could be a \`CREATE INDEX\` statement, a rewritten version of the query, or another SQL command +3. A brief explanation of why the fix helps and what improvement to expect + +Format the SQL as a runnable code block. Do not add caveats or lengthy explanations — focus on the fix. + +**Important:** The SQL Editor runs statements inside a transaction block. Do not use \`CREATE INDEX CONCURRENTLY\` — use plain \`CREATE INDEX\` instead. + +**Important:** Use table and column names exactly as they appear in the full query above, including their schema prefix (e.g. \`auth.users\`, not \`public.users\`). Do not guess or change schema names.` + + return { query: item.query, prompt } +} + +export function buildExplainOptimizationPrompt( + query: string, + planRows: QueryPlanRow[], + stats?: Pick +): QueryExplanationPrompt { + const explainText = planRows.map((r) => r['QUERY PLAN']).join('\n') + + const statLines = stats + ? [ + `Mean time: ${stats.mean_time.toLocaleString()}ms`, + `Total time: ${stats.total_time.toLocaleString()}ms`, + `Calls: ${stats.calls.toLocaleString()}`, + ].join('\n') + : null + + const prompt = `You are a PostgreSQL performance expert. Analyze the EXPLAIN ANALYZE output below and suggest specific optimizations. + +${statLines ? `**Performance stats:**\n${statLines}\n` : ''} +**EXPLAIN ANALYZE output:** +\`\`\` +${explainText} +\`\`\` + +Focus on: +1. The most expensive nodes (widest bars / highest actual time) +2. Any Seq Scans on large tables that could be replaced with an Index Scan +3. Large gaps between estimated rows and actual rows (stale statistics) +4. Inefficient join strategies (Nested Loop on large tables) + +For each issue found, suggest a concrete fix (e.g. the exact \`CREATE INDEX\` statement, or \`ANALYZE table_name\`). Be concise and actionable.` + + return { query, prompt } +} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts index ecbaa3ea46..14170d2bd5 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts @@ -88,41 +88,3 @@ export const QUERY_PERFORMANCE_CHART_TABS = [ label: 'Cache hits', }, ] - -export const getSupamonitorLogsQuery = (startTime: string, endTime: string) => - ` -select - TIMESTAMP_TRUNC(sml.timestamp, MINUTE) as timestamp, - CAST(sml_parsed.application_name AS STRING) as application_name, - SUM(sml_parsed.calls) as calls, - CAST(sml_parsed.database_name AS STRING) as database_name, - CAST(sml_parsed.query AS STRING) as query, - sml_parsed.query_id as query_id, - SUM(sml_parsed.total_exec_time) as total_exec_time, - SUM(sml_parsed.total_plan_time) as total_plan_time, - CAST(sml_parsed.user_name AS STRING) as user_name, - CASE WHEN SUM(sml_parsed.calls) > 0 - THEN SUM(sml_parsed.total_exec_time) / SUM(sml_parsed.calls) - ELSE 0 - END as mean_exec_time, - MIN(NULLIF(sml_parsed.total_exec_time, 0)) as min_exec_time, - MAX(sml_parsed.total_exec_time) as max_exec_time, - CASE WHEN SUM(sml_parsed.calls) > 0 - THEN SUM(sml_parsed.total_plan_time) / SUM(sml_parsed.calls) - ELSE 0 - END as mean_plan_time, - MIN(NULLIF(sml_parsed.total_plan_time, 0)) as min_plan_time, - MAX(sml_parsed.total_plan_time) as max_plan_time, - APPROX_QUANTILES(sml_parsed.total_exec_time, 100)[OFFSET(50)] as p50_exec_time, - APPROX_QUANTILES(sml_parsed.total_exec_time, 100)[OFFSET(95)] as p95_exec_time, - APPROX_QUANTILES(sml_parsed.total_plan_time, 100)[OFFSET(50)] as p50_plan_time, - APPROX_QUANTILES(sml_parsed.total_plan_time, 100)[OFFSET(95)] as p95_plan_time -from supamonitor_logs as sml -cross join unnest(sml.metadata) as sml_metadata -cross join unnest(sml_metadata.supamonitor) as sml_parsed -WHERE sml.event_message = 'log' - AND sml.timestamp >= CAST('${startTime}' AS TIMESTAMP) - AND sml.timestamp <= CAST('${endTime}' AS TIMESTAMP) -GROUP BY timestamp, user_name, database_name, application_name, query_id, query -ORDER BY timestamp DESC -`.trim() diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx index 7ff8243739..3c7a6d9d6b 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx @@ -1,7 +1,6 @@ import { useEffect } from 'react' import { WithStatements } from './WithStatements/WithStatements' -import { WithSupamonitor } from './WithSupamonitor/WithSupamonitor' import { useParams } from 'common' import { DbQueryHook } from 'hooks/analytics/useDbQuery' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' @@ -11,22 +10,12 @@ interface QueryPerformanceProps { queryHitRate: PresetHookResult queryPerformanceQuery: DbQueryHook queryMetrics: PresetHookResult - isSupamonitorEnabled: boolean - dateRange?: { - period_start: { date: string; time_period: string } - period_end: { date: string; time_period: string } - interval: string - } - onDateRangeChange?: (from: string, to: string) => void } export const QueryPerformance = ({ queryHitRate, queryPerformanceQuery, queryMetrics, - isSupamonitorEnabled, - dateRange, - onDateRangeChange, }: QueryPerformanceProps) => { const { ref } = useParams() const state = useDatabaseSelectorStateSnapshot() @@ -36,10 +25,6 @@ export const QueryPerformance = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref]) - if (isSupamonitorEnabled) { - return - } - return ( void -} - -export const WithSupamonitor = ({ dateRange, onDateRangeChange }: WithSupamonitorProps) => { - const { ref } = useParams() - const { data: project } = useSelectedProjectQuery() - const state = useDatabaseSelectorStateSnapshot() - const [selectedQuery, setSelectedQuery] = useState(null) - - const effectiveDateRange = useMemo(() => { - if (dateRange) { - return { - iso_timestamp_start: dateRange.period_start.date, - iso_timestamp_end: dateRange.period_end.date, - } - } - - const end = dayjs.utc() - const start = end.subtract(24, 'hours') - return { - iso_timestamp_start: start.toISOString(), - iso_timestamp_end: end.toISOString(), - } - }, [dateRange]) - - const queryWithTimeRange = useMemo(() => { - return getSupamonitorLogsQuery( - effectiveDateRange.iso_timestamp_start, - effectiveDateRange.iso_timestamp_end - ) - }, [effectiveDateRange]) - - const supamonitorLogs = useLogsQuery(ref as string, { - sql: queryWithTimeRange, - iso_timestamp_start: effectiveDateRange.iso_timestamp_start, - iso_timestamp_end: effectiveDateRange.iso_timestamp_end, - }) - - const { logData, isLoading: isLogsLoading, error: logsError } = supamonitorLogs - - const parsedLogs = useMemo(() => { - const result = parseSupamonitorLogs(logData || []) - return result - }, [logData]) - - const chartData = useMemo(() => { - const result = transformLogsToChartData(parsedLogs) - return result - }, [parsedLogs]) - - const aggregatedGridData = useMemo(() => { - const result = aggregateLogsByQuery(parsedLogs) - return result - }, [parsedLogs]) - - const handleSelectQuery = (query: string) => { - setSelectedQuery((prev) => (prev === query ? null : query)) - } - - const handleRetry = () => { - supamonitorLogs.runQuery() - } - - useEffect(() => { - if (logsError) { - const errorMessage = getErrorMessage(logsError) - captureQueryPerformanceError(logsError, { - projectRef: ref, - databaseIdentifier: state.selectedDatabaseId, - queryPreset: 'supamonitor', - queryType: 'supamonitor', - postgresVersion: project?.dbVersion, - databaseType: state.selectedDatabaseId === ref ? 'primary' : 'read-replica', - sql: queryWithTimeRange, - errorMessage: errorMessage || undefined, - }) - } - }, [logsError, ref, state.selectedDatabaseId, project?.dbVersion, queryWithTimeRange]) - - return ( - <> - - - } - /> - - - - ) -} diff --git a/apps/studio/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus.ts b/apps/studio/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus.ts index e525668a31..d142b0e3a7 100644 --- a/apps/studio/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus.ts +++ b/apps/studio/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus.ts @@ -1,9 +1,6 @@ import { useSupamonitorEnabledQuery } from 'data/database/supamonitor-enabled-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -/** - * Hook to check if supamonitor is enabled in shared_preload_libraries - */ export function useSupamonitorStatus() { const { data: project } = useSelectedProjectQuery() const { data: isSupamonitorEnabled, isLoading } = useSupamonitorEnabledQuery({ diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx index 6c7d4df974..96cb647613 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx @@ -20,6 +20,7 @@ import { cn, Menu } from 'ui' import { InnerSideBarEmptyPanel } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus' import { ObservabilityMenuItem } from './ObservabilityMenuItem' @@ -29,6 +30,7 @@ const ObservabilityMenu = () => { const { ref, id } = useParams() const pageKey = (id || router.pathname.split('/')[4] || 'observability') as string const showOverview = useFlag('observabilityOverview') + const { isSupamonitorEnabled } = useSupamonitorStatus() // b/c fly doesn't support storage const storageSupported = useIsFeatureEnabled('project_storage:all') @@ -136,11 +138,21 @@ const ObservabilityMenu = () => { }, ] : []), - { - name: 'Query Performance', - key: 'query-performance', - url: `/project/${ref}/observability/query-performance${preservedQueryParams}`, - }, + ...(isSupamonitorEnabled + ? [ + { + name: 'Query Insights', + key: 'query-insights', + url: `/project/${ref}/observability/query-insights${preservedQueryParams}`, + }, + ] + : [ + { + name: 'Query Performance', + key: 'query-performance', + url: `/project/${ref}/observability/query-performance${preservedQueryParams}`, + }, + ]), ...(IS_PLATFORM ? [ { diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index bf73976ada..6a5c379210 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -91,12 +91,10 @@ export const DatabaseSelector = ({ + {showOnlyButton && ( + + )}
) } diff --git a/apps/studio/data/database/keys.ts b/apps/studio/data/database/keys.ts index e0e1b9bbc5..f9647cd367 100644 --- a/apps/studio/data/database/keys.ts +++ b/apps/studio/data/database/keys.ts @@ -23,8 +23,22 @@ export const databaseKeys = { ['projects', projectRef, 'database', 'pooling-configuration'] as const, indexesFromQuery: (projectRef: string | undefined, query: string) => ['projects', projectRef, 'indexes', { query }] as const, - indexAdvisorFromQuery: (projectRef: string | undefined, query: string) => - ['projects', projectRef, 'index-advisor', { query }] as const, + indexAdvisorFromQuery: ( + projectRef: string | undefined, + query: string, + connectionString?: string + ) => { + // Use only the host (no credentials) as a safe cache discriminator + let connectionFingerprint: string | undefined + if (connectionString) { + try { + connectionFingerprint = new URL(connectionString).host + } catch { + connectionFingerprint = undefined + } + } + return ['projects', projectRef, 'index-advisor', { query, connectionFingerprint }] as const + }, tableConstraints: (projectRef: string | undefined, id?: number) => ['projects', projectRef, 'table-constraints', id] as const, foreignKeyConstraints: (projectRef: string | undefined, schema?: string, options = {}) => diff --git a/apps/studio/data/database/retrieve-index-advisor-result-query.ts b/apps/studio/data/database/retrieve-index-advisor-result-query.ts index 100483fb77..750be63810 100644 --- a/apps/studio/data/database/retrieve-index-advisor-result-query.ts +++ b/apps/studio/data/database/retrieve-index-advisor-result-query.ts @@ -15,10 +15,22 @@ export type GetIndexAdvisorResultVariables = { const IndexAdvisorResultSchema = z.object({ errors: z.array(z.string()), index_statements: z.array(z.string()), - startup_cost_before: z.number(), - startup_cost_after: z.number(), - total_cost_before: z.number(), - total_cost_after: z.number(), + startup_cost_before: z + .number() + .nullable() + .transform((v) => v ?? 0), + startup_cost_after: z + .number() + .nullable() + .transform((v) => v ?? 0), + total_cost_before: z + .number() + .nullable() + .transform((v) => v ?? 0), + total_cost_after: z + .number() + .nullable() + .transform((v) => v ?? 0), }) export type GetIndexAdvisorResultResponse = z.infer diff --git a/apps/studio/pages/project/[ref]/observability/query-insights.tsx b/apps/studio/pages/project/[ref]/observability/query-insights.tsx new file mode 100644 index 0000000000..f64cfcea6b --- /dev/null +++ b/apps/studio/pages/project/[ref]/observability/query-insights.tsx @@ -0,0 +1,72 @@ +import { QueryInsights } from 'components/interfaces/QueryInsights/QueryInsights' +import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants' +import { DefaultLayout } from 'components/layouts/DefaultLayout' +import ObservabilityLayout from 'components/layouts/ObservabilityLayout/ObservabilityLayout' +import { DatabaseSelector } from 'components/ui/DatabaseSelector' +import { DocsButton } from 'components/ui/DocsButton' +import { useReportDateRange } from 'hooks/misc/useReportDateRange' +import { DOCS_URL } from 'lib/constants' +import type { NextPageWithLayout } from 'types' +import { + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, +} from 'ui' + +const PRESETS = [ + REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES, + REPORT_DATERANGE_HELPER_LABELS.LAST_3_HOURS, + REPORT_DATERANGE_HELPER_LABELS.LAST_24_HOURS, +] + +const QueryInsightsReport: NextPageWithLayout = () => { + const { selectedDateRange, datePickerValue, datePickerHelpers, handleDatePickerChange } = + useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES) + + const handleSelect = (text: string) => { + const helper = datePickerHelpers.find((h) => h.text === text) + if (helper) { + handleDatePickerChange({ from: helper.calcFrom(), to: helper.calcTo(), isHelper: true, text }) + } + } + + return ( +
+
+

Query Insights

+
+ + + + + + + + {PRESETS.map((label) => ( + + {label} + + ))} + + +
+
+ +
+ ) +} + +QueryInsightsReport.getLayout = (page) => ( + + {page} + +) + +export default QueryInsightsReport diff --git a/apps/studio/pages/project/[ref]/observability/query-performance.tsx b/apps/studio/pages/project/[ref]/observability/query-performance.tsx index 784c45fed7..62b4a4a137 100644 --- a/apps/studio/pages/project/[ref]/observability/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/observability/query-performance.tsx @@ -3,22 +3,16 @@ import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFi import { useParams } from 'common' import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' -import { useSupamonitorStatus } from 'components/interfaces/QueryPerformance/hooks/useSupamonitorStatus' import { useQueryPerformanceSort } from 'components/interfaces/QueryPerformance/hooks/useQueryPerformanceSort' import { QueryPerformance } from 'components/interfaces/QueryPerformance/QueryPerformance' -import { - PRESET_CONFIG, - REPORT_DATERANGE_HELPER_LABELS, -} from 'components/interfaces/Reports/Reports.constants' +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 { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePickers' import { DefaultLayout } from 'components/layouts/DefaultLayout' import ObservabilityLayout from 'components/layouts/ObservabilityLayout/ObservabilityLayout' import { DatabaseSelector } from 'components/ui/DatabaseSelector' import { DocsButton } from 'components/ui/DocsButton' -import { useReportDateRange } from 'hooks/misc/useReportDateRange' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import type { NextPageWithLayout } from 'types' @@ -28,17 +22,8 @@ const QueryPerformanceReport: NextPageWithLayout = () => { const { ref } = useParams() const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() const { isIndexAdvisorEnabled } = useIndexAdvisorStatus() - const { isSupamonitorEnabled } = useSupamonitorStatus() const { sort: sortConfig } = useQueryPerformanceSort() - const { - selectedDateRange, - datePickerValue, - datePickerHelpers, - updateDateRange, - handleDatePickerChange, - } = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES) - const [ { search: searchQuery, roles, minCalls, totalTimeFilter: totalTimeFilterRaw, indexAdvisor }, ] = useQueryStates({ @@ -99,27 +84,12 @@ const QueryPerformanceReport: NextPageWithLayout = () => { href={`${DOCS_URL}/guides/platform/performance#examining-query-performance`} /> - {isSupamonitorEnabled && ( - - h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES || - h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_3_HOURS || - h.text === REPORT_DATERANGE_HELPER_LABELS.LAST_24_HOURS - )} - onSubmit={handleDatePickerChange} - /> - )}
) diff --git a/apps/studio/styles/main.scss b/apps/studio/styles/main.scss index 7229a55e25..95c54aac3e 100644 --- a/apps/studio/styles/main.scss +++ b/apps/studio/styles/main.scss @@ -11,6 +11,7 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --chart-blue: 217 91% 60%; --chart-warning: hsl(var(--warning-default)); --chart-destructive: hsl(var(--destructive-default)); --sidebar-background: var(--background-dash-sidebar); @@ -33,6 +34,7 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + --chart-blue: 217 91% 65%; --chart-warning: hsl(var(--warning-default)); --chart-destructive: hsl(var(--destructive-default)); --sidebar-background: var(--background-dash-sidebar);