mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: initial supamonitor changes (#42313)
Add a Query Performance page implementation powered by [supamonitor](https://github.com/supabase/supamonitor). [Context](https://linear.app/supabase/project/build-extension-for-supabase-query-insights-df4fb145352c/overview) This looks largely the same as the pg_stat_monitor implementation: <img width="2556" height="960" alt="Screenshot 2026-02-12 at 7 35 47 PM" src="https://github.com/user-attachments/assets/bf37466e-f7af-41f2-b4f2-cf8eb6a8c76f" /> Only available on projects on custom AMI - existing users are unaffected <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Supamonitor-based query performance view: charts, aggregated metrics, date-range controls, and export/download. * Added "Application" column for per-application tracking. * Interactive Supamonitor grid: sorting, filtering, keyboard navigation, selection, retry/error handling. * Automatic per-project Supamonitor detection with toggleable UI integration. * **Bug Fixes** * Chart latency calculation prefers histogram data for more accurate p95. * **Documentation** * Minor blog formatting fix. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: kemal <hello@kemal.earth> Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
19 files changed
+800
-505
No files matched your search
@@ -1,56 +0,0 @@
|
||||
import { cn } from 'ui'
|
||||
|
||||
interface QueryCostsProps {
|
||||
currentCost?: number
|
||||
improvedCost?: number
|
||||
improvement?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const QueryCosts = ({
|
||||
currentCost,
|
||||
improvedCost,
|
||||
improvement,
|
||||
className,
|
||||
}: QueryCostsProps) => {
|
||||
if (!currentCost) return null
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-4', className)}>
|
||||
<h3 className="text-sm">Query costs</h3>
|
||||
<div className="flex flex-col gap-y-2 rounded bg-surface-100 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-foreground-light">Total cost of query</p>
|
||||
<div className="flex flex-col items-end gap-y-1">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<p className="text-sm text-foreground-light">Currently:</p>
|
||||
<p className="font-mono text-sm">
|
||||
{typeof currentCost === 'number' && !isNaN(currentCost) && isFinite(currentCost)
|
||||
? currentCost.toFixed(2)
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
{improvedCost &&
|
||||
typeof improvedCost === 'number' &&
|
||||
!isNaN(improvedCost) &&
|
||||
isFinite(improvedCost) && (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<p className="text-sm text-foreground-light">With index:</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="font-mono text-sm">{improvedCost.toFixed(2)}</p>
|
||||
{improvement &&
|
||||
typeof improvement === 'number' &&
|
||||
!isNaN(improvement) &&
|
||||
isFinite(improvement) && (
|
||||
<p className="text-sm text-brand">↓ {improvement.toFixed(1)}%</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-sm text-brand hover:text-brand-600 transition">View more</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,10 +12,7 @@ import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button, cn
|
||||
|
||||
import { QueryPanelContainer, QueryPanelSection } from './QueryPanel'
|
||||
import { buildQueryExplanationPrompt } from './QueryPerformance.ai'
|
||||
import {
|
||||
QUERY_PERFORMANCE_COLUMNS,
|
||||
QUERY_PERFORMANCE_REPORT_TYPES,
|
||||
} from './QueryPerformance.constants'
|
||||
import { QUERY_PERFORMANCE_COLUMNS } from './QueryPerformance.constants'
|
||||
import { QueryPerformanceRow } from './QueryPerformance.types'
|
||||
import { formatDuration } from './QueryPerformance.utils'
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export const QUERY_PERFORMANCE_COLUMNS = [
|
||||
{ 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: 'Application', description: undefined, minWidth: 150 },
|
||||
] as const
|
||||
|
||||
export const QUERY_PERFORMANCE_ROLE_DESCRIPTION = [
|
||||
@@ -88,46 +89,40 @@ export const QUERY_PERFORMANCE_CHART_TABS = [
|
||||
},
|
||||
]
|
||||
|
||||
export const QUERY_PERFORMANCE_TIME_RANGES = [
|
||||
{
|
||||
id: 'last_60_minutes',
|
||||
label: 'Last 60 minutes',
|
||||
},
|
||||
{
|
||||
id: 'last_3_hours',
|
||||
label: 'Last 3 hours',
|
||||
},
|
||||
{
|
||||
id: 'last_24_hours',
|
||||
label: 'Last 24 hours',
|
||||
},
|
||||
]
|
||||
|
||||
export const getPgStatMonitorLogsQuery = (startTime: string, endTime: string) =>
|
||||
export const getSupamonitorLogsQuery = (startTime: string, endTime: string) =>
|
||||
`
|
||||
select
|
||||
id,
|
||||
pgl.timestamp as timestamp,
|
||||
'postgres' as log_type,
|
||||
CAST(pgl_parsed.sql_state_code AS STRING) as status,
|
||||
CASE
|
||||
WHEN pgl_parsed.error_severity = 'LOG' THEN 'success'
|
||||
WHEN pgl_parsed.error_severity = 'WARNING' THEN 'warning'
|
||||
WHEN pgl_parsed.error_severity = 'FATAL' THEN 'error'
|
||||
WHEN pgl_parsed.error_severity = 'ERROR' THEN 'error'
|
||||
ELSE null
|
||||
END as level,
|
||||
event_message as event_message
|
||||
from postgres_logs as pgl
|
||||
cross join unnest(pgl.metadata) as pgl_metadata
|
||||
cross join unnest(pgl_metadata.parsed) as pgl_parsed
|
||||
WHERE pgl.event_message LIKE '%[pg_stat_monitor]%'
|
||||
AND pgl.timestamp >= CAST('${startTime}' AS TIMESTAMP)
|
||||
AND pgl.timestamp <= CAST('${endTime}' AS TIMESTAMP)
|
||||
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()
|
||||
|
||||
export const PG_STAT_MONITOR_LOGS_QUERY = getPgStatMonitorLogsQuery(
|
||||
new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
new Date().toISOString()
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { WithMonitor } from './WithMonitor/WithMonitor'
|
||||
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,7 +11,7 @@ interface QueryPerformanceProps {
|
||||
queryHitRate: PresetHookResult
|
||||
queryPerformanceQuery: DbQueryHook<any>
|
||||
queryMetrics: PresetHookResult
|
||||
isPgStatMonitorEnabled: boolean
|
||||
isSupamonitorEnabled: boolean
|
||||
dateRange?: {
|
||||
period_start: { date: string; time_period: string }
|
||||
period_end: { date: string; time_period: string }
|
||||
@@ -24,7 +24,7 @@ export const QueryPerformance = ({
|
||||
queryHitRate,
|
||||
queryPerformanceQuery,
|
||||
queryMetrics,
|
||||
isPgStatMonitorEnabled,
|
||||
isSupamonitorEnabled,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
}: QueryPerformanceProps) => {
|
||||
@@ -36,8 +36,8 @@ export const QueryPerformance = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref])
|
||||
|
||||
if (isPgStatMonitorEnabled) {
|
||||
return <WithMonitor dateRange={dateRange} onDateRangeChange={onDateRangeChange} />
|
||||
if (isSupamonitorEnabled) {
|
||||
return <WithSupamonitor dateRange={dateRange} onDateRangeChange={onDateRangeChange} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,8 +11,48 @@ export interface QueryPerformanceRow {
|
||||
rows_read: number
|
||||
cache_hit_rate: number
|
||||
rolname: string
|
||||
application_name?: string
|
||||
index_advisor_result?: GetIndexAdvisorResultResponse | null
|
||||
_total_cache_hits?: number
|
||||
_total_cache_misses?: number
|
||||
_count?: number
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatDuration } from './QueryPerformance.utils'
|
||||
import { calculatePercentilesFromHistogram } from './WithMonitor/WithMonitor.utils'
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('should format seconds', () => {
|
||||
@@ -23,23 +22,3 @@ describe('formatDuration', () => {
|
||||
expect(formatDuration(90061000)).toBe('1d 1h 1m 1s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePercentilesFromHistogram', () => {
|
||||
it('should return zero for empty histogram', () => {
|
||||
const result = calculatePercentilesFromHistogram([])
|
||||
expect(result.p95).toBe(0)
|
||||
})
|
||||
|
||||
it('should return valid p95 for typical distribution', () => {
|
||||
const result = calculatePercentilesFromHistogram([10, 20, 30, 20, 10, 10])
|
||||
expect(result.p95).toBeGreaterThan(0)
|
||||
expect(result.p95).toBeGreaterThanOrEqual(result.p50)
|
||||
})
|
||||
|
||||
it('should return consistent p95 for same input', () => {
|
||||
const histogram = [10, 20, 30, 20, 10, 10]
|
||||
const result1 = calculatePercentilesFromHistogram(histogram)
|
||||
const result2 = calculatePercentilesFromHistogram(histogram)
|
||||
expect(result1.p95).toBe(result2.p95)
|
||||
})
|
||||
})
|
||||
@@ -28,22 +28,11 @@ export const formatDuration = (milliseconds: number) => {
|
||||
return parts.length > 0 ? parts.join(' ') : '0s'
|
||||
}
|
||||
|
||||
export const transformLogsToJSON = (log: string) => {
|
||||
try {
|
||||
let jsonString = log.replace('[pg_stat_monitor] ', '')
|
||||
jsonString = jsonString.replace(/""/g, '","')
|
||||
const jsonObject = JSON.parse(jsonString)
|
||||
return jsonObject
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type QueryPerformanceErrorContext = {
|
||||
projectRef?: string
|
||||
databaseIdentifier?: string
|
||||
queryPreset?: string
|
||||
queryType?: 'hitRate' | 'metrics' | 'mainQuery' | 'monitor' | 'slowQueriesCount'
|
||||
queryType?: 'hitRate' | 'metrics' | 'mainQuery' | 'slowQueriesCount' | 'supamonitor'
|
||||
sql?: string
|
||||
errorMessage?: string
|
||||
postgresVersion?: string
|
||||
|
||||
@@ -4,8 +4,7 @@ import { QUERY_PERFORMANCE_CHART_TABS } from './QueryPerformance.constants'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { ComposedChart } from 'components/ui/Charts/ComposedChart'
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
import type { ChartDataPoint } from './WithMonitor/WithMonitor.utils'
|
||||
import { calculatePercentilesFromHistogram } from './WithMonitor/WithMonitor.utils'
|
||||
import type { ChartDataPoint } from './QueryPerformance.types'
|
||||
|
||||
interface QueryPerformanceChartProps {
|
||||
dateRange?: {
|
||||
@@ -62,33 +61,11 @@ export const QueryPerformanceChart = ({
|
||||
|
||||
switch (selectedMetric) {
|
||||
case 'query_latency': {
|
||||
let trueP95: number = 0
|
||||
|
||||
if (parsedLogs && parsedLogs.length > 0) {
|
||||
const bucketCount = parsedLogs[0]?.resp_calls?.length || 50
|
||||
const combinedHistogram = new Array(bucketCount).fill(0)
|
||||
|
||||
parsedLogs.forEach((log) => {
|
||||
if (log.resp_calls && Array.isArray(log.resp_calls)) {
|
||||
log.resp_calls.forEach((count: number, index: number) => {
|
||||
if (index < combinedHistogram.length) {
|
||||
combinedHistogram[index] += count
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// [kemal]: this might need a revisit
|
||||
const percentiles = calculatePercentilesFromHistogram(combinedHistogram)
|
||||
trueP95 = percentiles.p95
|
||||
} else {
|
||||
// [kemal]: fallback to weighted average
|
||||
const totalCalls = chartData.reduce((sum, d) => sum + d.calls, 0)
|
||||
trueP95 =
|
||||
totalCalls > 0
|
||||
? chartData.reduce((sum, d) => sum + d.p95_time * d.calls, 0) / totalCalls
|
||||
: 0
|
||||
}
|
||||
const totalCalls = chartData.reduce((sum, d) => sum + d.calls, 0)
|
||||
const trueP95 =
|
||||
totalCalls > 0
|
||||
? chartData.reduce((sum, d) => sum + d.p95_time * d.calls, 0) / totalCalls
|
||||
: 0
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -167,12 +144,7 @@ export const QueryPerformanceChart = ({
|
||||
>()
|
||||
|
||||
queryLogs.forEach((log) => {
|
||||
const timestamps = [log.bucket_start_time, log.bucket, log.timestamp, log.ts]
|
||||
const validTimestamp = timestamps.find((t) => t && !isNaN(new Date(t).getTime()))
|
||||
|
||||
if (!validTimestamp) return
|
||||
|
||||
const time = new Date(validTimestamp).getTime()
|
||||
const time = new Date(log.timestamp).getTime()
|
||||
const meanTime = log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0
|
||||
const rowsRead = log.rows_read ?? log.rows ?? 0
|
||||
const calls = log.calls ?? 0
|
||||
@@ -258,8 +230,14 @@ export const QueryPerformanceChart = ({
|
||||
|
||||
const baseAttributes = attributeMap[selectedMetric] || []
|
||||
|
||||
// Add selected query line based on current metric
|
||||
if (currentSelectedQuery && querySpecificData) {
|
||||
const dimmedBaseAttributes = baseAttributes.map((attr) => ({
|
||||
...attr,
|
||||
color: attr.color
|
||||
? { light: attr.color.light + '4D', dark: attr.color.dark + '4D' }
|
||||
: attr.color,
|
||||
}))
|
||||
|
||||
const selectedQueryAttributes: Record<string, MultiAttribute> = {
|
||||
query_latency: {
|
||||
attribute: 'selected_query_time',
|
||||
@@ -297,7 +275,7 @@ export const QueryPerformanceChart = ({
|
||||
|
||||
const selectedQueryAttr = selectedQueryAttributes[selectedMetric]
|
||||
if (selectedQueryAttr) {
|
||||
return [...baseAttributes, selectedQueryAttr]
|
||||
return [...dimmedBaseAttributes, selectedQueryAttr]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,12 +338,7 @@ export const QueryPerformanceChart = ({
|
||||
hideHighlightArea={true}
|
||||
showTooltip={true}
|
||||
showGrid={true}
|
||||
showLegend={
|
||||
selectedMetric === 'query_latency' ||
|
||||
selectedMetric === 'cache_hits' ||
|
||||
selectedMetric === 'rows_read' ||
|
||||
selectedMetric === 'calls'
|
||||
}
|
||||
showLegend={true}
|
||||
showTotal={false}
|
||||
showMaxValue={false}
|
||||
updateDateRange={updateDateRange}
|
||||
|
||||
@@ -34,7 +34,6 @@ import { QueryDetail } from './QueryDetail'
|
||||
import { QueryIndexes } from './QueryIndexes'
|
||||
import {
|
||||
QUERY_PERFORMANCE_COLUMNS,
|
||||
QUERY_PERFORMANCE_REPORT_TYPES,
|
||||
QUERY_PERFORMANCE_ROLE_DESCRIPTION,
|
||||
} from './QueryPerformance.constants'
|
||||
import { QueryPerformanceRow } from './QueryPerformance.types'
|
||||
@@ -98,7 +97,6 @@ export const QueryPerformanceGrid = ({
|
||||
|
||||
const [view, setView] = useState<'details' | 'suggestion'>('details')
|
||||
const [selectedRow, setSelectedRow] = useState<number>()
|
||||
const reportType = QUERY_PERFORMANCE_REPORT_TYPES.UNIFIED
|
||||
|
||||
const columns = QUERY_PERFORMANCE_COLUMNS.map((col) => {
|
||||
const nonSortableColumns = ['query']
|
||||
@@ -341,6 +339,18 @@ export const QueryPerformanceGrid = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (col.id === 'application_name') {
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-center">
|
||||
{value ? (
|
||||
<p className="font-mono text-xs">{value}</p>
|
||||
) : (
|
||||
<p className="text-muted">–</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-y-0.5 justify-center text-xs">
|
||||
<p>{formattedValue}</p>
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { transformLogsToJSON } from '../QueryPerformance.utils'
|
||||
import { QueryPerformanceRow } from '../QueryPerformance.types'
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
||||
export interface ParsedLogEntry {
|
||||
bucket_start_time?: string
|
||||
bucket?: string
|
||||
timestamp?: string
|
||||
ts?: string
|
||||
mean_time?: number
|
||||
mean_exec_time?: number
|
||||
mean_query_time?: number
|
||||
min_time?: number
|
||||
min_exec_time?: number
|
||||
min_query_time?: number
|
||||
max_time?: number
|
||||
max_exec_time?: number
|
||||
max_query_time?: number
|
||||
stddev_time?: number
|
||||
stddev_exec_time?: number
|
||||
stddev_query_time?: number
|
||||
rows?: number
|
||||
calls?: number
|
||||
shared_blks_hit?: number
|
||||
shared_blks_read?: number
|
||||
query?: string
|
||||
userid?: string
|
||||
rolname?: string
|
||||
resp_calls?: number[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
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 const parsePgStatMonitorLogs = (logData: any[]): ParsedLogEntry[] => {
|
||||
if (!logData || logData.length === 0) return []
|
||||
|
||||
const validParsedLogs = logData
|
||||
.map((log) => ({
|
||||
...log,
|
||||
parsedEventMessage: transformLogsToJSON(log.event_message),
|
||||
}))
|
||||
.filter((log) => log.parsedEventMessage !== null)
|
||||
.filter((log) => log.parsedEventMessage?.event === 'bucket_query')
|
||||
|
||||
return validParsedLogs.map((log) => log.parsedEventMessage)
|
||||
}
|
||||
|
||||
export const transformLogsToChartData = (parsedLogs: ParsedLogEntry[]): ChartDataPoint[] => {
|
||||
if (!parsedLogs || parsedLogs.length === 0) return []
|
||||
|
||||
// [kemal]: here for debugging
|
||||
// if (parsedLogs.length > 0) {
|
||||
// console.log('🟡 Parsed logs:', parsedLogs)
|
||||
// }
|
||||
|
||||
return parsedLogs
|
||||
.map((log: ParsedLogEntry) => {
|
||||
const possibleTimestamps = [log.bucket_start_time, log.bucket, log.timestamp, log.ts]
|
||||
|
||||
let periodStart: number | null = null
|
||||
|
||||
for (const ts of possibleTimestamps) {
|
||||
if (ts) {
|
||||
const date = new Date(ts)
|
||||
const time = date.getTime()
|
||||
if (!isNaN(time) && time > 0 && time > 946684800000) {
|
||||
periodStart = time
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!periodStart) {
|
||||
return null
|
||||
}
|
||||
|
||||
const percentiles =
|
||||
log.resp_calls && Array.isArray(log.resp_calls)
|
||||
? calculatePercentilesFromHistogram(log.resp_calls)
|
||||
: { p50: 0, p95: 0 }
|
||||
|
||||
return {
|
||||
period_start: periodStart,
|
||||
timestamp: possibleTimestamps.find((t) => t) || '',
|
||||
query_latency: parseFloat(
|
||||
String(log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0)
|
||||
),
|
||||
mean_time: parseFloat(
|
||||
String(log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0)
|
||||
),
|
||||
min_time: parseFloat(String(log.min_time ?? log.min_exec_time ?? log.min_query_time ?? 0)),
|
||||
max_time: parseFloat(String(log.max_time ?? log.max_exec_time ?? log.max_query_time ?? 0)),
|
||||
stddev_time: parseFloat(
|
||||
String(log.stddev_time ?? log.stddev_exec_time ?? log.stddev_query_time ?? 0)
|
||||
),
|
||||
p50_time: percentiles.p50,
|
||||
p95_time: percentiles.p95,
|
||||
rows_read: parseInt(String(log.rows ?? 0), 10),
|
||||
calls: parseInt(String(log.calls ?? 0), 10),
|
||||
cache_hits: parseFloat(String(log.shared_blks_hit ?? 0)),
|
||||
cache_misses: parseFloat(String(log.shared_blks_read ?? 0)),
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
.sort((a, b) => a.period_start - b.period_start)
|
||||
}
|
||||
|
||||
const normalizeQuery = (query: string): string => {
|
||||
return query.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export const aggregateLogsByQuery = (parsedLogs: ParsedLogEntry[]): QueryPerformanceRow[] => {
|
||||
if (!parsedLogs || parsedLogs.length === 0) return []
|
||||
|
||||
const queryGroups = new Map<string, ParsedLogEntry[]>()
|
||||
|
||||
parsedLogs.forEach((log) => {
|
||||
const query = normalizeQuery(log.query || '')
|
||||
if (!query) return
|
||||
|
||||
if (!queryGroups.has(query)) {
|
||||
queryGroups.set(query, [])
|
||||
}
|
||||
queryGroups.get(query)!.push(log)
|
||||
})
|
||||
|
||||
const aggregatedData: QueryPerformanceRow[] = []
|
||||
let totalExecutionTime = 0
|
||||
|
||||
const queryStats = Array.from(queryGroups.entries()).map(([query, logs]) => {
|
||||
const count = logs.length
|
||||
let totalCalls = 0
|
||||
let totalRowsRead = 0
|
||||
let totalCacheHits = 0
|
||||
let totalCacheMisses = 0
|
||||
let rolname = logs[0].username
|
||||
let minTime = Infinity
|
||||
let maxTime = -Infinity
|
||||
let totalExecutionTimeForQuery = 0
|
||||
|
||||
logs.forEach((log) => {
|
||||
const logMeanTime = parseFloat(
|
||||
String(log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0)
|
||||
)
|
||||
const logMinTime = parseFloat(
|
||||
String(log.min_time ?? log.min_exec_time ?? log.min_query_time ?? 0)
|
||||
)
|
||||
const logMaxTime = parseFloat(
|
||||
String(log.max_time ?? log.max_exec_time ?? log.max_query_time ?? 0)
|
||||
)
|
||||
const logCalls = parseInt(String(log.calls ?? 0), 10)
|
||||
const logRows = parseInt(String(log.rows ?? 0), 10)
|
||||
const logCacheHits = parseFloat(String(log.shared_blks_hit ?? 0))
|
||||
const logCacheMisses = parseFloat(String(log.shared_blks_read ?? 0))
|
||||
|
||||
minTime = Math.min(minTime, logMinTime)
|
||||
maxTime = Math.max(maxTime, logMaxTime)
|
||||
totalCalls += logCalls
|
||||
totalRowsRead += logRows
|
||||
totalCacheHits += logCacheHits
|
||||
totalCacheMisses += logCacheMisses
|
||||
totalExecutionTimeForQuery += logMeanTime * logCalls
|
||||
})
|
||||
|
||||
// Overall mean time is the weighted average
|
||||
const avgMeanTime = totalCalls > 0 ? totalExecutionTimeForQuery / totalCalls : 0
|
||||
const finalMinTime = minTime === Infinity ? 0 : minTime
|
||||
const finalMaxTime = maxTime === -Infinity ? 0 : maxTime
|
||||
|
||||
totalExecutionTime += totalExecutionTimeForQuery
|
||||
|
||||
return {
|
||||
query,
|
||||
rolname,
|
||||
count,
|
||||
avgMeanTime,
|
||||
minTime: finalMinTime,
|
||||
maxTime: finalMaxTime,
|
||||
totalCalls,
|
||||
totalRowsRead,
|
||||
totalTime: totalExecutionTimeForQuery,
|
||||
totalCacheHits,
|
||||
totalCacheMisses,
|
||||
}
|
||||
})
|
||||
|
||||
queryStats.forEach((stats) => {
|
||||
const totalCacheAccess = stats.totalCacheHits + stats.totalCacheMisses
|
||||
const cacheHitRate = totalCacheAccess > 0 ? (stats.totalCacheHits / totalCacheAccess) * 100 : 0
|
||||
|
||||
const propTotalTime = totalExecutionTime > 0 ? (stats.totalTime / totalExecutionTime) * 100 : 0
|
||||
|
||||
aggregatedData.push({
|
||||
query: stats.query,
|
||||
rolname: stats.rolname,
|
||||
calls: stats.totalCalls,
|
||||
mean_time: stats.avgMeanTime,
|
||||
min_time: stats.minTime,
|
||||
max_time: stats.maxTime,
|
||||
total_time: stats.totalTime,
|
||||
rows_read: stats.totalRowsRead,
|
||||
cache_hit_rate: cacheHitRate,
|
||||
prop_total_time: propTotalTime,
|
||||
index_advisor_result: null,
|
||||
_total_cache_hits: stats.totalCacheHits,
|
||||
_total_cache_misses: stats.totalCacheMisses,
|
||||
_count: stats.count,
|
||||
})
|
||||
})
|
||||
|
||||
return aggregatedData.sort((a, b) => b.total_time - a.total_time)
|
||||
}
|
||||
|
||||
export const calculatePercentilesFromHistogram = (
|
||||
respCalls: number[]
|
||||
): {
|
||||
p50: number
|
||||
p95: number
|
||||
} => {
|
||||
const bucketBoundaries = [
|
||||
{ min: 0, max: 1 },
|
||||
{ min: 1, max: 10 },
|
||||
{ min: 10, max: 100 },
|
||||
{ min: 100, max: 1000 },
|
||||
{ min: 1000, max: 10000 },
|
||||
{ min: 10000, max: 100000 },
|
||||
]
|
||||
|
||||
const totalCalls = respCalls.reduce((sum, count) => sum + count, 0)
|
||||
|
||||
if (totalCalls === 0) {
|
||||
return { p50: 0, p95: 0 }
|
||||
}
|
||||
|
||||
const distribution: {
|
||||
minValue: number
|
||||
maxValue: number
|
||||
cumulativeCount: number
|
||||
count: number
|
||||
}[] = []
|
||||
let cumulativeCount = 0
|
||||
|
||||
respCalls.forEach((count, index) => {
|
||||
if (count > 0 && index < bucketBoundaries.length) {
|
||||
const bucket = bucketBoundaries[index]
|
||||
cumulativeCount += count
|
||||
distribution.push({
|
||||
minValue: bucket.min,
|
||||
maxValue: bucket.max,
|
||||
cumulativeCount,
|
||||
count,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const getPercentile = (percentile: number): number => {
|
||||
const targetCount = totalCalls * percentile
|
||||
|
||||
for (let i = 0; i < distribution.length; i++) {
|
||||
const prevCumulativeCount = i > 0 ? distribution[i - 1].cumulativeCount : 0
|
||||
|
||||
if (distribution[i].cumulativeCount >= targetCount) {
|
||||
const positionInBucket = (targetCount - prevCumulativeCount) / distribution[i].count
|
||||
const bucketMin = distribution[i].minValue
|
||||
const bucketMax = distribution[i].maxValue
|
||||
const logMin = Math.log10(Math.max(bucketMin, 0.1))
|
||||
const logMax = Math.log10(bucketMax)
|
||||
const logValue = logMin + positionInBucket * (logMax - logMin)
|
||||
|
||||
return Math.pow(10, logValue)
|
||||
}
|
||||
}
|
||||
|
||||
return distribution[distribution.length - 1]?.maxValue || 0
|
||||
}
|
||||
|
||||
const result = {
|
||||
p50: getPercentile(0.5),
|
||||
p95: getPercentile(0.95),
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { transformStatementDataToRows } from './WithStatements.utils'
|
||||
|
||||
vi.mock('../IndexAdvisor/index-advisor.utils', () => ({
|
||||
filterProtectedSchemaIndexAdvisorResult: vi.fn((result) => {
|
||||
if (result?._mock_filter_null) return null
|
||||
return result
|
||||
}),
|
||||
queryInvolvesProtectedSchemas: vi.fn((query: string) => {
|
||||
return query?.toLowerCase().includes('auth.')
|
||||
}),
|
||||
}))
|
||||
|
||||
const makeRow = (overrides: Record<string, any> = {}) => ({
|
||||
query: 'SELECT 1',
|
||||
rolname: 'postgres',
|
||||
calls: 10,
|
||||
mean_time: 5.0,
|
||||
min_time: 1.0,
|
||||
max_time: 20.0,
|
||||
total_time: 50.0,
|
||||
rows_read: 100,
|
||||
cache_hit_rate: 0.95,
|
||||
index_advisor_result: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('transformStatementDataToRows', () => {
|
||||
it('returns empty array for null or empty input', () => {
|
||||
expect(transformStatementDataToRows(null as any)).toEqual([])
|
||||
expect(transformStatementDataToRows([])).toEqual([])
|
||||
})
|
||||
|
||||
it('transforms basic rows correctly', () => {
|
||||
const data = [makeRow()]
|
||||
const result = transformStatementDataToRows(data)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
query: 'SELECT 1',
|
||||
rolname: 'postgres',
|
||||
calls: 10,
|
||||
mean_time: 5.0,
|
||||
min_time: 1.0,
|
||||
max_time: 20.0,
|
||||
total_time: 50.0,
|
||||
rows_read: 100,
|
||||
cache_hit_rate: 0.95,
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults missing numeric fields to 0', () => {
|
||||
const data = [{ query: 'SELECT 1' }]
|
||||
const result = transformStatementDataToRows(data)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].calls).toBe(0)
|
||||
expect(result[0].mean_time).toBe(0)
|
||||
expect(result[0].min_time).toBe(0)
|
||||
expect(result[0].max_time).toBe(0)
|
||||
expect(result[0].total_time).toBe(0)
|
||||
expect(result[0].rows_read).toBe(0)
|
||||
expect(result[0].cache_hit_rate).toBe(0)
|
||||
})
|
||||
|
||||
it('sets rolname to undefined when missing', () => {
|
||||
const data = [makeRow({ rolname: undefined })]
|
||||
const result = transformStatementDataToRows(data)
|
||||
expect(result[0].rolname).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calculates prop_total_time as percentage of total time', () => {
|
||||
const data = [
|
||||
makeRow({ query: 'Q1', total_time: 75 }),
|
||||
makeRow({ query: 'Q2', total_time: 25 }),
|
||||
]
|
||||
const result = transformStatementDataToRows(data)
|
||||
|
||||
expect(result[0].prop_total_time).toBe(75)
|
||||
expect(result[1].prop_total_time).toBe(25)
|
||||
})
|
||||
|
||||
it('handles prop_total_time when total is zero', () => {
|
||||
const data = [makeRow({ total_time: 0 })]
|
||||
const result = transformStatementDataToRows(data)
|
||||
expect(result[0].prop_total_time).toBe(0)
|
||||
})
|
||||
|
||||
it('applies index_advisor_result filtering', () => {
|
||||
const data = [
|
||||
makeRow({
|
||||
index_advisor_result: { index_statements: ['CREATE INDEX ON public.users (id)'] },
|
||||
}),
|
||||
]
|
||||
const result = transformStatementDataToRows(data)
|
||||
|
||||
expect(result[0].index_advisor_result).toEqual({
|
||||
index_statements: ['CREATE INDEX ON public.users (id)'],
|
||||
})
|
||||
})
|
||||
|
||||
it('sets index_advisor_result to null when source is null', () => {
|
||||
const data = [makeRow({ index_advisor_result: null })]
|
||||
const result = transformStatementDataToRows(data)
|
||||
expect(result[0].index_advisor_result).toBeNull()
|
||||
})
|
||||
|
||||
describe('filterIndexAdvisor mode', () => {
|
||||
it('keeps rows for non-protected schema queries', () => {
|
||||
const data = [makeRow({ query: 'SELECT * FROM public.users' })]
|
||||
const result = transformStatementDataToRows(data, true)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps protected-schema rows that have valid recommendations', () => {
|
||||
const data = [
|
||||
makeRow({
|
||||
query: 'SELECT * FROM auth.users',
|
||||
index_advisor_result: { index_statements: ['CREATE INDEX ON auth.users (id)'] },
|
||||
}),
|
||||
]
|
||||
const result = transformStatementDataToRows(data, true)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('filters out protected-schema rows with no valid recommendations', () => {
|
||||
const data = [
|
||||
makeRow({
|
||||
query: 'SELECT * FROM auth.users',
|
||||
index_advisor_result: { _mock_filter_null: true },
|
||||
}),
|
||||
]
|
||||
const result = transformStatementDataToRows(data, true)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not filter protected-schema rows when filterIndexAdvisor is false', () => {
|
||||
const data = [
|
||||
makeRow({
|
||||
query: 'SELECT * FROM auth.users',
|
||||
index_advisor_result: { _mock_filter_null: true },
|
||||
}),
|
||||
]
|
||||
const result = transformStatementDataToRows(data, false)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
+18
-17
@@ -6,12 +6,12 @@ import { useMemo, useState, useEffect } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import useLogsQuery from 'hooks/analytics/useLogsQuery'
|
||||
import { getPgStatMonitorLogsQuery } from '../QueryPerformance.constants'
|
||||
import { getSupamonitorLogsQuery } from '../QueryPerformance.constants'
|
||||
import {
|
||||
parsePgStatMonitorLogs,
|
||||
parseSupamonitorLogs,
|
||||
transformLogsToChartData,
|
||||
aggregateLogsByQuery,
|
||||
} from './WithMonitor.utils'
|
||||
} from './WithSupamonitor.utils'
|
||||
import { useParams } from 'common'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { captureQueryPerformanceError } from '../QueryPerformance.utils'
|
||||
@@ -21,7 +21,7 @@ import { getErrorMessage } from 'lib/get-error-message'
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
||||
interface WithMonitorProps {
|
||||
interface WithSupamonitorProps {
|
||||
dateRange?: {
|
||||
period_start: { date: string; time_period: string }
|
||||
period_end: { date: string; time_period: string }
|
||||
@@ -30,13 +30,12 @@ interface WithMonitorProps {
|
||||
onDateRangeChange?: (from: string, to: string) => void
|
||||
}
|
||||
|
||||
export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps) => {
|
||||
export const WithSupamonitor = ({ dateRange, onDateRangeChange }: WithSupamonitorProps) => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(null)
|
||||
|
||||
// [kemal]: Fetch pg_stat_monitor logs. This will need to change when we move to the actual extension.
|
||||
const effectiveDateRange = useMemo(() => {
|
||||
if (dateRange) {
|
||||
return {
|
||||
@@ -45,7 +44,6 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps)
|
||||
}
|
||||
}
|
||||
|
||||
// [kemal]: Fallback to default 24 hours
|
||||
const end = dayjs.utc()
|
||||
const start = end.subtract(24, 'hours')
|
||||
return {
|
||||
@@ -55,30 +53,33 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps)
|
||||
}, [dateRange])
|
||||
|
||||
const queryWithTimeRange = useMemo(() => {
|
||||
return getPgStatMonitorLogsQuery(
|
||||
return getSupamonitorLogsQuery(
|
||||
effectiveDateRange.iso_timestamp_start,
|
||||
effectiveDateRange.iso_timestamp_end
|
||||
)
|
||||
}, [effectiveDateRange])
|
||||
|
||||
const pgStatMonitorLogs = useLogsQuery(ref as string, {
|
||||
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 } = pgStatMonitorLogs
|
||||
const { logData, isLoading: isLogsLoading, error: logsError } = supamonitorLogs
|
||||
|
||||
const parsedLogs = useMemo(() => {
|
||||
return parsePgStatMonitorLogs(logData || [])
|
||||
const result = parseSupamonitorLogs(logData || [])
|
||||
return result
|
||||
}, [logData])
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return transformLogsToChartData(parsedLogs)
|
||||
const result = transformLogsToChartData(parsedLogs)
|
||||
return result
|
||||
}, [parsedLogs])
|
||||
|
||||
const aggregatedGridData = useMemo(() => {
|
||||
return aggregateLogsByQuery(parsedLogs)
|
||||
const result = aggregateLogsByQuery(parsedLogs)
|
||||
return result
|
||||
}, [parsedLogs])
|
||||
|
||||
const handleSelectQuery = (query: string) => {
|
||||
@@ -86,7 +87,7 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps)
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
pgStatMonitorLogs.runQuery()
|
||||
supamonitorLogs.runQuery()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,8 +96,8 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps)
|
||||
captureQueryPerformanceError(logsError, {
|
||||
projectRef: ref,
|
||||
databaseIdentifier: state.selectedDatabaseId,
|
||||
queryPreset: 'pg_stat_monitor',
|
||||
queryType: 'monitor',
|
||||
queryPreset: 'supamonitor',
|
||||
queryType: 'supamonitor',
|
||||
postgresVersion: project?.dbVersion,
|
||||
databaseType: state.selectedDatabaseId === ref ? 'primary' : 'read-replica',
|
||||
sql: queryWithTimeRange,
|
||||
@@ -120,7 +121,7 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps)
|
||||
actions={
|
||||
<DownloadResultsButton
|
||||
results={aggregatedGridData}
|
||||
fileName={`Supabase Query Performance Monitor (${ref})`}
|
||||
fileName={`Supabase Query Performance Supamonitor (${ref})`}
|
||||
align="end"
|
||||
/>
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
parseSupamonitorLogs,
|
||||
transformLogsToChartData,
|
||||
aggregateLogsByQuery,
|
||||
} from './WithSupamonitor.utils'
|
||||
import { ParsedLogEntry } from '../QueryPerformance.types'
|
||||
|
||||
const makeSampleLog = (overrides: Partial<ParsedLogEntry> = {}): any => ({
|
||||
timestamp: '2025-01-01T00:00:00Z',
|
||||
application_name: 'test_app',
|
||||
calls: 10,
|
||||
database_name: 'test_db',
|
||||
query: 'SELECT 1',
|
||||
query_id: 1,
|
||||
total_exec_time: 100,
|
||||
total_plan_time: 20,
|
||||
user_name: 'postgres',
|
||||
mean_exec_time: 10,
|
||||
mean_plan_time: 2,
|
||||
min_exec_time: 1,
|
||||
max_exec_time: 50,
|
||||
min_plan_time: 0.5,
|
||||
max_plan_time: 5,
|
||||
p50_exec_time: 8,
|
||||
p95_exec_time: 40,
|
||||
p50_plan_time: 1.5,
|
||||
p95_plan_time: 4,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('parseSupamonitorLogs', () => {
|
||||
it('returns empty array for null or empty input', () => {
|
||||
expect(parseSupamonitorLogs(null as any)).toEqual([])
|
||||
expect(parseSupamonitorLogs([])).toEqual([])
|
||||
})
|
||||
|
||||
it('parses log entries preserving all fields', () => {
|
||||
const raw = [makeSampleLog()]
|
||||
const result = parseSupamonitorLogs(raw)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
timestamp: '2025-01-01T00:00:00Z',
|
||||
application_name: 'test_app',
|
||||
calls: 10,
|
||||
database_name: 'test_db',
|
||||
query: 'SELECT 1',
|
||||
query_id: 1,
|
||||
total_exec_time: 100,
|
||||
total_plan_time: 20,
|
||||
user_name: 'postgres',
|
||||
mean_exec_time: 10,
|
||||
mean_plan_time: 2,
|
||||
min_exec_time: 1,
|
||||
max_exec_time: 50,
|
||||
min_plan_time: 0.5,
|
||||
max_plan_time: 5,
|
||||
p50_exec_time: 8,
|
||||
p95_exec_time: 40,
|
||||
p50_plan_time: 1.5,
|
||||
p95_plan_time: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles multiple log entries', () => {
|
||||
const raw = [makeSampleLog(), makeSampleLog({ query: 'SELECT 2', query_id: 2 })]
|
||||
const result = parseSupamonitorLogs(raw)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformLogsToChartData', () => {
|
||||
it('returns empty array for null or empty input', () => {
|
||||
expect(transformLogsToChartData(null as any)).toEqual([])
|
||||
expect(transformLogsToChartData([])).toEqual([])
|
||||
})
|
||||
|
||||
it('filters out entries with no timestamp', () => {
|
||||
const logs: ParsedLogEntry[] = [{ query: 'SELECT 1', calls: 5 }]
|
||||
const result = transformLogsToChartData(logs)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('filters out entries with invalid timestamp', () => {
|
||||
const logs: ParsedLogEntry[] = [{ timestamp: 'not-a-date', calls: 5 }]
|
||||
const result = transformLogsToChartData(logs)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('transforms a valid log entry into a chart data point', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{
|
||||
timestamp: '2025-01-01T00:00:00Z',
|
||||
mean_exec_time: 10,
|
||||
mean_plan_time: 2,
|
||||
min_exec_time: 1,
|
||||
max_exec_time: 50,
|
||||
min_plan_time: 0.5,
|
||||
max_plan_time: 5,
|
||||
p50_exec_time: 8,
|
||||
p95_exec_time: 40,
|
||||
p50_plan_time: 1.5,
|
||||
p95_plan_time: 4,
|
||||
calls: 10,
|
||||
},
|
||||
]
|
||||
|
||||
const result = transformLogsToChartData(logs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
period_start: new Date('2025-01-01T00:00:00Z').getTime(),
|
||||
timestamp: '2025-01-01T00:00:00Z',
|
||||
query_latency: 12, // 10 + 2
|
||||
mean_time: 10,
|
||||
min_time: 1.5, // 1 + 0.5
|
||||
max_time: 55, // 50 + 5
|
||||
stddev_time: 0,
|
||||
p50_time: 9.5, // 8 + 1.5
|
||||
p95_time: 44, // 40 + 4
|
||||
rows_read: 0,
|
||||
calls: 10,
|
||||
cache_hits: 0,
|
||||
cache_misses: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults missing numeric fields to 0', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{
|
||||
timestamp: '2025-06-01T12:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const result = transformLogsToChartData(logs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].query_latency).toBe(0)
|
||||
expect(result[0].calls).toBe(0)
|
||||
expect(result[0].min_time).toBe(0)
|
||||
expect(result[0].max_time).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts results by period_start ascending', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{ timestamp: '2025-01-03T00:00:00Z', mean_exec_time: 1 },
|
||||
{ timestamp: '2025-01-01T00:00:00Z', mean_exec_time: 2 },
|
||||
{ timestamp: '2025-01-02T00:00:00Z', mean_exec_time: 3 },
|
||||
]
|
||||
|
||||
const result = transformLogsToChartData(logs)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].timestamp).toBe('2025-01-01T00:00:00Z')
|
||||
expect(result[1].timestamp).toBe('2025-01-02T00:00:00Z')
|
||||
expect(result[2].timestamp).toBe('2025-01-03T00:00:00Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aggregateLogsByQuery', () => {
|
||||
it('returns empty array for null or empty input', () => {
|
||||
expect(aggregateLogsByQuery(null as any)).toEqual([])
|
||||
expect(aggregateLogsByQuery([])).toEqual([])
|
||||
})
|
||||
|
||||
it('skips entries with empty or whitespace-only queries', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{ query: '', calls: 5 },
|
||||
{ query: ' ', calls: 3 },
|
||||
]
|
||||
const result = aggregateLogsByQuery(logs)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('aggregates a single log entry correctly', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{
|
||||
query: 'SELECT 1',
|
||||
user_name: 'postgres',
|
||||
application_name: 'app',
|
||||
calls: 10,
|
||||
total_exec_time: 100,
|
||||
total_plan_time: 20,
|
||||
min_exec_time: 1,
|
||||
max_exec_time: 50,
|
||||
min_plan_time: 0.5,
|
||||
max_plan_time: 5,
|
||||
},
|
||||
]
|
||||
|
||||
const result = aggregateLogsByQuery(logs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].query).toBe('SELECT 1')
|
||||
expect(result[0].rolname).toBe('postgres')
|
||||
expect(result[0].application_name).toBe('app')
|
||||
expect(result[0].calls).toBe(10)
|
||||
expect(result[0].total_time).toBe(120)
|
||||
expect(result[0].mean_time).toBe(12)
|
||||
expect(result[0].min_time).toBe(1.5)
|
||||
expect(result[0].max_time).toBe(55)
|
||||
expect(result[0].prop_total_time).toBe(100)
|
||||
})
|
||||
|
||||
it('aggregates multiple entries for the same query', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{
|
||||
query: 'SELECT 1',
|
||||
user_name: 'postgres',
|
||||
calls: 5,
|
||||
total_exec_time: 50,
|
||||
total_plan_time: 10,
|
||||
min_exec_time: 2,
|
||||
max_exec_time: 20,
|
||||
min_plan_time: 1,
|
||||
max_plan_time: 3,
|
||||
},
|
||||
{
|
||||
query: 'SELECT 1',
|
||||
user_name: 'postgres',
|
||||
calls: 10,
|
||||
total_exec_time: 100,
|
||||
total_plan_time: 20,
|
||||
min_exec_time: 1,
|
||||
max_exec_time: 50,
|
||||
min_plan_time: 0.5,
|
||||
max_plan_time: 5,
|
||||
},
|
||||
]
|
||||
|
||||
const result = aggregateLogsByQuery(logs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].calls).toBe(15) // 5 + 10
|
||||
expect(result[0].total_time).toBe(180) // (50+10) + (100+20)
|
||||
expect(result[0].mean_time).toBe(12) // 180 / 15
|
||||
expect(result[0].min_time).toBe(1.5) // min(2+1, 1+0.5) = 1.5
|
||||
expect(result[0].max_time).toBe(55) // max(20+3, 50+5) = 55
|
||||
expect(result[0]._count).toBe(2) // 2 log entries
|
||||
})
|
||||
|
||||
it('normalizes whitespace differences in queries', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{ query: 'SELECT 1', calls: 5, total_exec_time: 50, total_plan_time: 0 },
|
||||
{ query: 'SELECT 1', calls: 3, total_exec_time: 30, total_plan_time: 0 },
|
||||
]
|
||||
|
||||
const result = aggregateLogsByQuery(logs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].calls).toBe(8)
|
||||
})
|
||||
|
||||
it('sorts results by total_time descending', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{ query: 'SELECT 1', calls: 1, total_exec_time: 10, total_plan_time: 0 },
|
||||
{ query: 'SELECT 2', calls: 1, total_exec_time: 100, total_plan_time: 0 },
|
||||
{ query: 'SELECT 3', calls: 1, total_exec_time: 50, total_plan_time: 0 },
|
||||
]
|
||||
|
||||
const result = aggregateLogsByQuery(logs)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].query).toBe('SELECT 2')
|
||||
expect(result[1].query).toBe('SELECT 3')
|
||||
expect(result[2].query).toBe('SELECT 1')
|
||||
})
|
||||
|
||||
it('calculates prop_total_time as percentage of total execution', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{ query: 'SELECT 1', calls: 1, total_exec_time: 75, total_plan_time: 0 },
|
||||
{ query: 'SELECT 2', calls: 1, total_exec_time: 25, total_plan_time: 0 },
|
||||
]
|
||||
|
||||
const result = aggregateLogsByQuery(logs)
|
||||
|
||||
expect(result[0].prop_total_time).toBe(75)
|
||||
expect(result[1].prop_total_time).toBe(25)
|
||||
})
|
||||
|
||||
it('handles zero calls gracefully (mean_time defaults to 0)', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{ query: 'SELECT 1', calls: 0, total_exec_time: 100, total_plan_time: 0 },
|
||||
]
|
||||
|
||||
const result = aggregateLogsByQuery(logs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mean_time).toBe(0)
|
||||
})
|
||||
|
||||
it('sets static fields correctly', () => {
|
||||
const logs: ParsedLogEntry[] = [
|
||||
{ query: 'SELECT 1', calls: 1, total_exec_time: 10, total_plan_time: 0 },
|
||||
]
|
||||
|
||||
const result = aggregateLogsByQuery(logs)
|
||||
|
||||
expect(result[0].rows_read).toBe(0)
|
||||
expect(result[0].cache_hit_rate).toBe(0)
|
||||
expect(result[0].index_advisor_result).toBeNull()
|
||||
expect(result[0]._total_cache_hits).toBe(0)
|
||||
expect(result[0]._total_cache_misses).toBe(0)
|
||||
})
|
||||
})
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { QueryPerformanceRow, ChartDataPoint, ParsedLogEntry } from '../QueryPerformance.types'
|
||||
|
||||
export function parseSupamonitorLogs(logData: any[]): ParsedLogEntry[] {
|
||||
if (!logData || logData.length === 0) return []
|
||||
|
||||
return logData.map((log) => ({
|
||||
timestamp: log.timestamp,
|
||||
application_name: log.application_name,
|
||||
calls: log.calls,
|
||||
database_name: log.database_name,
|
||||
query: log.query,
|
||||
query_id: log.query_id,
|
||||
total_exec_time: log.total_exec_time,
|
||||
total_plan_time: log.total_plan_time,
|
||||
user_name: log.user_name,
|
||||
mean_exec_time: log.mean_exec_time,
|
||||
mean_plan_time: log.mean_plan_time,
|
||||
min_exec_time: log.min_exec_time,
|
||||
max_exec_time: log.max_exec_time,
|
||||
min_plan_time: log.min_plan_time,
|
||||
max_plan_time: log.max_plan_time,
|
||||
p50_exec_time: log.p50_exec_time,
|
||||
p95_exec_time: log.p95_exec_time,
|
||||
p50_plan_time: log.p50_plan_time,
|
||||
p95_plan_time: log.p95_plan_time,
|
||||
}))
|
||||
}
|
||||
|
||||
export function transformLogsToChartData(parsedLogs: ParsedLogEntry[]): ChartDataPoint[] {
|
||||
if (!parsedLogs || parsedLogs.length === 0) return []
|
||||
|
||||
return parsedLogs
|
||||
.map((log: ParsedLogEntry) => {
|
||||
if (!log.timestamp) return null
|
||||
|
||||
const periodStart = new Date(log.timestamp).getTime()
|
||||
if (isNaN(periodStart)) return null
|
||||
|
||||
const meanExecTime = parseFloat(String(log.mean_exec_time ?? 0))
|
||||
const meanPlanTime = parseFloat(String(log.mean_plan_time ?? 0))
|
||||
const calls = parseInt(String(log.calls ?? 0), 10)
|
||||
|
||||
return {
|
||||
period_start: periodStart,
|
||||
timestamp: log.timestamp,
|
||||
query_latency: meanExecTime + meanPlanTime,
|
||||
mean_time: meanExecTime,
|
||||
min_time: (log.min_exec_time ?? 0) + (log.min_plan_time ?? 0),
|
||||
max_time: (log.max_exec_time ?? 0) + (log.max_plan_time ?? 0),
|
||||
stddev_time: 0,
|
||||
p50_time: (log.p50_exec_time ?? 0) + (log.p50_plan_time ?? 0),
|
||||
p95_time: (log.p95_exec_time ?? 0) + (log.p95_plan_time ?? 0),
|
||||
rows_read: 0,
|
||||
calls,
|
||||
cache_hits: 0,
|
||||
cache_misses: 0,
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
.sort((a, b) => a.period_start - b.period_start)
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string): string {
|
||||
return query.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function aggregateLogsByQuery(parsedLogs: ParsedLogEntry[]): QueryPerformanceRow[] {
|
||||
if (!parsedLogs || parsedLogs.length === 0) return []
|
||||
|
||||
const queryGroups = new Map<string, ParsedLogEntry[]>()
|
||||
|
||||
parsedLogs.forEach((log) => {
|
||||
const query = normalizeQuery(log.query || '')
|
||||
if (!query) return
|
||||
|
||||
if (!queryGroups.has(query)) {
|
||||
queryGroups.set(query, [])
|
||||
}
|
||||
queryGroups.get(query)!.push(log)
|
||||
})
|
||||
|
||||
const aggregatedData: QueryPerformanceRow[] = []
|
||||
let totalExecutionTime = 0
|
||||
|
||||
const queryStats = Array.from(queryGroups.entries()).map(([query, logs]) => {
|
||||
const count = logs.length
|
||||
let totalCalls = 0
|
||||
let totalExecTime = 0
|
||||
let totalPlanTime = 0
|
||||
let minTime = Infinity
|
||||
let maxTime = -Infinity
|
||||
const rolname = logs[0]?.user_name || ''
|
||||
const applicationName = logs[0]?.application_name || ''
|
||||
|
||||
logs.forEach((log) => {
|
||||
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 totalTime = totalExecTime + totalPlanTime
|
||||
const avgMeanTime = totalCalls > 0 ? totalTime / totalCalls : 0
|
||||
const finalMinTime = minTime === Infinity ? 0 : minTime
|
||||
const finalMaxTime = maxTime === -Infinity ? 0 : maxTime
|
||||
|
||||
totalExecutionTime += totalTime
|
||||
|
||||
return {
|
||||
query,
|
||||
rolname,
|
||||
applicationName,
|
||||
count,
|
||||
avgMeanTime,
|
||||
minTime: finalMinTime,
|
||||
maxTime: finalMaxTime,
|
||||
totalCalls,
|
||||
totalTime,
|
||||
}
|
||||
})
|
||||
|
||||
queryStats.forEach((stats) => {
|
||||
const propTotalTime = totalExecutionTime > 0 ? (stats.totalTime / totalExecutionTime) * 100 : 0
|
||||
|
||||
aggregatedData.push({
|
||||
query: stats.query,
|
||||
rolname: stats.rolname,
|
||||
application_name: stats.applicationName,
|
||||
calls: stats.totalCalls,
|
||||
mean_time: stats.avgMeanTime,
|
||||
min_time: stats.minTime,
|
||||
max_time: stats.maxTime,
|
||||
total_time: stats.totalTime,
|
||||
rows_read: 0,
|
||||
cache_hit_rate: 0,
|
||||
prop_total_time: propTotalTime,
|
||||
index_advisor_result: null,
|
||||
_total_cache_hits: 0,
|
||||
_total_cache_misses: 0,
|
||||
_count: stats.count,
|
||||
})
|
||||
})
|
||||
|
||||
return aggregatedData.sort((a, b) => b.total_time - a.total_time)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
return {
|
||||
isSupamonitorEnabled: isSupamonitorEnabled ?? false,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,6 @@ export const databaseKeys = {
|
||||
schema: string | undefined,
|
||||
table: string | undefined
|
||||
) => ['projects', projectRef, 'table-index-advisor', schema, table] as const,
|
||||
supamonitorEnabled: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'supamonitor-enabled'] as const,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import type { ResponseError, UseCustomQueryOptions } from 'types'
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
export type SupamonitorEnabledVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
}
|
||||
|
||||
export async function getSupamonitorEnabled({
|
||||
projectRef,
|
||||
connectionString,
|
||||
}: SupamonitorEnabledVariables) {
|
||||
const { result } = await executeSql<{ libraries: string }[]>({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `SELECT current_setting('shared_preload_libraries', true) AS libraries`,
|
||||
})
|
||||
|
||||
const libraries = result[0]?.libraries ?? ''
|
||||
return libraries.split(',').some((lib) => lib.trim() === 'supamonitor')
|
||||
}
|
||||
|
||||
export type SupamonitorEnabledData = Awaited<ReturnType<typeof getSupamonitorEnabled>>
|
||||
export type SupamonitorEnabledError = ResponseError
|
||||
|
||||
export const useSupamonitorEnabledQuery = <TData = SupamonitorEnabledData>(
|
||||
{ projectRef, connectionString }: SupamonitorEnabledVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<SupamonitorEnabledData, SupamonitorEnabledError, TData> = {}
|
||||
) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
|
||||
|
||||
return useQuery<SupamonitorEnabledData, SupamonitorEnabledError, TData>({
|
||||
queryKey: databaseKeys.supamonitorEnabled(projectRef),
|
||||
queryFn: () => getSupamonitorEnabled({ projectRef, connectionString }),
|
||||
enabled: enabled && typeof projectRef !== 'undefined' && isActive,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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 {
|
||||
@@ -27,6 +28,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
const { ref } = useParams()
|
||||
const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery()
|
||||
const { isIndexAdvisorEnabled } = useIndexAdvisorStatus()
|
||||
const { isSupamonitorEnabled } = useSupamonitorStatus()
|
||||
const { sort: sortConfig } = useQueryPerformanceSort()
|
||||
|
||||
const {
|
||||
@@ -76,8 +78,6 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
filterIndexAdvisor: indexAdvisor === 'true',
|
||||
})
|
||||
|
||||
const isPgStatMonitorEnabled = project?.dbVersion === '17.4.1.076-psml-1'
|
||||
|
||||
if (!isLoadingProject && !project) {
|
||||
return (
|
||||
<div className="h-full flex flex-col p-6">
|
||||
@@ -99,7 +99,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
href={`${DOCS_URL}/guides/platform/performance#examining-query-performance`}
|
||||
/>
|
||||
<DatabaseSelector />
|
||||
{isPgStatMonitorEnabled && (
|
||||
{isSupamonitorEnabled && (
|
||||
<LogsDatePicker
|
||||
value={datePickerValue}
|
||||
helpers={datePickerHelpers.filter(
|
||||
@@ -117,7 +117,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
queryHitRate={queryHitRate}
|
||||
queryPerformanceQuery={queryPerformanceQuery}
|
||||
queryMetrics={queryMetrics}
|
||||
isPgStatMonitorEnabled={isPgStatMonitorEnabled}
|
||||
isSupamonitorEnabled={isSupamonitorEnabled}
|
||||
dateRange={selectedDateRange}
|
||||
onDateRangeChange={updateDateRange}
|
||||
/>
|
||||
|
||||
Whitespace-only changes.
Reference in new issue
Block a user