mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
Shift-clicking a log row checkbox now selects every row between the last clicked row and the clicked one, so you can grab a consecutive block of logs to copy without checking each one. Applies everywhere the shared `LogTable` renders: Postgres/API/Auth/Edge Functions logs and the Logs Explorer. **Added:** - `getShiftClickSelection` in `Logs.utils.ts`: pure helper that computes the next selection from the ordered row keys, the current selection, the anchor row, and the clicked row. Adds the inclusive range in either direction. If the whole range is already selected it deselects the range instead. Falls back to a plain toggle when there's no usable anchor. Covered by unit tests, plus `LogTable` component tests for range select, the no-anchor fallback, anchor clearing, and range deselect. **Changed:** - `LogTable` tracks the last toggled row as the range anchor (a ref, since it's only read in handlers). The anchor is set by plain clicks, shift-clicks, and the Shift+Space row toggle, and cleared whenever the selection becomes empty (toggling off the last row, plain row click, Escape, action bar clear, select-all then deselect-all, or a new query loading). - The checkbox cell handles `onClick` instead of `onCheckedChange` so the shift key is available. Keyboard Space on a focused checkbox still toggles it, since Radix dispatches a click for it. - A shift mousedown on the checkbox cell is prevented so the browser doesn't start a text selection across rows. Unified Logs has its own row selection (TanStack Table) and is not changed here. ## To test - Open any log page with a decent number of rows, e.g. Postgres logs. Click one checkbox, then shift-click a checkbox several rows below. Every row in between should be checked and the action bar should show the count. Repeat upward. - Shift-click a range that's already fully selected: the range should clear, and rows outside it stay as they were. - Plain-click a row's message text (not the checkbox): side panel opens and the selection clears. A following shift-click should just toggle that one row. - Press Escape or the action bar's clear button, then shift-click: also just a single toggle. - Focus a row with the arrow keys, press Shift+Space, then shift-click a lower checkbox: the range should extend from the keyboard-toggled row. - Tab to a checkbox and press Space: it should still toggle. - After a shift-click, confirm no text is highlighted across the rows. - Copy as JSON/Markdown/CSV still copies the selected rows in display order. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added shift-click range selection to the logs table for selecting or deselecting consecutive rows. - Preserved single-row selection when range selection is unavailable. - Improved selection behavior when clearing selections or changing log queries, preventing stale range anchors. - **Tests** - Added coverage for forward and reverse range selection, deselection, partial selections, fallback behavior, and input immutability. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
705 lines
22 KiB
TypeScript
705 lines
22 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { IS_PLATFORM, useParams } from 'common'
|
|
import { Copy, Eye, EyeOff, Play } from 'lucide-react'
|
|
import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Button,
|
|
Checkbox,
|
|
cn,
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
copyToClipboard,
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from 'ui'
|
|
|
|
import AuthColumnRenderer from './LogColumnRenderers/AuthColumnRenderer'
|
|
import DatabaseApiColumnRender from './LogColumnRenderers/DatabaseApiColumnRender'
|
|
import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresColumnRender'
|
|
import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewColumnRenderer'
|
|
import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender'
|
|
import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender'
|
|
import MultigresColumnRender from './LogColumnRenderers/MultigresColumnRender'
|
|
import type { LogData, LogQueryError, QueryType } from './Logs.types'
|
|
import {
|
|
formatLogsAsCsv,
|
|
formatLogsAsJson,
|
|
formatLogsAsMarkdown,
|
|
getShiftClickSelection,
|
|
isDefaultLogPreviewFormat,
|
|
} from './Logs.utils'
|
|
import LogSelection from './LogSelection'
|
|
import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer'
|
|
import { MissingLimitErrorRenderer } from './LogsErrorRenderers/MissingLimitErrorRenderer'
|
|
import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer'
|
|
import { LogsTableEmptyState } from './LogsTableEmptyState'
|
|
import { MultiSelectActionBar, type LogCopyFormat } from './MultiSelectActionBar'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
|
|
import { useSelectedLog } from '@/hooks/analytics/useSelectedLog'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useProfile } from '@/lib/profile'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
|
import type { ResponseError } from '@/types'
|
|
|
|
interface Props {
|
|
data?: LogData[]
|
|
onHistogramToggle?: () => void
|
|
isHistogramShowing?: boolean
|
|
isLoading?: boolean
|
|
isSaving?: boolean
|
|
error?: LogQueryError | null
|
|
showDownload?: boolean
|
|
queryType?: QueryType
|
|
projectRef: string
|
|
onRun?: () => void
|
|
onSave?: () => void
|
|
hasEditorValue?: boolean
|
|
className?: string
|
|
EmptyState?: ReactNode
|
|
showHeader?: boolean
|
|
showHistogramToggle?: boolean
|
|
selectedLog?: LogData
|
|
isSelectedLogLoading?: boolean
|
|
selectedLogError?: LogQueryError | ResponseError
|
|
onSelectedLogChange?: (log: LogData | null) => void
|
|
sqlQuery?: string
|
|
columnRenderers?: Column<LogData>[]
|
|
}
|
|
type LogMap = { [id: string]: LogData }
|
|
|
|
/**
|
|
* Logs table view with focus side panel
|
|
*
|
|
* When in custom data display mode, the side panel will not open when focusing on logs.
|
|
*/
|
|
export const LogTable = ({
|
|
data = [],
|
|
queryType,
|
|
onHistogramToggle,
|
|
isHistogramShowing,
|
|
isLoading,
|
|
isSaving,
|
|
error,
|
|
projectRef,
|
|
onRun,
|
|
onSave,
|
|
hasEditorValue,
|
|
className,
|
|
EmptyState,
|
|
showHeader = true,
|
|
showHistogramToggle = true,
|
|
selectedLog,
|
|
isSelectedLogLoading,
|
|
selectedLogError,
|
|
onSelectedLogChange,
|
|
sqlQuery,
|
|
columnRenderers,
|
|
}: Props) => {
|
|
const { ref } = useParams()
|
|
const { profile } = useProfile()
|
|
const [selectedLogId] = useSelectedLog()
|
|
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
|
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
|
|
// Last row key the user toggled, used as the start of a shift-click range
|
|
const selectionAnchorRef = useRef<string | null>(null)
|
|
const [copiedFormat, setCopiedFormat] = useState<LogCopyFormat | null>(null)
|
|
const triggerRef = useRef<HTMLDivElement>(null)
|
|
const [activeRow, setActiveRow] = useState<LogData | null>(null)
|
|
const [contextMenuKey, setContextMenuKey] = useState(0)
|
|
|
|
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: LogData) => {
|
|
e.preventDefault()
|
|
setActiveRow(row)
|
|
// Force re-render of ContextMenuContent to update the current position.
|
|
setContextMenuKey((prev) => prev + 1)
|
|
const trigger = triggerRef.current
|
|
if (!trigger) return
|
|
trigger.style.left = `${e.clientX}px`
|
|
trigger.style.top = `${e.clientY}px`
|
|
trigger.dispatchEvent(
|
|
new MouseEvent('contextmenu', {
|
|
bubbles: true,
|
|
clientX: e.clientX,
|
|
clientY: e.clientY,
|
|
})
|
|
)
|
|
}, [])
|
|
|
|
const { can: canCreateLogQuery } = useAsyncCheckPermissions(
|
|
PermissionAction.CREATE,
|
|
'user_content',
|
|
{
|
|
resource: { type: 'log_sql', owner_id: profile?.id },
|
|
subject: { id: profile?.id },
|
|
}
|
|
)
|
|
|
|
const firstRow = data[0]
|
|
|
|
function getFirstRow() {
|
|
if (!firstRow) return {}
|
|
const { timestamp, ...rest } = firstRow
|
|
if (!timestamp) return firstRow
|
|
return { timestamp, ...rest }
|
|
}
|
|
|
|
const columnNames = Object.keys(getFirstRow() || {})
|
|
const hasId = columnNames.includes('id')
|
|
const hasTimestamp = columnNames.includes('timestamp')
|
|
|
|
const panelContentMinSize = 40
|
|
const panelContentMaxSize = 60
|
|
|
|
const getRowKey = useCallback(
|
|
(row: LogData): string => {
|
|
if (!hasId) return JSON.stringify(row)
|
|
return (row as LogData).id
|
|
},
|
|
[hasId]
|
|
)
|
|
|
|
const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => {
|
|
const deduped = [...new Set(data)] as LogData[]
|
|
if (!hasId) return [deduped, {}]
|
|
const map = deduped.reduce((acc: LogMap, d: LogData) => {
|
|
acc[d.id] = d
|
|
return acc
|
|
}, {})
|
|
return [deduped, map]
|
|
}, [data, hasId])
|
|
|
|
const logDataRows = useMemo(() => {
|
|
if (hasId && hasTimestamp) {
|
|
return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp)
|
|
} else {
|
|
return dedupedData
|
|
}
|
|
}, [dedupedData, hasId, hasTimestamp, logMap])
|
|
|
|
// Side panel is open only when a single row is selected via regular click (not multi-select)
|
|
const selectionOpen = Boolean((selectedLog || isSelectedLogLoading) && selectedRows.size === 0)
|
|
|
|
const selectedRowsData = useMemo(
|
|
() => logDataRows.filter((r) => selectedRows.has(getRowKey(r))),
|
|
[logDataRows, selectedRows, getRowKey]
|
|
)
|
|
|
|
const checkboxColumn: Column<LogData> = {
|
|
key: 'multi-select',
|
|
name: '',
|
|
width: 32,
|
|
maxWidth: 32,
|
|
minWidth: 32,
|
|
renderCell: ({ row }) => {
|
|
const key = getRowKey(row)
|
|
const toggle = (isShiftClick: boolean) => {
|
|
let next: Set<string>
|
|
if (isShiftClick) {
|
|
next = getShiftClickSelection({
|
|
orderedKeys: logDataRows.map(getRowKey),
|
|
selectedKeys: selectedRows,
|
|
anchorKey: selectionAnchorRef.current,
|
|
targetKey: key,
|
|
})
|
|
} else {
|
|
next = new Set(selectedRows)
|
|
if (next.has(key)) {
|
|
next.delete(key)
|
|
} else {
|
|
next.add(key)
|
|
}
|
|
}
|
|
selectionAnchorRef.current = next.size > 0 ? key : null
|
|
setSelectedRows(next)
|
|
if (next.size > 0) {
|
|
setSelectedRow(null)
|
|
onSelectedLogChange?.(null)
|
|
}
|
|
}
|
|
return (
|
|
<div
|
|
className="absolute group inset-0 flex justify-center px-2 items-center cursor-pointer"
|
|
// Prevent a shift-click from starting a browser text selection across rows
|
|
onMouseDown={(e) => {
|
|
if (e.shiftKey) e.preventDefault()
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggle(e.shiftKey)
|
|
}}
|
|
>
|
|
<Checkbox
|
|
className="group-hover:border-foreground-muted"
|
|
checked={selectedRows.has(key)}
|
|
// use onClick instead of onCheckedChange so the shift key is available for range selection
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
toggle(e.shiftKey)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
},
|
|
}
|
|
|
|
const DEFAULT_COLUMNS = columnNames.map((v: keyof LogData, idx) => {
|
|
const column = `logs-column-${idx}`
|
|
const result: Column<LogData> = {
|
|
key: column,
|
|
name: v as string,
|
|
resizable: true,
|
|
renderCell: ({ row }) => {
|
|
return <span>{formatCellValue(row?.[v])}</span>
|
|
},
|
|
renderHeaderCell: () => {
|
|
return <div className="flex items-center">{v}</div>
|
|
},
|
|
minWidth: 128,
|
|
}
|
|
return result
|
|
})
|
|
|
|
let columns = DEFAULT_COLUMNS
|
|
|
|
if (columnRenderers) {
|
|
columns = columnRenderers
|
|
} else if (!queryType) {
|
|
columns
|
|
} else {
|
|
switch (queryType) {
|
|
case 'api':
|
|
columns = DatabaseApiColumnRender
|
|
break
|
|
case 'database':
|
|
columns = DatabasePostgresColumnRender
|
|
break
|
|
case 'fn_edge':
|
|
columns = FunctionsEdgeColumnRender
|
|
break
|
|
case 'functions':
|
|
columns = FunctionsLogsColumnRender
|
|
break
|
|
case 'auth':
|
|
columns = AuthColumnRenderer
|
|
break
|
|
case 'pg_cron':
|
|
columns = DatabasePostgresColumnRender
|
|
break
|
|
case 'multigres':
|
|
columns = MultigresColumnRender
|
|
break
|
|
default:
|
|
if (firstRow && isDefaultLogPreviewFormat(firstRow)) {
|
|
columns = DefaultPreviewColumnRenderer
|
|
} else {
|
|
columns = DEFAULT_COLUMNS
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if (columns.length > 0) {
|
|
columns = [checkboxColumn, ...columns]
|
|
}
|
|
|
|
const onRowClick = useCallback(
|
|
(row: LogData) => {
|
|
// Regular single click — clear multi-select, open side panel
|
|
setSelectedRows(new Set())
|
|
selectionAnchorRef.current = null
|
|
setSelectedRow(row)
|
|
onSelectedLogChange?.(row)
|
|
},
|
|
[onSelectedLogChange]
|
|
)
|
|
|
|
const RowRenderer = useCallback<(key: Key, props: RenderRowProps<LogData, unknown>) => ReactNode>(
|
|
(key, props) => {
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
// Check if clicking on the checkbox column - let that handler handle it
|
|
const target = e.target as HTMLElement
|
|
if (target.closest('[data-column-key="multi-select"]')) return
|
|
onRowClick(props.row)
|
|
}
|
|
return (
|
|
<Row
|
|
key={key}
|
|
{...props}
|
|
isRowSelected={false}
|
|
selectedCellIdx={undefined}
|
|
onClick={handleClick}
|
|
onContextMenu={(e) => handleRowContextMenu(e, props.row)}
|
|
/>
|
|
)
|
|
},
|
|
[handleRowContextMenu, onRowClick]
|
|
)
|
|
|
|
const formatCellValue = (value: any) => {
|
|
return value && typeof value === 'object'
|
|
? JSON.stringify(value)
|
|
: value === null
|
|
? 'NULL'
|
|
: String(value)
|
|
}
|
|
|
|
// Arrow-key navigation. Unlike mouse-click (`onRowClick`), keyboard nav must
|
|
// preserve any existing multi-select checkmarks — clearing `selectedRows`
|
|
// here would wipe the user's checked rows the moment they press an arrow.
|
|
const navigate = (direction: 'down' | 'up') => {
|
|
if (logDataRows.length === 0) return
|
|
const focusRow = (row: LogData) => {
|
|
setSelectedRow(row)
|
|
onSelectedLogChange?.(row)
|
|
}
|
|
if (!selectedRow) {
|
|
focusRow(logDataRows[0])
|
|
return
|
|
}
|
|
const selectedKey = getRowKey(selectedRow)
|
|
const currentIdx = logDataRows.findIndex((row) => getRowKey(row) === selectedKey)
|
|
if (currentIdx === -1) {
|
|
focusRow(logDataRows[0])
|
|
return
|
|
}
|
|
if (direction === 'down' && currentIdx < logDataRows.length - 1) {
|
|
focusRow(logDataRows[currentIdx + 1])
|
|
} else if (direction === 'up' && currentIdx > 0) {
|
|
focusRow(logDataRows[currentIdx - 1])
|
|
}
|
|
}
|
|
|
|
useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_DOWN, () => navigate('down'), {
|
|
enabled: logDataRows.length > 0,
|
|
})
|
|
|
|
useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_UP, () => navigate('up'), {
|
|
enabled: logDataRows.length > 0,
|
|
})
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ALL_SELECTION,
|
|
() => {
|
|
if (selectedRows.size === logDataRows.length) {
|
|
setSelectedRows(new Set())
|
|
selectionAnchorRef.current = null
|
|
} else {
|
|
setSelectedRows(new Set(logDataRows.map((row) => getRowKey(row))))
|
|
setSelectedRow(null)
|
|
onSelectedLogChange?.(null)
|
|
}
|
|
},
|
|
{ enabled: logDataRows.length > 0 }
|
|
)
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ROW_SELECTION,
|
|
() => {
|
|
if (!selectedRow) return
|
|
const key = getRowKey(selectedRow)
|
|
const next = new Set(selectedRows)
|
|
if (next.has(key)) {
|
|
next.delete(key)
|
|
} else {
|
|
next.add(key)
|
|
}
|
|
selectionAnchorRef.current = next.size > 0 ? key : null
|
|
setSelectedRows(next)
|
|
},
|
|
{ enabled: selectedRow !== null }
|
|
)
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.LOGS_PREVIEW_CLOSE_PANEL,
|
|
() => {
|
|
onSelectedLogChange?.(null)
|
|
setSelectedRow(null)
|
|
},
|
|
{ enabled: selectionOpen }
|
|
)
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.LOGS_PREVIEW_EXIT_SELECTION,
|
|
() => {
|
|
setSelectedRows(new Set())
|
|
selectionAnchorRef.current = null
|
|
;(document.activeElement as HTMLElement | null)?.blur()
|
|
},
|
|
{ enabled: !selectionOpen && selectedRows.size > 0 }
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!isSelectedLogLoading && !selectedLog) {
|
|
setSelectedRow(null)
|
|
}
|
|
}, [selectedLog, isSelectedLogLoading])
|
|
|
|
useEffect(() => {
|
|
if (!isLoading && !selectedRow) {
|
|
const logData = data.find((x) => x.id === selectedLogId)
|
|
if (logData) setSelectedRow(logData)
|
|
}
|
|
}, [isLoading, data, selectedRow, selectedLogId])
|
|
|
|
// Clear multi-select when a new query starts loading
|
|
useEffect(() => {
|
|
if (isLoading) {
|
|
setSelectedRows(new Set())
|
|
selectionAnchorRef.current = null
|
|
}
|
|
}, [isLoading])
|
|
|
|
// Copy feedback timeout
|
|
useEffect(() => {
|
|
if (!copiedFormat) return
|
|
const timer = setTimeout(() => setCopiedFormat(null), 2000)
|
|
return () => clearTimeout(timer)
|
|
}, [copiedFormat])
|
|
|
|
function handleCopySelectedRows(format: LogCopyFormat) {
|
|
let text = ''
|
|
if (format === 'json') text = formatLogsAsJson(selectedRowsData)
|
|
if (format === 'markdown') text = formatLogsAsMarkdown(selectedRowsData)
|
|
if (format === 'csv') text = formatLogsAsCsv(selectedRowsData)
|
|
|
|
copyToClipboard(text, () => {
|
|
setCopiedFormat(format)
|
|
toast.success(
|
|
`Copied ${selectedRowsData.length} log${selectedRowsData.length !== 1 ? 's' : ''} as ${format.toUpperCase()}`
|
|
)
|
|
})
|
|
}
|
|
|
|
useShortcut(SHORTCUT_IDS.RESULTS_COPY_JSON, () => handleCopySelectedRows('json'), {
|
|
enabled: selectedRowsData.length > 0,
|
|
conflictBehavior: 'allow',
|
|
})
|
|
|
|
useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, () => handleCopySelectedRows('markdown'), {
|
|
enabled: selectedRowsData.length > 0,
|
|
conflictBehavior: 'allow',
|
|
})
|
|
|
|
useShortcut(SHORTCUT_IDS.RESULTS_COPY_CSV, () => handleCopySelectedRows('csv'), {
|
|
enabled: selectedRowsData.length > 0,
|
|
conflictBehavior: 'allow',
|
|
})
|
|
|
|
const logsExplorerTableHeader = (
|
|
<div
|
|
className={cn(
|
|
'flex w-full items-center justify-between border-t bg-surface-100 px-5 py-2',
|
|
className,
|
|
{ hidden: !showHeader }
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<DownloadResultsButton
|
|
variant="text"
|
|
text={`Results ${data && data.length ? `(${data.length})` : ''}`}
|
|
results={data}
|
|
fileName={`supabase-logs-${ref}.csv`}
|
|
enableCopyShortcuts={selectedRowsData.length === 0}
|
|
/>
|
|
</div>
|
|
|
|
{showHistogramToggle && (
|
|
<div className="flex items-center gap-2">
|
|
<Button icon={isHistogramShowing ? <Eye /> : <EyeOff />} onClick={onHistogramToggle}>
|
|
Histogram
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="gap-x-2 flex items-center">
|
|
{IS_PLATFORM && (
|
|
<ButtonTooltip
|
|
onClick={onSave}
|
|
loading={isSaving}
|
|
disabled={!canCreateLogQuery || !hasEditorValue}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
text: !canCreateLogQuery
|
|
? 'You need additional permissions to save your query'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
Save query
|
|
</ButtonTooltip>
|
|
)}
|
|
<Button
|
|
title="run-logs-query"
|
|
variant="primary"
|
|
disabled={!hasEditorValue}
|
|
onClick={onRun}
|
|
iconRight={<Play size={12} />}
|
|
loading={isLoading}
|
|
>
|
|
Run
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const renderErrorAlert = () => {
|
|
if (!error) return null
|
|
const childProps = {
|
|
isCustomQuery: queryType ? false : true,
|
|
error: error!,
|
|
}
|
|
if (
|
|
typeof error === 'object' &&
|
|
error.error?.errors.find((err) => err.reason === 'missingLimit')
|
|
) {
|
|
return <MissingLimitErrorRenderer />
|
|
}
|
|
if (
|
|
typeof error === 'object' &&
|
|
error.error?.errors.find((err) => err.reason === 'resourcesExceeded')
|
|
) {
|
|
return <ResourcesExceededErrorRenderer {...childProps} />
|
|
}
|
|
return (
|
|
<div className="text-foreground flex gap-2 font-mono p-4">
|
|
<DefaultErrorRenderer {...childProps} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderNoResultAlert = () => {
|
|
if (EmptyState) return EmptyState
|
|
return <LogsTableEmptyState />
|
|
}
|
|
|
|
if (!data) return null
|
|
|
|
return (
|
|
<section className={'h-full flex w-full flex-col flex-1'}>
|
|
{!queryType && logsExplorerTableHeader}
|
|
<ResizablePanelGroup orientation="horizontal">
|
|
<ResizablePanel
|
|
id="log-table-content"
|
|
minSize={`${panelContentMinSize}`}
|
|
maxSize={`${panelContentMaxSize}`}
|
|
defaultSize={`${panelContentMaxSize}`}
|
|
>
|
|
<div className="flex flex-col h-full">
|
|
<div
|
|
style={{
|
|
maxHeight: selectedRows.size > 0 ? 40 : 0,
|
|
overflow: 'hidden',
|
|
transition: 'max-height 150ms ease',
|
|
}}
|
|
>
|
|
<MultiSelectActionBar
|
|
selectedRows={selectedRows}
|
|
selectedRowsData={selectedRowsData}
|
|
copiedFormat={copiedFormat}
|
|
onCopy={handleCopySelectedRows}
|
|
queryType={queryType}
|
|
sqlQuery={sqlQuery}
|
|
onClear={() => {
|
|
setSelectedRows(new Set())
|
|
selectionAnchorRef.current = null
|
|
}}
|
|
/>
|
|
</div>
|
|
<ContextMenu modal={false}>
|
|
<ContextMenuTrigger asChild>
|
|
<div ref={triggerRef} className="fixed pointer-events-none w-0 h-0" />
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent key={contextMenuKey}>
|
|
<ContextMenuItem
|
|
className="gap-x-2"
|
|
onSelect={() => {
|
|
const eventMessage = activeRow?.event_message
|
|
if (eventMessage) {
|
|
copyToClipboard(eventMessage, () => {
|
|
toast.success('Copied to clipboard')
|
|
})
|
|
}
|
|
}}
|
|
>
|
|
<Copy size={14} />
|
|
<span className="text-xs">Copy event message</span>
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
<DataGrid
|
|
role="table"
|
|
style={{ flex: '1 1 0%', minHeight: 0 }}
|
|
className={cn('border-t-0! border-b-0!', {
|
|
'data-grid--simple-logs': queryType,
|
|
'data-grid--logs-explorer': !queryType,
|
|
})}
|
|
rowHeight={40}
|
|
headerRowHeight={queryType || columnRenderers ? 0 : 28}
|
|
columns={columns}
|
|
rowClass={(row: LogData) => {
|
|
const key = getRowKey(row)
|
|
const isMultiSelected = selectedRows.has(key)
|
|
const isSingleSelected = selectedRow !== null && getRowKey(selectedRow) === key
|
|
return cn(
|
|
'font-mono tracking-tight bg-studio! hover:bg-surface-100! cursor-pointer',
|
|
{
|
|
'bg-surface-200! rdg-row--focused': isSingleSelected || isMultiSelected,
|
|
}
|
|
)
|
|
}}
|
|
rows={logDataRows}
|
|
rowKeyGetter={(r) => {
|
|
if (!hasId) return JSON.stringify(r)
|
|
const row = r as LogData
|
|
return row.id
|
|
}}
|
|
renderers={{
|
|
renderRow: RowRenderer,
|
|
noRowsFallback: !isLoading ? (
|
|
// gridColumn: '1 / -1' makes the fallback span all CSS grid columns,
|
|
// including the checkbox column we prepend, so it fills the full width.
|
|
<div style={{ gridColumn: '1 / -1' }}>
|
|
{logDataRows.length === 0 && !error && renderNoResultAlert()}
|
|
{error && renderErrorAlert()}
|
|
</div>
|
|
) : null,
|
|
}}
|
|
/>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
{selectionOpen && (
|
|
<>
|
|
<ResizableHandle withHandle />
|
|
<ResizablePanel
|
|
id="log-table-panel"
|
|
minSize={`${100 - panelContentMaxSize}`}
|
|
maxSize={`${100 - panelContentMinSize}`}
|
|
defaultSize={`${100 - panelContentMaxSize}`}
|
|
>
|
|
<LogSelection
|
|
isLoading={isSelectedLogLoading || false}
|
|
projectRef={projectRef}
|
|
onClose={() => {
|
|
onSelectedLogChange?.(null)
|
|
}}
|
|
log={selectedLog}
|
|
error={selectedLogError}
|
|
queryType={queryType}
|
|
/>
|
|
</ResizablePanel>
|
|
</>
|
|
)}
|
|
</ResizablePanelGroup>
|
|
</section>
|
|
)
|
|
}
|