Files
supabase/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx
T
Joshen Lim 09a1f431bc Mass update for all lucide icon imports to use the component with Icon suffix (#51659)
## Context

This is largely just a mechanical change here:
Opting to use lucide icons with the `Icon` suffix across all icon
imports within `studio`
- so that it’s clearer which components are icons + reduces the
possibility of name collision with our own components
- e.g <User /> vs <UserIcon />

Also added a eslint rule for this:
<img width="1069" height="167" alt="image"
src="https://github.com/user-attachments/assets/7f36f443-5ca2-4bf6-8d22-86e774513bfc"
/>

And updated `AGENTS.md` as such:
```
Icons come from `lucide-react`, always imported with the `Icon` suffix (`import { CheckIcon, Trash2Icon } from 'lucide-react'`) — never the bare names (`Check`) or `Check as CheckIcon` aliases (lint-enforced).
```
2026-10-14 12:56:55 +02:00

601 lines
23 KiB
TypeScript
Raw 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 {
ArrowDownIcon,
ArrowRightIcon,
ArrowUpIcon,
ChevronDownIcon,
ExternalLinkIcon,
ScanSearchIcon,
} from 'lucide-react'
import { useMemo, type RefObject } from 'react'
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
variant="text"
size="tiny"
className="p-1 h-5 w-5 shrink-0"
icon={<ChevronDownIcon 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'
)}
>
<ArrowUpIcon 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'
)}
>
<ArrowDownIcon 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={<ArrowRightIcon size={14} />}
size="tiny"
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={<ArrowRightIcon size={14} />}
size="tiny"
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
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
variant="primary"
size="tiny"
className="rounded-r-none hover:z-10 focus-visible:z-10 focus-visible:rounded-r-sm"
onClick={() => {
setSelectedTriageRow(props.rowIdx)
setSheetView('indexes')
}}
>
Create Index
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="primary"
size="tiny"
aria-label="More index actions"
className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px focus-visible:z-10 focus-visible:rounded-l-sm"
icon={<ChevronDownIcon size={12} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => handleGoToLogs()} className="gap-2">
<ExternalLinkIcon size={14} />
Go to Logs
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedTriageRow(props.rowIdx)
setSheetView('explain')
}}
className="gap-2"
>
<ScanSearchIcon 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: <ExternalLinkIcon size={14} />,
onClick: () => handleGoToLogs(),
},
...(row.issueType === 'slow'
? [
{
label: 'Explain',
icon: <ScanSearchIcon size={14} />,
onClick: () => {
setSelectedTriageRow(props.rowIdx)
setSheetView('explain')
},
},
]
: []),
]}
/>
</div>
)}
</div>
)
},
},
],
[
triageQueryColWidth,
timeConsumedWidth,
handleGoToLogs,
handleAiSuggestedFix,
setSelectedTriageRow,
setSheetView,
]
)
return { columns, triageColumns }
}