Files
supabase/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx
T
Joshen LimandIvan Vasilov fa8cf46d50 Chore/refactor download results button to use papaparse (#34693)
* refactor LogTable and PreviewFilterPanel to use DownloadResultsButton

* Remove CSVButton file and remove react-csv dependency

* Update

* Change the download button to use file-saver.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2025-04-03 13:44:16 +02:00

446 lines
13 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { isEqual } from 'lodash'
import { Clipboard, Eye, EyeOff, Play } from 'lucide-react'
import { Key, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { Item, Menu, useContextMenu } from 'react-contexify'
import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid'
import { createPortal } from 'react-dom'
import { IS_PLATFORM, useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { copyToClipboard } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { ResponseError } from 'types'
import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } 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 LogSelection from './LogSelection'
import type { LogData, LogQueryError, QueryType } from './Logs.types'
import { isDefaultLogPreviewFormat } from './Logs.utils'
import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer'
import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer'
import { LogsTableEmptyState } from './LogsTableEmptyState'
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
collectionName?: string // Used for warehouse queries
warehouseError?: string
EmptyState?: ReactNode
showHeader?: boolean
showHistogramToggle?: boolean
selectedLog?: LogData
isSelectedLogLoading?: boolean
selectedLogError?: LogQueryError | ResponseError
onSelectedLogChange?: (log: LogData | null) => void
}
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.
*/
const LogTable = ({
data = [],
queryType,
onHistogramToggle,
isHistogramShowing,
isLoading,
isSaving,
error,
projectRef,
onRun,
onSave,
hasEditorValue,
className,
collectionName,
EmptyState,
showHeader = true,
showHistogramToggle = true,
selectedLog,
isSelectedLogLoading,
selectedLogError,
onSelectedLogChange,
}: Props) => {
const { ref } = useParams()
const { profile } = useProfile()
const [selectedLogId] = useSelectedLog()
const { show: showContextMenu } = useContextMenu()
const [cellPosition, setCellPosition] = useState<any>()
const [selectionOpen, setSelectionOpen] = useState(false)
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
const canCreateLogQuery = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'log_sql', owner_id: profile?.id },
subject: { id: profile?.id },
})
const firstRow = data[0]
// move timestamp to the first column, if it exists
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 LOGS_EXPLORER_CONTEXT_MENU_ID = 'logs-explorer-context-menu'
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 }: any) => {
return (
<span onContextMenu={(e) => showContextMenu(e, { id: LOGS_EXPLORER_CONTEXT_MENU_ID })}>
{formatCellValue(row?.[v])}
</span>
)
},
renderHeaderCell: (props) => {
return <div className="flex items-center">{v}</div>
},
minWidth: 128,
}
return result
})
let columns = DEFAULT_COLUMNS
if (!queryType) {
columns
} else {
switch (queryType) {
case 'warehouse':
columns = DEFAULT_COLUMNS
break
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
default:
if (firstRow && isDefaultLogPreviewFormat(firstRow)) {
columns = DefaultPreviewColumnRenderer
} else {
columns = DEFAULT_COLUMNS
}
break
}
}
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])
const RowRenderer = useCallback<(key: Key, props: RenderRowProps<LogData, unknown>) => ReactNode>(
(key, props) => {
return <Row key={key} {...props} isRowSelected={false} selectedCellIdx={undefined} />
},
[]
)
const formatCellValue = (value: any) => {
return value && typeof value === 'object'
? JSON.stringify(value)
: value === null
? 'NULL'
: String(value)
}
const onCopyCell = () => {
if (cellPosition) {
const { row, column } = cellPosition
const cellValue = row?.[column.name] ?? ''
const value = formatCellValue(cellValue)
copyToClipboard(value)
}
}
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
type="text"
text={`Results ${data && data.length ? `(${data.length})` : ''}`}
results={data}
fileName={`supabase-logs-${ref}.csv`}
/>
</div>
{showHistogramToggle && (
<div className="flex items-center gap-2">
<Button
type="default"
icon={isHistogramShowing ? <Eye /> : <EyeOff />}
onClick={onHistogramToggle}
>
Histogram
</Button>
</div>
)}
<div className="space-x-2">
{IS_PLATFORM && (
<ButtonTooltip
type="default"
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"
type={hasEditorValue ? 'primary' : 'alternative'}
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 === '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
else return <LogsTableEmptyState />
}
function onRowClick(row: LogData) {
setSelectedRow(row)
onSelectedLogChange?.(row)
}
// Keyboard navigation
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!logDataRows.length || !selectedRow) return
const currentIndex = logDataRows.findIndex((row) => isEqual(row, selectedRow))
if (currentIndex === -1) return
if (event.key === 'ArrowUp' && currentIndex > 0) {
const prevRow = logDataRows[currentIndex - 1]
onRowClick(prevRow)
} else if (event.key === 'ArrowDown' && currentIndex < logDataRows.length - 1) {
const nextRow = logDataRows[currentIndex + 1]
onRowClick(nextRow)
}
},
[logDataRows, selectedRow, onRowClick]
)
useEffect(() => {
if (selectedLog || isSelectedLogLoading) {
setSelectionOpen(true)
}
if (!isSelectedLogLoading && !selectedLog) {
setSelectedRow(null)
}
}, [selectedLog, isSelectedLogLoading])
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleKeyDown])
useEffect(() => {
if (!isLoading && !selectedRow) {
// [Joshen] Only want to run this once on a fresh session when log param is provided in URL
// Subsequently, selectedRow state is just controlled by the user's clicks on LogTable
const logData = data.find((x) => x.id === selectedLogId)
if (logData) setSelectedRow(logData)
}
}, [isLoading])
if (!data) return null
return (
<section className={'h-full flex w-full flex-col flex-1'}>
{!queryType && <LogsExplorerTableHeader />}
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={selectedLog ? 60 : 100}>
<DataGrid
role="table"
style={{ height: '100%' }}
className={cn('flex-1 flex-grow h-full border-0', {
'data-grid--simple-logs': queryType,
'data-grid--logs-explorer': !queryType,
})}
rowHeight={40}
headerRowHeight={queryType ? 0 : 28}
onSelectedCellChange={(row) => {
setCellPosition(row)
}}
onCellClick={(row) => {
onRowClick(row.row)
}}
columns={columns}
rowClass={(row: LogData) => {
return cn(
'font-mono tracking-tight !bg-studio hover:!bg-surface-100 cursor-pointer',
{
'!bg-surface-200 rdg-row--focused': isEqual(row, selectedRow),
}
)
}}
rows={logDataRows}
rowKeyGetter={(r) => {
if (!hasId) return JSON.stringify(r)
const row = r as LogData
return row.id
}}
renderers={{
renderRow: RowRenderer,
noRowsFallback: !isLoading ? (
<>
{logDataRows.length === 0 && !error && <RenderNoResultAlert />}
{error && <RenderErrorAlert />}
</>
) : null,
}}
/>
{typeof window !== 'undefined' &&
createPortal(
<Menu id={LOGS_EXPLORER_CONTEXT_MENU_ID} animation={false}>
<Item onClick={onCopyCell}>
<Clipboard size={14} />
<span className="ml-2 text-xs">Copy cell content</span>
</Item>
</Menu>,
document.body
)}
</ResizablePanel>
<ResizableHandle withHandle />
{selectionOpen && (
<ResizablePanel minSize={40} defaultSize={50}>
<LogSelection
isLoading={isSelectedLogLoading || false}
projectRef={projectRef}
onClose={() => {
onSelectedLogChange?.(null)
setSelectionOpen(false)
}}
log={selectedLog}
error={selectedLogError}
queryType={queryType}
collectionName={collectionName}
/>
</ResizablePanel>
)}
</ResizablePanelGroup>
</section>
)
}
export default LogTable