mirror of
https://github.com/supabase/supabase.git
synced 2026-05-09 02:09:50 -04:00
56de26fe22
This PR migrates the whole monorepo to use Tailwind v4: - Removed `@tailwindcss/container-queries` plugin since it's included by default in v4, - Bump all instances of Tailwind to v4. Made minimal changes to the shared config to remove non-supported features (`alpha` mentions), - Migrate all apps to be compatible with v4 configs, - Fix the `typography.css` import in 3 apps, - Add missing rules which were included by default in v3, - Run `pnpm dlx @tailwindcss/upgrade` on all apps, which renames a lot of classes - Rename all misnamed classes according to https://tailwindcss.com/docs/upgrade-guide#renamed-utilities in all apps. --------- Co-authored-by: Jordi Enric <jordi.err@gmail.com>
600 lines
22 KiB
TypeScript
600 lines
22 KiB
TypeScript
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,
|
||
DropdownMenuSeparator,
|
||
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">–</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">–</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">–</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">–</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">–</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">–</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">–</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">–</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">–</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">–</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"
|
||
extraDropdownItems={
|
||
<>
|
||
<DropdownMenuItem onClick={() => handleGoToLogs()} className="gap-2">
|
||
<ExternalLink size={14} />
|
||
Go to Logs
|
||
</DropdownMenuItem>
|
||
{row.issueType === 'slow' && (
|
||
<DropdownMenuItem
|
||
onClick={() => {
|
||
setSelectedTriageRow(props.rowIdx)
|
||
setSheetView('explain')
|
||
}}
|
||
className="gap-2"
|
||
>
|
||
<ScanSearch size={14} />
|
||
Explain
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuSeparator />
|
||
</>
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
],
|
||
[
|
||
triageQueryColWidth,
|
||
timeConsumedWidth,
|
||
handleGoToLogs,
|
||
handleAiSuggestedFix,
|
||
setSelectedTriageRow,
|
||
setSheetView,
|
||
]
|
||
)
|
||
|
||
return { columns, triageColumns }
|
||
}
|