mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
init
This commit is contained in:
1 parent
d60aceb562
commit
cba8ca919d
10 files changed
+925
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { DateRangePicker } from 'components/ui/DateRangePicker'
|
||||
import { TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_, cn } from 'ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { InformationCircleIcon } from '@heroicons/react/16/solid'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { MetricsChart } from './components/MetricsChart'
|
||||
import { QueryList } from './components/QueryList'
|
||||
import {
|
||||
useQueryInsightsMetrics,
|
||||
useQueryInsightsQueries,
|
||||
} from 'data/query-insights/query-insights-query'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
|
||||
export type MetricType = 'rows_read' | 'rows_written' | 'query_latency' | 'queries_per_second'
|
||||
|
||||
const METRICS: { id: MetricType; label: string; description: string }[] = [
|
||||
{
|
||||
id: 'query_latency',
|
||||
label: 'Query Latency',
|
||||
description: 'Average query execution time in milliseconds',
|
||||
},
|
||||
{
|
||||
id: 'rows_read',
|
||||
label: 'Rows Read',
|
||||
description: 'Number of rows read by queries',
|
||||
},
|
||||
{
|
||||
id: 'rows_written',
|
||||
label: 'Rows Written',
|
||||
description: 'Number of rows written by queries',
|
||||
},
|
||||
{
|
||||
id: 'queries_per_second',
|
||||
label: 'Queries per Second',
|
||||
description: 'Number of queries executed per second',
|
||||
},
|
||||
]
|
||||
|
||||
export const QueryInsights = () => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
const { data: databases } = useReadReplicasQuery({ projectRef: ref })
|
||||
|
||||
const [selectedMetric, setSelectedMetric] = useState<MetricType>('query_latency')
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState('24h')
|
||||
const [timeRange, setTimeRange] = useState({
|
||||
period_start: {
|
||||
date: dayjs().subtract(24, 'hour').format('YYYY-MM-DD HH:mm:ssZ'),
|
||||
time_period: '24h',
|
||||
},
|
||||
period_end: {
|
||||
date: dayjs().format('YYYY-MM-DD HH:mm:ssZ'),
|
||||
time_period: 'now',
|
||||
},
|
||||
})
|
||||
|
||||
const { data: metricsData, isLoading: isLoadingMetrics } = useQueryInsightsMetrics(
|
||||
ref,
|
||||
selectedMetric,
|
||||
timeRange.period_start.date,
|
||||
timeRange.period_end.date
|
||||
)
|
||||
|
||||
const { data: queriesData, isLoading: isLoadingQueries } = useQueryInsightsQueries(
|
||||
ref,
|
||||
timeRange.period_start.date,
|
||||
timeRange.period_end.date
|
||||
)
|
||||
|
||||
const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId)
|
||||
|
||||
useEffect(() => {
|
||||
// Update the time range when selectedTimeRange changes
|
||||
switch (selectedTimeRange) {
|
||||
case '24h':
|
||||
setTimeRange({
|
||||
period_start: {
|
||||
date: dayjs().subtract(24, 'hour').format('YYYY-MM-DD HH:mm:ssZ'),
|
||||
time_period: '24h',
|
||||
},
|
||||
period_end: {
|
||||
date: dayjs().format('YYYY-MM-DD HH:mm:ssZ'),
|
||||
time_period: 'now',
|
||||
},
|
||||
})
|
||||
break
|
||||
case '7d':
|
||||
setTimeRange({
|
||||
period_start: {
|
||||
date: dayjs().subtract(7, 'day').format('YYYY-MM-DD HH:mm:ssZ'),
|
||||
time_period: '7d',
|
||||
},
|
||||
period_end: {
|
||||
date: dayjs().format('YYYY-MM-DD HH:mm:ssZ'),
|
||||
time_period: 'now',
|
||||
},
|
||||
})
|
||||
break
|
||||
case '30d':
|
||||
setTimeRange({
|
||||
period_start: {
|
||||
date: dayjs().subtract(30, 'day').format('YYYY-MM-DD HH:mm:ssZ'),
|
||||
time_period: '30d',
|
||||
},
|
||||
period_end: {
|
||||
date: dayjs().format('YYYY-MM-DD HH:mm:ssZ'),
|
||||
time_period: 'now',
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}, [selectedTimeRange])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl text-foreground">Query Metrics</h3>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Monitor and analyze query performance over time
|
||||
</p>
|
||||
</div>
|
||||
<DateRangePicker
|
||||
value={selectedTimeRange}
|
||||
loading={isLoadingMetrics}
|
||||
onChange={({ period_start, period_end, interval }) => {
|
||||
setSelectedTimeRange(period_start.time_period)
|
||||
setTimeRange({ period_start, period_end })
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
key: '24h',
|
||||
label: 'Last 24 hours',
|
||||
interval: '1h',
|
||||
},
|
||||
{
|
||||
key: '7d',
|
||||
label: 'Last 7 days',
|
||||
interval: '1d',
|
||||
},
|
||||
{
|
||||
key: '30d',
|
||||
label: 'Last 30 days',
|
||||
interval: '1d',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs_Shadcn_
|
||||
value={selectedMetric}
|
||||
onValueChange={(value) => setSelectedMetric(value as MetricType)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<TabsList_Shadcn_>
|
||||
{METRICS.map((metric) => (
|
||||
<TabsTrigger_Shadcn_ key={metric.id} value={metric.id}>
|
||||
{metric.label}
|
||||
</TabsTrigger_Shadcn_>
|
||||
))}
|
||||
</TabsList_Shadcn_>
|
||||
|
||||
<div className="h-80">
|
||||
<MetricsChart data={metricsData} metric={selectedMetric} isLoading={isLoadingMetrics} />
|
||||
</div>
|
||||
</Tabs_Shadcn_>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl text-foreground">Recent Queries</h3>
|
||||
<p className="text-sm text-foreground-light">Detailed view of recent query executions</p>
|
||||
</div>
|
||||
|
||||
<QueryList queries={queriesData || []} isLoading={isLoadingQueries} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import DatabaseSelector from 'components/ui/DatabaseSelector'
|
||||
import { DateRangePicker } from 'components/ui/DateRangePicker'
|
||||
import { MetricsOverview } from './components/MetricsOverview'
|
||||
import { QueryList } from './components/QueryList'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export type MetricType = 'rows_read' | 'rows_written' | 'query_latency' | 'queries_per_second'
|
||||
|
||||
const QueryInsightsPage = () => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const [selectedMetric, setSelectedMetric] = useState<MetricType>('rows_read')
|
||||
const [timeRange, setTimeRange] = useState({
|
||||
startDate: dayjs().subtract(24, 'hour'),
|
||||
endDate: dayjs(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<FormHeader
|
||||
className="py-4 px-6 !mb-0"
|
||||
title="Query Insights"
|
||||
description="Monitor and analyze query performance across your database"
|
||||
docsUrl="https://supabase.com/docs/guides/platform/performance"
|
||||
actions={<DatabaseSelector />}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col flex-grow">
|
||||
<MetricsOverview
|
||||
selectedMetric={selectedMetric}
|
||||
onMetricChange={setSelectedMetric}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
/>
|
||||
<QueryList timeRange={timeRange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QueryInsightsPage
|
||||
@@ -0,0 +1,214 @@
|
||||
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
|
||||
import { QueryInsightsMetric } from 'data/query-insights/query-insights-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Line, LineChart, XAxis, YAxis, CartesianGrid, Area, AreaChart } from 'recharts'
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface MetricsChartProps {
|
||||
data: QueryInsightsMetric[] | undefined
|
||||
metric: string
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const MetricsChart = ({ data, metric, isLoading }: MetricsChartProps) => {
|
||||
const [visibleMetrics, setVisibleMetrics] = useState({
|
||||
p50: true,
|
||||
p95: true,
|
||||
p99: true,
|
||||
p99_9: true,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-foreground-light">Loading metrics...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-foreground-light">No data available</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (metric === 'query_latency') {
|
||||
const chartData = data.map((point) => ({
|
||||
timestamp: dayjs(point.timestamp).format('HH:mm'),
|
||||
p50: point.p50,
|
||||
p95: point.p95,
|
||||
p99: point.p99,
|
||||
p99_9: point.p99_9,
|
||||
}))
|
||||
|
||||
const config = {
|
||||
p50: {
|
||||
label: 'p50',
|
||||
color: 'hsl(var(--brand-default))',
|
||||
},
|
||||
p95: {
|
||||
label: 'p95',
|
||||
color: 'hsl(var(--foreground-default))',
|
||||
},
|
||||
p99: {
|
||||
label: 'p99',
|
||||
color: 'hsl(var(--warning-default))',
|
||||
},
|
||||
p99_9: {
|
||||
label: 'p99.9',
|
||||
color: 'hsl(var(--destructive-default))',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(config).map(([key, value]) => {
|
||||
const metricValue = Number(
|
||||
chartData[chartData.length - 1]?.[key as keyof (typeof chartData)[0]]
|
||||
)
|
||||
const formattedValue = !isNaN(metricValue) ? `${Math.round(metricValue)} ms` : '0 ms'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={cn(
|
||||
'text-xs px-2 py-1 rounded-full transition-colors inline-flex items-center gap-1.5',
|
||||
visibleMetrics[key as keyof typeof visibleMetrics]
|
||||
? 'bg-surface-300'
|
||||
: 'hover:bg-surface-100'
|
||||
)}
|
||||
onClick={() =>
|
||||
setVisibleMetrics((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key as keyof typeof visibleMetrics],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: value.color }} />
|
||||
<span>
|
||||
{value.label}: {formattedValue}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ChartContainer className="h-[300px] w-full" config={config}>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
width={100}
|
||||
height={100}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
{Object.entries(config).map(([key, value]) => (
|
||||
<linearGradient key={key} id={`gradient-${key}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={value.color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={value.color} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={50}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<YAxis />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" hideLabel />}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{visibleMetrics.p50 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="p50"
|
||||
fill={config.p50.color}
|
||||
fillOpacity={0.4}
|
||||
stroke={config.p50.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={100}
|
||||
/>
|
||||
)}
|
||||
{visibleMetrics.p95 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="p95"
|
||||
fill={config.p95.color}
|
||||
fillOpacity={0.4}
|
||||
stroke={config.p95.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={100}
|
||||
/>
|
||||
)}
|
||||
{visibleMetrics.p99 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="p99"
|
||||
fill={config.p99.color}
|
||||
fillOpacity={0.4}
|
||||
stroke={config.p99.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={100}
|
||||
/>
|
||||
)}
|
||||
{visibleMetrics.p99_9 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="p99_9"
|
||||
fill={config.p99_9.color}
|
||||
fillOpacity={0.4}
|
||||
stroke={config.p99_9.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={100}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For other metrics, use the bar chart
|
||||
const chartData = data.map((point) => ({
|
||||
timestamp: dayjs(point.timestamp).toISOString(),
|
||||
ok_count: point.value ?? 0,
|
||||
warning_count: 0,
|
||||
error_count: 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<LogsBarChart
|
||||
data={chartData}
|
||||
DateTimeFormat="HH:mm"
|
||||
EmptyState={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-foreground-light">No data available</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react'
|
||||
import { Line, XAxis, YAxis, Tooltip, Area, ComposedChart } from 'recharts'
|
||||
import { ChartContainer, ChartConfig } from 'components/ui/Charts'
|
||||
import { CHART_COLORS } from 'components/ui/Charts/Charts.constants'
|
||||
import dayjs from 'dayjs'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export interface QueryInsightsChartDatum {
|
||||
timestamp: string
|
||||
p50: number
|
||||
p95: number
|
||||
p99: number
|
||||
p999: number
|
||||
}
|
||||
|
||||
export const QueryInsightsLineChart = ({
|
||||
data,
|
||||
onPointClick,
|
||||
EmptyState,
|
||||
DateTimeFormat = 'MMM D, HH:mm',
|
||||
}: {
|
||||
data: QueryInsightsChartDatum[]
|
||||
onPointClick?: (datum: QueryInsightsChartDatum, tooltipData?: Record<string, any>) => void
|
||||
EmptyState?: React.ReactNode
|
||||
DateTimeFormat?: string
|
||||
}) => {
|
||||
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
|
||||
|
||||
if (data.length === 0) {
|
||||
if (EmptyState) return EmptyState
|
||||
return null
|
||||
}
|
||||
|
||||
const startDate = dayjs(data[0]['timestamp']).format(DateTimeFormat)
|
||||
const endDate = dayjs(data[data?.length - 1]?.['timestamp']).format(DateTimeFormat)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-3')}>
|
||||
<ChartContainer
|
||||
config={
|
||||
{
|
||||
p50: {
|
||||
label: 'p50',
|
||||
color: CHART_COLORS.GREEN_1,
|
||||
},
|
||||
p95: {
|
||||
label: 'p95',
|
||||
color: CHART_COLORS.BLUE_1,
|
||||
},
|
||||
p99: {
|
||||
label: 'p99',
|
||||
color: '#925AFF',
|
||||
},
|
||||
p999: {
|
||||
label: 'p99.9',
|
||||
color: '#FF8B3E',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
}
|
||||
className="h-[400px]"
|
||||
>
|
||||
<ComposedChart
|
||||
data={data}
|
||||
onMouseMove={(e: { activeTooltipIndex: number | null }) => {
|
||||
if (e.activeTooltipIndex !== focusDataIndex) {
|
||||
setFocusDataIndex(e.activeTooltipIndex)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setFocusDataIndex(null)}
|
||||
onClick={(tooltipData: Record<string, any>) => {
|
||||
const datum = tooltipData?.activePayload?.[0]?.payload
|
||||
if (onPointClick) onPointClick(datum, tooltipData)
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="p95Gradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={CHART_COLORS.BLUE_1} stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor={CHART_COLORS.BLUE_1} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={(v) => dayjs(v).format(DateTimeFormat)}
|
||||
stroke={CHART_COLORS.AXIS}
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => `${v}ms`}
|
||||
stroke={CHART_COLORS.AXIS}
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(v) => dayjs(v).format(DateTimeFormat)}
|
||||
formatter={(value: number) => [`${value}ms`]}
|
||||
/>
|
||||
|
||||
<Area type="monotone" dataKey="p95" stroke="none" fill="url(#p95Gradient)" />
|
||||
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p50"
|
||||
stroke={CHART_COLORS.GREEN_1}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p95"
|
||||
stroke={CHART_COLORS.BLUE_1}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
<Line type="monotone" dataKey="p99" stroke="#925AFF" strokeWidth={1.5} dot={false} />
|
||||
<Line type="monotone" dataKey="p999" stroke="#FF8B3E" strokeWidth={1.5} dot={false} />
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
{data && (
|
||||
<div className="text-foreground-lighter -mt-10 flex items-center justify-between text-[10px] font-mono">
|
||||
<span>{startDate}</span>
|
||||
<span>{endDate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clock, Database } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { Button } from 'ui'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
interface Query {
|
||||
query_id: string
|
||||
query: string
|
||||
total_time: number
|
||||
calls: number
|
||||
rows: number
|
||||
shared_blks_read: number
|
||||
shared_blks_hit: number
|
||||
mean_exec_time: number
|
||||
database: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface QueryListProps {
|
||||
queries?: Query[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export const QueryList = ({ queries = [], isLoading = false }: QueryListProps) => {
|
||||
const [expandedQueries, setExpandedQueries] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleQueryExpansion = (queryId: string) => {
|
||||
const newExpanded = new Set(expandedQueries)
|
||||
if (newExpanded.has(queryId)) {
|
||||
newExpanded.delete(queryId)
|
||||
} else {
|
||||
newExpanded.add(queryId)
|
||||
}
|
||||
setExpandedQueries(newExpanded)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-pulse">Loading queries...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (queries.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-foreground-light">
|
||||
No queries found for the selected time range
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{queries.map((query) => (
|
||||
<div
|
||||
key={query.query_id}
|
||||
className="rounded-md border border-default bg-surface-100 overflow-hidden"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1 flex-grow">
|
||||
<div className="font-mono text-xs text-foreground-light break-all">
|
||||
{expandedQueries.has(query.query_id) ? (
|
||||
query.query
|
||||
) : (
|
||||
<>
|
||||
{query.query.slice(0, 100)}
|
||||
{query.query.length > 100 && '...'}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 text-xs text-foreground-light">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Database strokeWidth={1.5} size={14} />
|
||||
<span>{query.database}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Clock strokeWidth={1.5} size={14} />
|
||||
<span>{dayjs(query.timestamp).fromNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{query.query.length > 100 && (
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
onClick={() => toggleQueryExpansion(query.query_id)}
|
||||
>
|
||||
{expandedQueries.has(query.query_id) ? 'Show less' : 'Show more'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-foreground-light mb-1">Total time</div>
|
||||
<div className="text-sm">{query.total_time.toFixed(2)}ms</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-foreground-light mb-1">Calls</div>
|
||||
<div className="text-sm">{query.calls}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-foreground-light mb-1">Rows</div>
|
||||
<div className="text-sm">
|
||||
{query.rows >= 1000 ? `${(query.rows / 1000).toFixed(1)}k` : query.rows}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-foreground-light mb-1">Cache hit ratio</div>
|
||||
<div className="text-sm">
|
||||
{(
|
||||
(query.shared_blks_hit / (query.shared_blks_hit + query.shared_blks_read)) *
|
||||
100
|
||||
).toFixed(1)}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,12 @@ export const generateAdvisorsMenu = (
|
||||
url: `/project/${ref}/advisors/query-performance`,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Query Insights',
|
||||
key: 'query-insights',
|
||||
url: `/project/${ref}/advisors/query`,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
...(IS_PLATFORM && features?.advisorRules
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useParams } from 'common'
|
||||
|
||||
export const queryInsightsKeys = {
|
||||
metrics: (projectRef: string | undefined, metric: string, startTime: string, endTime: string) =>
|
||||
['query-insights', 'metrics', projectRef, metric, startTime, endTime] as const,
|
||||
queries: (projectRef: string | undefined, startTime: string, endTime: string) =>
|
||||
['query-insights', 'queries', projectRef, startTime, endTime] as const,
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { executeSql } from '../sql/execute-sql-query'
|
||||
import { queryInsightsKeys } from './keys'
|
||||
|
||||
export type QueryInsightsMetric = {
|
||||
timestamp: string
|
||||
value?: number
|
||||
database: string
|
||||
rows?: number
|
||||
shared_blks_read?: number
|
||||
shared_blks_hit?: number
|
||||
mean_exec_time?: number
|
||||
total_exec_time?: number
|
||||
p50?: number
|
||||
p95?: number
|
||||
p99?: number
|
||||
p99_9?: number
|
||||
}
|
||||
|
||||
export type QueryInsightsQuery = {
|
||||
query_id: string
|
||||
query: string
|
||||
total_time: number
|
||||
calls: number
|
||||
rows: number
|
||||
shared_blks_read: number
|
||||
shared_blks_hit: number
|
||||
mean_exec_time: number
|
||||
database: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export type QueryInsightsMetrics = {
|
||||
queries: {
|
||||
total: number
|
||||
total_time: number
|
||||
avg_time: number
|
||||
total_rows: number
|
||||
}
|
||||
cache: {
|
||||
hit_ratio: number
|
||||
hits: number
|
||||
misses: number
|
||||
}
|
||||
errors: {
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
const getMetricsSql = (metric: string, startTime: string, endTime: string) => {
|
||||
switch (metric) {
|
||||
case 'rows_read':
|
||||
return /* SQL */ `
|
||||
SELECT
|
||||
bucket_start_time as timestamp,
|
||||
SUM(rows_retrieved) as value,
|
||||
datname as database
|
||||
FROM pg_stat_monitor
|
||||
WHERE bucket_start_time >= '${startTime}'
|
||||
AND bucket_start_time <= '${endTime}'
|
||||
GROUP BY bucket_start_time, datname
|
||||
ORDER BY bucket_start_time ASC
|
||||
`
|
||||
case 'rows_written':
|
||||
return /* SQL */ `
|
||||
SELECT
|
||||
bucket_start_time as timestamp,
|
||||
SUM(rows_retrieved) as value,
|
||||
datname as database
|
||||
FROM pg_stat_monitor
|
||||
WHERE bucket_start_time >= '${startTime}'
|
||||
AND bucket_start_time <= '${endTime}'
|
||||
GROUP BY bucket_start_time, datname
|
||||
ORDER BY bucket_start_time ASC
|
||||
`
|
||||
case 'query_latency':
|
||||
return /* SQL */ `
|
||||
SELECT
|
||||
bucket_start_time as timestamp,
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY mean_exec_time) as p50,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY mean_exec_time) as p95,
|
||||
percentile_cont(0.99) WITHIN GROUP (ORDER BY mean_exec_time) as p99,
|
||||
percentile_cont(0.999) WITHIN GROUP (ORDER BY mean_exec_time) as p99_9,
|
||||
datname as database
|
||||
FROM pg_stat_monitor
|
||||
WHERE bucket_start_time >= '${startTime}'
|
||||
AND bucket_start_time <= '${endTime}'
|
||||
GROUP BY bucket_start_time, datname
|
||||
ORDER BY bucket_start_time ASC
|
||||
`
|
||||
case 'queries_per_second':
|
||||
return /* SQL */ `
|
||||
SELECT
|
||||
bucket_start_time as timestamp,
|
||||
COUNT(*) as value,
|
||||
datname as database
|
||||
FROM pg_stat_monitor
|
||||
WHERE bucket_start_time >= '${startTime}'
|
||||
AND bucket_start_time <= '${endTime}'
|
||||
GROUP BY bucket_start_time, datname
|
||||
ORDER BY bucket_start_time ASC
|
||||
`
|
||||
default:
|
||||
return /* SQL */ `
|
||||
SELECT
|
||||
bucket_start_time as timestamp,
|
||||
SUM(rows_retrieved) as value,
|
||||
datname as database
|
||||
FROM pg_stat_monitor
|
||||
WHERE bucket_start_time >= '${startTime}'
|
||||
AND bucket_start_time <= '${endTime}'
|
||||
GROUP BY bucket_start_time, datname
|
||||
ORDER BY bucket_start_time ASC
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
const getQueriesSql = (startTime: string, endTime: string) => /* SQL */ `
|
||||
SELECT
|
||||
queryid as query_id,
|
||||
query,
|
||||
total_exec_time as total_time,
|
||||
calls,
|
||||
rows_retrieved as rows,
|
||||
shared_blks_read,
|
||||
shared_blks_hit,
|
||||
mean_exec_time,
|
||||
datname as database,
|
||||
bucket_start_time as timestamp
|
||||
FROM pg_stat_monitor
|
||||
WHERE bucket_start_time >= '${startTime}'
|
||||
AND bucket_start_time <= '${endTime}'
|
||||
ORDER BY calls DESC
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
export function useQueryInsightsMetrics(
|
||||
projectRef: string | undefined,
|
||||
metric: string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
options?: UseQueryOptions<QueryInsightsMetric[]>
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryInsightsKeys.metrics(projectRef, metric, startTime, endTime),
|
||||
queryFn: async () => {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
sql: getMetricsSql(metric, startTime, endTime),
|
||||
})
|
||||
|
||||
return result as QueryInsightsMetric[]
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function useQueryInsightsQueries(
|
||||
projectRef: string | undefined,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
options?: UseQueryOptions<QueryInsightsQuery[]>
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryInsightsKeys.queries(projectRef, startTime, endTime),
|
||||
queryFn: async () => {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
sql: getQueriesSql(startTime, endTime),
|
||||
})
|
||||
|
||||
return result as QueryInsightsQuery[]
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { QueryInsights } from 'components/interfaces/QueryInsights/QueryInsights'
|
||||
import AdvisorsLayout from 'components/layouts/AdvisorsLayout/AdvisorsLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import DatabaseSelector from 'components/ui/DatabaseSelector'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const QueryInsightsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<FormHeader
|
||||
className="py-4 px-6 !mb-0"
|
||||
title="Query Insights"
|
||||
description="Monitor and analyze query performance with enhanced metrics"
|
||||
docsUrl="https://supabase.com/docs/guides/platform/performance"
|
||||
actions={<DatabaseSelector />}
|
||||
/>
|
||||
<QueryInsights />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
QueryInsightsPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AdvisorsLayout title="Query insights">{page}</AdvisorsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default QueryInsightsPage
|
||||
Reference in new issue
Block a user