Files
supabase/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx
Joshen Lim 84484be46d Support multi select logs and add CTA (#45974)
## Context

Supports selecting log rows and allow to copy / ask assistant for
selected rows, similar to what we had for the old logs UI
Selection will clear whenever the search parameters change

<img width="1448" height="413" alt="image"
src="https://github.com/user-attachments/assets/b81b359c-28c3-48a8-9895-e77327ebd33e"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Multi-row selection with an action header showing selected count
  * Copy selected logs as JSON or Markdown from a dropdown
* "Explain with AI" action to open the assistant pre-filled with
selected logs
  * Clear selection button

* **Refactor**
* Row/detail selection now syncs with the URL for shareable views and
improves next/previous navigation and panel behavior

* **Style**
  * Minor visual tweak to column level indicator dot size

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45974)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-20 20:03:52 +07:00

597 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, ExternalLink, ScanSearch } from 'lucide-react'
import { useMemo, type RefObject } from 'react'
// eslint-disable-next-line no-restricted-imports
import { type Column, type DataGridHandle } from 'react-data-grid'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { buildQueryInsightFixPrompt } from '../../QueryPerformance/QueryPerformance.ai'
import { QUERY_PERFORMANCE_ROLE_DESCRIPTION } from '../../QueryPerformance/QueryPerformance.constants'
import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types'
import {
ISSUE_DOT_COLORS,
ISSUE_ICONS,
NON_SORTABLE_COLUMNS,
QUERY_INSIGHTS_EXPLORER_COLUMNS,
} from '../QueryInsightsTable/QueryInsightsTable.constants'
import {
formatDuration,
getColumnName,
getTableName,
} from '../QueryInsightsTable/QueryInsightsTable.utils'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
interface UseQueryInsightsTableColumnsParams {
sort: { column: string; order: 'asc' | 'desc' }
setSort: (config: { column: string; order: 'asc' | 'desc' } | null) => void
timeConsumedWidth: number
triageQueryColWidth: number
gridRef: RefObject<DataGridHandle | null>
setSelectedRow: (idx: number) => void
setSelectedTriageRow: (idx: number | undefined) => void
setSheetView: (view: 'details' | 'indexes' | 'explain') => void
handleGoToLogs: () => void
handleAiSuggestedFix: (item: ClassifiedQuery) => void
}
export function useQueryInsightsTableColumns({
sort,
setSort,
timeConsumedWidth,
triageQueryColWidth,
gridRef,
setSelectedRow,
setSelectedTriageRow,
setSheetView,
handleGoToLogs,
handleAiSuggestedFix,
}: UseQueryInsightsTableColumnsParams): {
columns: Column<ClassifiedQuery>[]
triageColumns: Column<ClassifiedQuery>[]
} {
const columns = useMemo(() => {
return QUERY_INSIGHTS_EXPLORER_COLUMNS.map((col) => {
const isSortable = !NON_SORTABLE_COLUMNS.includes(col.id as never)
const result: Column<ClassifiedQuery> = {
key: col.id,
name: col.name,
cellClass: `column-${col.id}`,
resizable: true,
minWidth: col.id === 'prop_total_time' ? timeConsumedWidth : (col.minWidth ?? 120),
sortable: isSortable,
headerCellClass: 'first:pl-6 cursor-pointer',
renderHeaderCell: () => {
return (
<div className="flex items-center justify-between text-xs w-full">
<div className="flex items-center gap-x-2">
<p className="text-foreground! font-medium">{col.name}</p>
{col.description && (
<p className="text-foreground-lighter font-normal">{col.description}</p>
)}
</div>
{isSortable && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
size="tiny"
className="p-1 h-5 w-5 shrink-0"
icon={<ChevronDown size={14} className="text-foreground-muted" />}
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => setSort({ column: col.id, order: 'asc' })}
className={cn(
'flex gap-2',
sort?.column === col.id && sort?.order === 'asc' && 'text-foreground'
)}
>
<ArrowUp size={14} />
Sort Ascending
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setSort({ column: col.id, order: 'desc' })}
className={cn(
'flex gap-2',
sort?.column === col.id && sort?.order === 'desc' && 'text-foreground'
)}
>
<ArrowDown size={14} />
Sort Descending
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)
},
renderCell: (props) => {
const row = props.row
const value = row[col.id]
if (col.id === 'query') {
const IssueIcon = row.issueType ? ISSUE_ICONS[row.issueType] : null
return (
<div className="w-full flex items-center gap-x-3 group">
<div className="shrink-0 w-6">
{row.issueType && IssueIcon && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'h-6 w-6 rounded-full border flex items-center justify-center cursor-default',
ISSUE_DOT_COLORS[row.issueType]?.border,
ISSUE_DOT_COLORS[row.issueType]?.background
)}
>
<IssueIcon size={14} className={ISSUE_DOT_COLORS[row.issueType].color} />
</div>
</TooltipTrigger>
{row.hint && (
<TooltipContent side="top" className="max-w-[260px]">
{row.hint}
</TooltipContent>
)}
</Tooltip>
)}
</div>
<CodeBlock
language="pgsql"
className="bg-transparent! p-0! m-0! border-none! whitespace-nowrap! [&>code]:whitespace-nowrap! [&>code]:wrap-break-word overflow-visible! truncate! w-full! pr-20! pointer-events-none"
wrapperClassName="max-w-full! flex-1"
hideLineNumbers
hideCopy
value={typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''}
wrapLines={false}
/>
<ButtonTooltip
tooltip={{ content: { text: 'Query details' } }}
icon={<ArrowRight size={14} />}
size="tiny"
type="default"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setSelectedRow(props.rowIdx)
setSheetView('details')
gridRef.current?.scrollToCell({ idx: 0, rowIdx: props.rowIdx })
}}
className="p-1 shrink-0 -translate-x-2 group-hover:flex hidden"
/>
</div>
)
}
if (col.id === 'prop_total_time') {
const percentage = row.prop_total_time || 0
const totalTime = row.total_time || 0
const fillWidth = Math.min(percentage, 100)
return (
<div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
<div
className="absolute inset-0 bg-foreground transition-all duration-200 z-0"
style={{ width: `${fillWidth}%`, opacity: 0.04 }}
/>
{percentage && totalTime ? (
<span className="flex items-center justify-end gap-x-1.5">
<span
className={cn(percentage.toFixed(1) === '0.0' && 'text-foreground-lighter')}
>
{percentage.toFixed(1)}%
</span>
<span className="text-muted">/</span>
<span
className={cn(
formatDuration(totalTime) === '0ms' && 'text-foreground-lighter'
)}
>
{formatDuration(totalTime)}
</span>
</span>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
if (col.id === 'calls') {
return (
<div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
<p className={cn(value === 0 && 'text-foreground-lighter')}>
{value.toLocaleString()}
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
if (col.id === 'max_time' || col.id === 'mean_time' || col.id === 'min_time') {
return (
<div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
<p className={cn(value.toFixed(0) === '0' && 'text-foreground-lighter')}>
{Math.round(value).toLocaleString()}ms
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
if (col.id === 'rows_read') {
return (
<div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
<p className={cn(value === 0 && 'text-foreground-lighter')}>
{value.toLocaleString()}
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
if (col.id === 'cache_hit_rate') {
const num = typeof value === 'number' ? value : parseFloat(value ?? '0')
return (
<div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
{typeof num === 'number' && !isNaN(num) && isFinite(num) ? (
<p className={cn(num.toFixed(2) === '0.00' && 'text-foreground-lighter')}>
{num.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
%
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
if (col.id === 'rolname') {
return (
<div className="w-full flex flex-col justify-center">
{value ? (
<span className="flex items-center gap-x-1">
<p className="font-mono text-xs">{value}</p>
<InfoTooltip align="end" alignOffset={-12} className="w-56">
{
QUERY_PERFORMANCE_ROLE_DESCRIPTION.find((r) => r.name === value)
?.description
}
</InfoTooltip>
</span>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
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">&ndash;</p>
)}
</div>
)
}
return null
},
}
return result
})
}, [sort, setSort, timeConsumedWidth, gridRef, setSelectedRow, setSheetView])
const triageColumns = useMemo(
(): Column<ClassifiedQuery>[] => [
{
key: 'query',
name: 'Query',
minWidth: triageQueryColWidth,
width: triageQueryColWidth,
resizable: true,
headerCellClass: 'first:pl-6 cursor-default',
renderHeaderCell: () => (
<div className="flex items-center text-xs w-full">
<p className="text-foreground! font-medium">Query</p>
</div>
),
renderCell: (props) => {
const row = props.row as ClassifiedQuery
const IssueIcon = row.issueType ? ISSUE_ICONS[row.issueType] : null
return (
<div className="w-full flex items-center gap-x-3 group">
<div className="shrink-0 w-6">
{row.issueType && IssueIcon && (
<div
className={cn(
'h-6 w-6 rounded-full border flex items-center justify-center',
ISSUE_DOT_COLORS[row.issueType]?.border,
ISSUE_DOT_COLORS[row.issueType]?.background
)}
>
<IssueIcon size={14} className={ISSUE_DOT_COLORS[row.issueType].color} />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-mono text-foreground truncate">
{row.queryType ?? ''}
{getTableName(row.query) && (
<>
{' '}
<span className="text-foreground-lighter">in</span> {getTableName(row.query)}
</>
)}
{getColumnName(row.query) && (
<>
<span className="text-foreground-lighter">,</span> {getColumnName(row.query)}
</>
)}
</p>
<p
className={cn(
'text-xs mt-0.5 font-mono truncate',
row.issueType === 'error' && 'text-destructive-600',
row.issueType === 'index' && 'text-warning-600',
row.issueType === 'slow' && 'text-foreground-lighter'
)}
>
{row.hint}
</p>
</div>
<ButtonTooltip
tooltip={{ content: { text: 'Query details' } }}
icon={<ArrowRight size={14} />}
size="tiny"
type="default"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setSelectedTriageRow(props.rowIdx)
setSheetView('details')
}}
className="p-1 shrink-0 group-hover:flex hidden"
/>
</div>
)
},
},
{
key: 'prop_total_time',
name: 'Time consumed',
minWidth: timeConsumedWidth,
resizable: true,
cellClass: 'column-prop_total_time',
headerCellClass: 'cursor-default',
renderHeaderCell: () => (
<div className="flex items-center text-xs w-full">
<p className="text-foreground! font-medium">Time consumed</p>
</div>
),
renderCell: (props) => {
const row = props.row as ClassifiedQuery
const percentage = row.prop_total_time || 0
const totalTime = row.total_time || 0
const fillWidth = Math.min(percentage, 100)
return (
<div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
<div
className="absolute inset-0 bg-foreground transition-all duration-200 z-0"
style={{ width: `${fillWidth}%`, opacity: 0.04 }}
/>
{percentage && totalTime ? (
<span className="flex items-center justify-end gap-x-1.5">
<span
className={cn(percentage.toFixed(1) === '0.0' && 'text-foreground-lighter')}
>
{percentage.toFixed(1)}%
</span>
<span className="text-muted">/</span>
<span
className={cn(formatDuration(totalTime) === '0ms' && 'text-foreground-lighter')}
>
{formatDuration(totalTime)}
</span>
</span>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
},
},
{
key: 'calls',
name: 'Calls',
minWidth: 90,
resizable: true,
headerCellClass: 'cursor-default',
renderHeaderCell: () => (
<div className="flex items-center text-xs w-full">
<p className="text-foreground! font-medium">Calls</p>
</div>
),
renderCell: (props) => {
const value = (props.row as ClassifiedQuery).calls
return (
<div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
<p className={cn(value === 0 && 'text-foreground-lighter')}>
{value.toLocaleString()}
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
},
},
{
key: 'mean_time',
name: 'Mean time',
minWidth: 90,
resizable: true,
headerCellClass: 'cursor-default',
renderHeaderCell: () => (
<div className="flex items-center text-xs w-full">
<p className="text-foreground! font-medium">Mean time</p>
</div>
),
renderCell: (props) => {
const value = (props.row as ClassifiedQuery).mean_time
return (
<div className="w-full flex flex-col justify-center text-xs text-right tabular-nums font-mono">
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (
<p className={cn(value === 0 && 'text-foreground-lighter')}>
{formatDuration(value)}
</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
},
},
{
key: 'actions',
name: 'Actions',
minWidth: 200,
resizable: false,
headerCellClass: 'cursor-default',
renderHeaderCell: () => (
<div className="flex items-center text-xs w-full">
<p className="text-foreground! font-medium">Actions</p>
</div>
),
renderCell: (props) => {
const row = props.row as ClassifiedQuery
return (
<div className="flex items-center gap-2 justify-end w-full h-full">
{!row.issueType && (
<Button
type="default"
size="tiny"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
handleGoToLogs()
}}
>
Go to Logs
</Button>
)}
{row.issueType === 'index' && (
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
<Button
type="primary"
size="tiny"
className="rounded-r-none border-r-0"
onClick={() => {
setSelectedTriageRow(props.rowIdx)
setSheetView('indexes')
}}
>
Create Index
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="primary"
size="tiny"
className="rounded-l-none px-1"
icon={<ChevronDown size={12} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => handleGoToLogs()} className="gap-2">
<ExternalLink size={14} />
Go to Logs
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedTriageRow(props.rowIdx)
setSheetView('explain')
}}
className="gap-2"
>
<ScanSearch size={14} />
Explain
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{(row.issueType === 'error' || row.issueType === 'slow') && (
<div onClick={(e) => e.stopPropagation()}>
<AiAssistantDropdown
label="Fix with AI"
buildPrompt={() => buildQueryInsightFixPrompt(row).prompt}
onOpenAssistant={() => handleAiSuggestedFix(row)}
copyLabel="Copy Markdown"
additionalDropdownItems={[
{
label: 'Go to Logs',
icon: <ExternalLink size={14} />,
onClick: () => handleGoToLogs(),
},
...(row.issueType === 'slow'
? [
{
label: 'Explain',
icon: <ScanSearch size={14} />,
onClick: () => {
setSelectedTriageRow(props.rowIdx)
setSheetView('explain')
},
},
]
: []),
]}
/>
</div>
)}
</div>
)
},
},
],
[
triageQueryColWidth,
timeConsumedWidth,
handleGoToLogs,
handleAiSuggestedFix,
setSelectedTriageRow,
setSheetView,
]
)
return { columns, triageColumns }
}