added rows read section

This commit is contained in:
Jonathan Summers-Muir committed 2025-04-09 12:49:41 +08:00
1 parent 767e6de695
commit 89ee1f5537
6 files changed
+550 -57

No files matched your search

@@ -0,0 +1,11 @@
/**
* Formats a latency value in milliseconds to a human-readable string
* with appropriate units (μs, ms, or s) based on the magnitude.
*/
export function formatLatency(value: number): string
/**
* Formats large numbers with thousands separators for better readability
* Used for metrics like row counts, query calls, and other statistical values.
*/
export function formatMetricValue(value: number): string
@@ -0,0 +1,29 @@
/**
* Formats a latency value in milliseconds to a human-readable string
* with appropriate units (μs, ms, or s) based on the magnitude.
*
* @param value - The latency value in milliseconds
* @returns Formatted string with appropriate unit
*/
export function formatLatency(value: number): string {
if (value === 0) return '0 ms'
if (value < 1) return `${(value * 1000).toFixed(2)} μs`
if (value < 1000) return `${value.toFixed(2)} ms`
return `${(value / 1000).toFixed(2)} s`
}
/**
* Formats large numbers with thousands separators for better readability
* Used for metrics like row counts, query calls, and other statistical values
* that can grow to large numbers.
*
* Examples:
* - 1000 -> "1,000"
* - 1000000 -> "1,000,000"
*
* @param value - The number to format
* @returns Formatted string with thousands separators
*/
export function formatMetricValue(value: number): string {
return new Intl.NumberFormat('en-US').format(value)
}
@@ -16,7 +16,9 @@ import { useParams } from 'common'
import { Button } from 'ui'
import { MetricType } from '../QueryInsights'
import { useSingleQueryLatency } from 'data/query-insights/single-query-latency-query'
import { useSingleQueryRows } from 'data/query-insights/single-query-latency-query'
import { motion, AnimatePresence } from 'framer-motion'
import { formatLatency, formatMetricValue } from '../QueryInsights.utils'
interface MetricsChartProps {
data: QueryInsightsMetric[]
@@ -24,7 +26,15 @@ interface MetricsChartProps {
isLoading: boolean
startTime: string
endTime: string
selectedQuery?: QueryInsightsQuery | null
selectedQuery: QueryInsightsQuery | null
}
interface ChartConfigType {
[key: string]: {
label: string
color: string
formatter: (value: number) => string
}
}
export function MetricsChart({
@@ -36,12 +46,22 @@ export function MetricsChart({
selectedQuery,
}: MetricsChartProps) {
const { ref } = useParams()
const [visibleMetrics, setVisibleMetrics] = useState({
const [visibleMetrics, setVisibleMetrics] = useState<{
p50: boolean
p95: boolean
p99: boolean
p99_9: boolean
query_latency: boolean
rows: boolean
query_rows: boolean
}>({
p50: true,
p95: true,
p99: true,
p99_9: true,
query_latency: true,
rows: true,
query_rows: true,
})
// Fetch single query latency data if a query is selected
@@ -49,8 +69,17 @@ export function MetricsChart({
data: queryLatencyData,
isLoading: isLoadingQueryLatency,
error: queryLatencyError,
} = useSingleQueryLatency(ref, selectedQuery?.query_id, startTime, endTime, {
enabled: !!selectedQuery,
} = useSingleQueryLatency(ref, selectedQuery?.query_id?.toString(), startTime, endTime, {
enabled: !!selectedQuery && metric === 'query_latency',
})
// Fetch single query rows data if a query is selected
const {
data: queryRowsData,
isLoading: isLoadingQueryRows,
error: queryRowsError,
} = useSingleQueryRows(ref, selectedQuery?.query_id?.toString(), startTime, endTime, {
enabled: !!selectedQuery && metric === 'rows_read',
})
if (isLoading) {
@@ -85,6 +114,178 @@ export function MetricsChart({
)
}
if (metric === 'rows_read') {
// Create the baseline chart data from the metrics
const chartData = data.map((point) => ({
timestamp: point.timestamp,
rows: Number(point.value) ?? 0,
query_rows: 0,
}))
// Sort chart data by timestamp
chartData.sort((a, b) => dayjs(a.timestamp).diff(dayjs(b.timestamp)))
// If we have query rows data, add it to the chart
if (selectedQuery && queryRowsData && queryRowsData.length > 0) {
// Create a map of timestamps to query rows values
const queryRowsMap = new Map<string, number>()
queryRowsData.forEach((point) => {
queryRowsMap.set(point.timestamp, Number(point.value) || 0)
})
// Add query rows data to each chart data point
chartData.forEach((point) => {
if (queryRowsMap.has(point.timestamp)) {
point.query_rows = queryRowsMap.get(point.timestamp)!
}
})
// Add any missing query rows points
queryRowsData.forEach((point) => {
if (!chartData.find((d) => d.timestamp === point.timestamp)) {
chartData.push({
timestamp: point.timestamp,
rows: 0,
query_rows: Number(point.value) || 0,
})
}
})
// Re-sort chart data by timestamp
chartData.sort((a, b) => dayjs(a.timestamp).diff(dayjs(b.timestamp)))
}
const config: ChartConfigType = {
rows: {
label: 'Rows Read',
color: '#3ECF8E',
formatter: formatMetricValue,
},
query_rows: {
label: 'Query Rows',
color: '#9BA6B0',
formatter: formatMetricValue,
},
}
return (
<div className="h-[320px] flex flex-col">
<div className="flex flex-col flex-1 min-h-0">
<div className="flex gap-2 px-5 mb-4">
<AnimatePresence>
{Object.entries(config).map(([key, value]) => {
// Only show query rows button if we have a selected query
if (key === 'query_rows' && !selectedQuery) {
return null
}
const metricValue = Number(
chartData[chartData.length - 1]?.[key as keyof (typeof chartData)[0]]
)
const formattedValue = !isNaN(metricValue) ? value.formatter(metricValue) : '0'
return (
<motion.button
key={key}
initial={{ opacity: 0, scale: 0.95, x: -8 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.95, x: -8 }}
transition={{ duration: 0.15 }}
className={cn(
'text-xs px-2 py-1 rounded-full transition-colors inline-flex items-center gap-1.5 border',
visibleMetrics[key as keyof typeof visibleMetrics]
? 'bg-surface-300'
: 'hover:bg-surface-100 border-transparent'
)}
onClick={() =>
setVisibleMetrics((prev) => ({
...prev,
[key]: !prev[key as keyof typeof visibleMetrics],
}))
}
>
<div
className={cn(
'w-2 h-2 rounded-full',
!visibleMetrics[key as keyof typeof visibleMetrics] && 'opacity-50'
)}
style={{ backgroundColor: value.color }}
/>
<span>
{value.label}: {formattedValue}
</span>
</motion.button>
)
})}
</AnimatePresence>
</div>
<div className="flex-1 min-h-0">
<ChartContainer className="h-full w-full" config={config}>
<AreaChart
data={chartData}
style={{ width: '100%', height: '100%' }}
margin={{ right: 16 }}
>
<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}
tickFormatter={(value) => dayjs(value).format('HH:mm')}
/>
<YAxis
tickFormatter={formatMetricValue}
domain={['dataMin', 'dataMax']}
allowDataOverflow={false}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" hideLabel />}
/>
<ChartLegend content={<ChartLegendContent />} />
{visibleMetrics.rows && (
<Area
type="monotone"
dataKey="rows"
fill={`url(#gradient-rows)`}
stroke={config.rows.color}
strokeWidth={1}
dot={false}
animationDuration={100}
fillOpacity={selectedQuery ? 0.1 : 0.3}
strokeOpacity={selectedQuery ? 0.3 : 1}
/>
)}
{selectedQuery && visibleMetrics.query_rows && (
<Area
type="monotone"
dataKey="query_rows"
fill={`url(#gradient-query_rows)`}
stroke={config.query_rows.color}
strokeWidth={2}
dot={false}
animationDuration={100}
/>
)}
</AreaChart>
</ChartContainer>
</div>
</div>
</div>
)
}
if (metric === 'query_latency') {
// Create the baseline chart data from the metrics
const chartData = data.map((point) => {
@@ -142,25 +343,30 @@ export function MetricsChart({
const config = {
p50: {
label: 'p50',
color: 'hsl(var(--brand-default))',
color: '#4B5563',
formatter: formatLatency,
},
p95: {
label: 'p95',
color: 'hsl(var(--foreground-default))',
color: '#6B7280',
formatter: formatLatency,
},
p99: {
label: 'p99',
color: 'hsl(var(--warning-default))',
color: '#9CA3AF',
formatter: formatLatency,
},
p99_9: {
label: 'p99.9',
color: 'hsl(var(--destructive-default))',
color: '#D1D5DB',
formatter: formatLatency,
},
query_latency: {
label: 'Query Latency',
color: 'hsl(var(--foreground-default))',
label: 'Query latency',
color: '#3B82F6',
formatter: formatLatency,
},
} satisfies ChartConfig
} satisfies ChartConfigType
return (
<div className="h-[320px] flex flex-col">
@@ -270,7 +476,13 @@ export function MetricsChart({
tickMargin={8}
tickFormatter={(value) => dayjs(value).format('HH:mm')}
/>
<YAxis />
<YAxis
tickFormatter={formatMetricValue}
width={80}
tickCount={5}
scale="linear"
domain={['auto', 'auto']}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" hideLabel />}
@@ -284,7 +496,7 @@ export function MetricsChart({
fillOpacity={selectedQuery && visibleMetrics.query_latency ? 0.05 : 0.4}
stroke={config.p50.color}
strokeOpacity={selectedQuery && visibleMetrics.query_latency ? 0.1 : 0.8}
strokeWidth={2}
strokeWidth={1}
dot={false}
animationDuration={100}
/>
@@ -297,7 +509,7 @@ export function MetricsChart({
fillOpacity={selectedQuery && visibleMetrics.query_latency ? 0.05 : 0.4}
stroke={config.p95.color}
strokeOpacity={selectedQuery && visibleMetrics.query_latency ? 0.1 : 0.8}
strokeWidth={2}
strokeWidth={1}
dot={false}
animationDuration={100}
/>
@@ -310,7 +522,7 @@ export function MetricsChart({
fillOpacity={selectedQuery && visibleMetrics.query_latency ? 0.05 : 0.4}
stroke={config.p99.color}
strokeOpacity={selectedQuery && visibleMetrics.query_latency ? 0.1 : 0.8}
strokeWidth={2}
strokeWidth={1}
dot={false}
animationDuration={100}
/>
@@ -323,7 +535,7 @@ export function MetricsChart({
fillOpacity={selectedQuery && visibleMetrics.query_latency ? 0.05 : 0.4}
stroke={config.p99_9.color}
strokeOpacity={selectedQuery && visibleMetrics.query_latency ? 0.1 : 0.8}
strokeWidth={2}
strokeWidth={1}
dot={false}
animationDuration={100}
/>
@@ -357,25 +569,160 @@ export function MetricsChart({
error_count: 0,
}))
const toggleButtons = () => {
switch (metric as MetricType) {
case 'query_latency':
return (
<>
<Button
type="text"
className={cn(
'flex items-center gap-2 px-3',
visibleMetrics.p50 ? 'opacity-100' : 'opacity-50'
)}
onClick={() => setVisibleMetrics((prev) => ({ ...prev, p50: !prev.p50 }))}
>
<div className="h-2 w-2 rounded-full bg-[#4B5563]" />
<span className="text-xs">p50: {formatLatency(data[data.length - 1]?.p50 ?? 0)}</span>
</Button>
<Button
type="text"
className={cn(
'flex items-center gap-2 px-3',
visibleMetrics.p95 ? 'opacity-100' : 'opacity-50'
)}
onClick={() => setVisibleMetrics((prev) => ({ ...prev, p95: !prev.p95 }))}
>
<div className="h-2 w-2 rounded-full bg-[#6B7280]" />
<span className="text-xs">p95: {formatLatency(data[data.length - 1]?.p95 ?? 0)}</span>
</Button>
<Button
type="text"
className={cn(
'flex items-center gap-2 px-3',
visibleMetrics.p99 ? 'opacity-100' : 'opacity-50'
)}
onClick={() => setVisibleMetrics((prev) => ({ ...prev, p99: !prev.p99 }))}
>
<div className="h-2 w-2 rounded-full bg-[#9CA3AF]" />
<span className="text-xs">p99: {formatLatency(data[data.length - 1]?.p99 ?? 0)}</span>
</Button>
<Button
type="text"
className={cn(
'flex items-center gap-2 px-3',
visibleMetrics.p99_9 ? 'opacity-100' : 'opacity-50'
)}
onClick={() => setVisibleMetrics((prev) => ({ ...prev, p99_9: !prev.p99_9 }))}
>
<div className="h-2 w-2 rounded-full bg-[#D1D5DB]" />
<span className="text-xs">
p99.9: {formatLatency(data[data.length - 1]?.p99_9 ?? 0)}
</span>
</Button>
{selectedQuery && (
<Button
type="text"
className={cn(
'flex items-center gap-2 px-3',
visibleMetrics.query_latency ? 'opacity-100' : 'opacity-50'
)}
onClick={() =>
setVisibleMetrics((prev) => ({ ...prev, query_latency: !prev.query_latency }))
}
>
<div className="h-2 w-2 rounded-full bg-[#3B82F6]" />
<span className="text-xs">
Query latency:{' '}
{formatLatency(queryLatencyData?.[queryLatencyData.length - 1]?.value ?? 0)}
</span>
</Button>
)}
</>
)
case 'rows_read':
return (
<>
<Button
type="text"
className={cn(
'flex items-center gap-2 px-3',
visibleMetrics.rows ? 'opacity-100' : 'opacity-50'
)}
onClick={() => setVisibleMetrics((prev) => ({ ...prev, rows: !prev.rows }))}
>
<div className="h-2 w-2 rounded-full bg-[#10B981]" />
<span className="text-xs">
Rows: {formatMetricValue(data[data.length - 1]?.value ?? 0)}
</span>
</Button>
{selectedQuery && (
<Button
type="text"
className={cn(
'flex items-center gap-2 px-3',
visibleMetrics.query_rows ? 'opacity-100' : 'opacity-50'
)}
onClick={() =>
setVisibleMetrics((prev) => ({ ...prev, query_rows: !prev.query_rows }))
}
>
<div className="h-2 w-2 rounded-full bg-[#3B82F6]" />
<span className="text-xs">
Query rows:{' '}
{formatMetricValue(queryRowsData?.[queryRowsData.length - 1]?.value ?? 0)}
</span>
</Button>
)}
</>
)
case 'rows_written':
case 'queries_per_second':
return (
<Button
type="text"
className={cn(
'flex items-center gap-2 px-3',
visibleMetrics.rows ? 'opacity-100' : 'opacity-50'
)}
onClick={() => setVisibleMetrics((prev) => ({ ...prev, rows: !prev.rows }))}
>
<div className="h-2 w-2 rounded-full bg-[#10B981]" />
<span className="text-xs">
Value: {formatMetricValue(data[data.length - 1]?.value ?? 0)}
</span>
</Button>
)
default:
return null
}
}
return (
<div className="h-[320px]">
<motion.div
className="h-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<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>
}
/>
</motion.div>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 px-4">{toggleButtons()}</div>
<div className="h-[320px]">
<motion.div
className="h-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<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>
}
/>
</motion.div>
</div>
</div>
)
}
@@ -123,13 +123,71 @@ export const QueryList = ({ queries, isLoading, onQuerySelect, selectedQuery }:
),
},
{
key: 'rows',
name: 'Rows',
key: 'rows_read',
name: 'Rows Read',
resizable: true,
minWidth: 100,
headerCellClass: '',
renderCell: (props) => (
<div className="font-mono text-xs text-right">{props.row.rows.toLocaleString()}</div>
<div
className={cn(
'font-mono text-xs text-right',
selectedQuery && selectedQuery.query_id !== props.row.query_id && 'opacity-50'
)}
>
{((props.row.rows_read ?? 0) > 0 ? props.row.rows_read : 0).toLocaleString()}
</div>
),
},
{
key: 'rows_insert',
name: 'Rows Insert',
resizable: true,
minWidth: 100,
headerCellClass: '',
renderCell: (props) => (
<div
className={cn(
'font-mono text-xs text-right',
selectedQuery && selectedQuery.query_id !== props.row.query_id && 'opacity-50'
)}
>
{((props.row.rows_insert ?? 0) > 0 ? props.row.rows_insert : 0).toLocaleString()}
</div>
),
},
{
key: 'rows_update',
name: 'Rows Update',
resizable: true,
minWidth: 100,
headerCellClass: '',
renderCell: (props) => (
<div
className={cn(
'font-mono text-xs text-right',
selectedQuery && selectedQuery.query_id !== props.row.query_id && 'opacity-50'
)}
>
{((props.row.rows_update ?? 0) > 0 ? props.row.rows_update : 0).toLocaleString()}
</div>
),
},
{
key: 'rows_delete',
name: 'Rows Delete',
resizable: true,
minWidth: 100,
headerCellClass: '',
renderCell: (props) => (
<div
className={cn(
'font-mono text-xs text-right',
selectedQuery && selectedQuery.query_id !== props.row.query_id && 'opacity-50'
)}
>
{((props.row.rows_delete ?? 0) > 0 ? props.row.rows_delete : 0).toLocaleString()}
</div>
),
},
// {
@@ -5,8 +5,9 @@ import { queryInsightsKeys } from './keys'
export type QueryInsightsMetric = {
timestamp: string
value?: number
database: string
rows?: number
cmd_type_text?: string
database: string
shared_blks_read?: number
shared_blks_hit?: number
mean_exec_time?: number
@@ -22,7 +23,10 @@ export type QueryInsightsQuery = {
query: string
total_time: number
calls: number
rows: number
rows_read: number
rows_insert: number
rows_update: number
rows_delete: number
shared_blks_read: number
shared_blks_hit: number
mean_exec_time: number
@@ -54,28 +58,29 @@ const getMetricsSql = (metric: string, startTime: string, endTime: string) => {
return /* SQL */ `
SELECT
bucket_start_time as timestamp,
SUM(rows) / 300 as value, -- Convert to per-second rate (5 mins = 300 seconds)
SUM(rows) as value,
datname as database
FROM pg_stat_monitor
WHERE bucket_start_time >= '${startTime}'::timestamptz
AND bucket_start_time <= '${endTime}'::timestamptz
AND bucket_done = true -- Only include completed buckets
GROUP BY bucket_start_time, datname
ORDER BY timestamp ASC
`
case 'rows_written':
return /* SQL */ `
SELECT
bucket_start_time as timestamp,
SUM(rows) / 300 as value, -- Convert to per-second rate (5 mins = 300 seconds)
datname as database
FROM pg_stat_monitor
WHERE bucket_start_time >= '${startTime}'::timestamptz
AND bucket_start_time <= '${endTime}'::timestamptz
AND bucket_done = true -- Only include completed buckets
AND bucket_done = true
AND cmd_type = 1
GROUP BY bucket_start_time, datname
ORDER BY timestamp ASC
`
// case 'rows_written':
// return /* SQL */ `
// SELECT
// bucket_start_time as timestamp,
// SUM(rows) / 300 as value, -- Convert to per-second rate (5 mins = 300 seconds)
// datname as database
// FROM pg_stat_monitor
// WHERE bucket_start_time >= '${startTime}'::timestamptz
// AND bucket_start_time <= '${endTime}'::timestamptz
// AND bucket_done = true -- Only include completed buckets
// GROUP BY bucket_start_time, datname
// ORDER BY timestamp ASC
// `
case 'query_latency':
return /* SQL */ `
SELECT
@@ -127,7 +132,10 @@ const getQueriesSql = (startTime: string, endTime: string) => /* SQL */ `
COALESCE(MAX(top_query), MAX(query)) as query,
SUM(total_exec_time) as total_time,
SUM(calls) as calls,
SUM(rows) as rows,
SUM(CASE WHEN cmd_type = 1 THEN rows ELSE 0 END) as rows_read,
SUM(CASE WHEN cmd_type = 2 THEN rows ELSE 0 END) as rows_insert,
SUM(CASE WHEN cmd_type = 3 THEN rows ELSE 0 END) as rows_update,
SUM(CASE WHEN cmd_type = 4 THEN rows ELSE 0 END) as rows_delete,
SUM(shared_blks_read) as shared_blks_read,
SUM(shared_blks_hit) as shared_blks_hit,
SUM(total_exec_time) / NULLIF(SUM(calls), 0) as mean_exec_time,
@@ -137,9 +145,9 @@ const getQueriesSql = (startTime: string, endTime: string) => /* SQL */ `
FROM pg_stat_monitor
WHERE bucket_start_time >= '${startTime}'
AND bucket_start_time <= '${endTime}'
AND bucket_done = true -- Only include completed buckets
AND bucket_done = true
GROUP BY queryid
ORDER BY SUM(calls) DESC
ORDER BY mean_exec_time DESC
LIMIT 100
`
@@ -30,6 +30,21 @@ const getSingleQueryLatencySql = (
ORDER BY timestamp ASC
`
const getSingleQueryRowsSql = (queryId: string, startTime: string, endTime: string) => /* SQL */ `
SELECT
bucket_start_time as timestamp,
SUM(rows) as value,
datname as database
FROM pg_stat_monitor
WHERE bucket_start_time >= '${startTime}'::timestamptz
AND bucket_start_time <= '${endTime}'::timestamptz
AND bucket_done = true
AND queryid = ${queryId}::bigint
AND cmd_type = 1
GROUP BY bucket_start_time, datname
ORDER BY timestamp ASC
`
export function useSingleQueryLatency(
projectRef: string | undefined,
queryId: string | undefined,
@@ -55,3 +70,28 @@ export function useSingleQueryLatency(
...options,
})
}
export function useSingleQueryRows(
projectRef: string | undefined,
queryId: string | undefined,
startTime: string,
endTime: string,
options?: UseQueryOptions<QueryInsightsMetric[]>
) {
return useQuery({
queryKey: [...singleQueryLatencyKeys.base, 'rows', projectRef, queryId, startTime, endTime],
queryFn: async () => {
if (!projectRef) throw new Error('Project ref is required')
if (!queryId) return []
const { result } = await executeSql({
projectRef,
sql: getSingleQueryRowsSql(queryId, startTime, endTime),
})
return result as QueryInsightsMetric[]
},
enabled: !!queryId && !!projectRef,
...options,
})
}