LogTable.tsx667 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { ContextMenuContent } from '@ui/components/shadcn/ui/context-menu'
3import { IS_PLATFORM, useParams } from 'common'
4import { Copy, Eye, EyeOff, Play } from 'lucide-react'
5import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
6import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid'
7import { toast } from 'sonner'
8import {
9 Button,
10 Checkbox,
11 cn,
12 ContextMenu,
13 ContextMenuItem,
14 ContextMenuTrigger,
15 copyToClipboard,
16 ResizableHandle,
17 ResizablePanel,
18 ResizablePanelGroup,
19} from 'ui'
20
21import AuthColumnRenderer from './LogColumnRenderers/AuthColumnRenderer'
22import DatabaseApiColumnRender from './LogColumnRenderers/DatabaseApiColumnRender'
23import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresColumnRender'
24import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewColumnRenderer'
25import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender'
26import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender'
27import type { LogData, LogQueryError, QueryType } from './Logs.types'
28import {
29 formatLogsAsCsv,
30 formatLogsAsJson,
31 formatLogsAsMarkdown,
32 isDefaultLogPreviewFormat,
33} from './Logs.utils'
34import LogSelection from './LogSelection'
35import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer'
36import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer'
37import { LogsTableEmptyState } from './LogsTableEmptyState'
38import { MultiSelectActionBar, type LogCopyFormat } from './MultiSelectActionBar'
39import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
40import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
41import { useSelectedLog } from '@/hooks/analytics/useSelectedLog'
42import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
43import { useProfile } from '@/lib/profile'
44import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
45import { useShortcut } from '@/state/shortcuts/useShortcut'
46import type { ResponseError } from '@/types'
47
48interface Props {
49 data?: LogData[]
50 onHistogramToggle?: () => void
51 isHistogramShowing?: boolean
52 isLoading?: boolean
53 isSaving?: boolean
54 error?: LogQueryError | null
55 showDownload?: boolean
56 queryType?: QueryType
57 projectRef: string
58 onRun?: () => void
59 onSave?: () => void
60 hasEditorValue?: boolean
61 className?: string
62 EmptyState?: ReactNode
63 showHeader?: boolean
64 showHistogramToggle?: boolean
65 selectedLog?: LogData
66 isSelectedLogLoading?: boolean
67 selectedLogError?: LogQueryError | ResponseError
68 onSelectedLogChange?: (log: LogData | null) => void
69 sqlQuery?: string
70}
71type LogMap = { [id: string]: LogData }
72
73/**
74 * Logs table view with focus side panel
75 *
76 * When in custom data display mode, the side panel will not open when focusing on logs.
77 */
78export const LogTable = ({
79 data = [],
80 queryType,
81 onHistogramToggle,
82 isHistogramShowing,
83 isLoading,
84 isSaving,
85 error,
86 projectRef,
87 onRun,
88 onSave,
89 hasEditorValue,
90 className,
91 EmptyState,
92 showHeader = true,
93 showHistogramToggle = true,
94 selectedLog,
95 isSelectedLogLoading,
96 selectedLogError,
97 onSelectedLogChange,
98 sqlQuery,
99}: Props) => {
100 const { ref } = useParams()
101 const { profile } = useProfile()
102 const [selectedLogId] = useSelectedLog()
103 const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
104 const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
105 const [copiedFormat, setCopiedFormat] = useState<LogCopyFormat | null>(null)
106 const triggerRef = useRef<HTMLDivElement>(null)
107 const [activeRow, setActiveRow] = useState<LogData | null>(null)
108 const [contextMenuKey, setContextMenuKey] = useState(0)
109
110 const handleRowContextMenu = useCallback((e: React.MouseEvent, row: LogData) => {
111 e.preventDefault()
112 setActiveRow(row)
113 // Force re-render of ContextMenuContent to update the current position.
114 setContextMenuKey((prev) => prev + 1)
115 const trigger = triggerRef.current
116 if (!trigger) return
117 trigger.style.left = `${e.clientX}px`
118 trigger.style.top = `${e.clientY}px`
119 trigger.dispatchEvent(
120 new MouseEvent('contextmenu', {
121 bubbles: true,
122 clientX: e.clientX,
123 clientY: e.clientY,
124 })
125 )
126 }, [])
127
128 const { can: canCreateLogQuery } = useAsyncCheckPermissions(
129 PermissionAction.CREATE,
130 'user_content',
131 {
132 resource: { type: 'log_sql', owner_id: profile?.id },
133 subject: { id: profile?.id },
134 }
135 )
136
137 const firstRow = data[0]
138
139 function getFirstRow() {
140 if (!firstRow) return {}
141 const { timestamp, ...rest } = firstRow
142 if (!timestamp) return firstRow
143 return { timestamp, ...rest }
144 }
145
146 const columnNames = Object.keys(getFirstRow() || {})
147 const hasId = columnNames.includes('id')
148 const hasTimestamp = columnNames.includes('timestamp')
149
150 const panelContentMinSize = 40
151 const panelContentMaxSize = 60
152
153 const getRowKey = useCallback(
154 (row: LogData): string => {
155 if (!hasId) return JSON.stringify(row)
156 return (row as LogData).id
157 },
158 [hasId]
159 )
160
161 const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => {
162 const deduped = [...new Set(data)] as LogData[]
163 if (!hasId) return [deduped, {}]
164 const map = deduped.reduce((acc: LogMap, d: LogData) => {
165 acc[d.id] = d
166 return acc
167 }, {})
168 return [deduped, map]
169 }, [data, hasId])
170
171 const logDataRows = useMemo(() => {
172 if (hasId && hasTimestamp) {
173 return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp)
174 } else {
175 return dedupedData
176 }
177 }, [dedupedData, hasId, hasTimestamp, logMap])
178
179 // Side panel is open only when a single row is selected via regular click (not multi-select)
180 const selectionOpen = Boolean((selectedLog || isSelectedLogLoading) && selectedRows.size === 0)
181
182 const selectedRowsData = useMemo(
183 () => logDataRows.filter((r) => selectedRows.has(getRowKey(r))),
184 [logDataRows, selectedRows, getRowKey]
185 )
186
187 const checkboxColumn: Column<LogData> = {
188 key: 'multi-select',
189 name: '',
190 width: 32,
191 maxWidth: 32,
192 minWidth: 32,
193 renderCell: ({ row }) => {
194 const key = getRowKey(row)
195 const toggle = () => {
196 const next = new Set(selectedRows)
197 if (next.has(key)) {
198 next.delete(key)
199 } else {
200 next.add(key)
201 }
202 setSelectedRows(next)
203 if (next.size > 0) {
204 setSelectedRow(null)
205 onSelectedLogChange?.(null)
206 }
207 }
208 return (
209 <div
210 className="absolute group inset-0 flex justify-center px-2 items-center cursor-pointer"
211 onClick={(e) => {
212 e.stopPropagation()
213 toggle()
214 }}
215 >
216 <Checkbox
217 className="group-hover:border-foreground-muted"
218 checked={selectedRows.has(key)}
219 onClick={(e: React.MouseEvent) => e.stopPropagation()}
220 onCheckedChange={toggle}
221 />
222 </div>
223 )
224 },
225 }
226
227 const DEFAULT_COLUMNS = columnNames.map((v: keyof LogData, idx) => {
228 const column = `logs-column-${idx}`
229 const result: Column<LogData> = {
230 key: column,
231 name: v as string,
232 resizable: true,
233 renderCell: ({ row }) => {
234 return <span>{formatCellValue(row?.[v])}</span>
235 },
236 renderHeaderCell: () => {
237 return <div className="flex items-center">{v}</div>
238 },
239 minWidth: 128,
240 }
241 return result
242 })
243
244 let columns = DEFAULT_COLUMNS
245
246 if (!queryType) {
247 columns
248 } else {
249 switch (queryType) {
250 case 'api':
251 columns = DatabaseApiColumnRender
252 break
253 case 'database':
254 columns = DatabasePostgresColumnRender
255 break
256 case 'fn_edge':
257 columns = FunctionsEdgeColumnRender
258 break
259 case 'functions':
260 columns = FunctionsLogsColumnRender
261 break
262 case 'auth':
263 columns = AuthColumnRenderer
264 break
265 case 'pg_cron':
266 columns = DatabasePostgresColumnRender
267 break
268 default:
269 if (firstRow && isDefaultLogPreviewFormat(firstRow)) {
270 columns = DefaultPreviewColumnRenderer
271 } else {
272 columns = DEFAULT_COLUMNS
273 }
274 break
275 }
276 }
277
278 if (columns.length > 0) {
279 columns = [checkboxColumn, ...columns]
280 }
281
282 const onRowClick = useCallback(
283 (row: LogData) => {
284 // Regular single click — clear multi-select, open side panel
285 setSelectedRows(new Set())
286 setSelectedRow(row)
287 onSelectedLogChange?.(row)
288 },
289 [onSelectedLogChange]
290 )
291
292 const RowRenderer = useCallback<(key: Key, props: RenderRowProps<LogData, unknown>) => ReactNode>(
293 (key, props) => {
294 const handleClick = (e: React.MouseEvent) => {
295 // Check if clicking on the checkbox column - let that handler handle it
296 const target = e.target as HTMLElement
297 if (target.closest('[data-column-key="multi-select"]')) return
298 onRowClick(props.row)
299 }
300 return (
301 <Row
302 key={key}
303 {...props}
304 isRowSelected={false}
305 selectedCellIdx={undefined}
306 onClick={handleClick}
307 onContextMenu={(e) => handleRowContextMenu(e, props.row)}
308 />
309 )
310 },
311 [handleRowContextMenu, onRowClick]
312 )
313
314 const formatCellValue = (value: any) => {
315 return value && typeof value === 'object'
316 ? JSON.stringify(value)
317 : value === null
318 ? 'NULL'
319 : String(value)
320 }
321
322 // Arrow-key navigation. Unlike mouse-click (`onRowClick`), keyboard nav must
323 // preserve any existing multi-select checkmarks — clearing `selectedRows`
324 // here would wipe the user's checked rows the moment they press an arrow.
325 const navigate = (direction: 'down' | 'up') => {
326 if (logDataRows.length === 0) return
327 const focusRow = (row: LogData) => {
328 setSelectedRow(row)
329 onSelectedLogChange?.(row)
330 }
331 if (!selectedRow) {
332 focusRow(logDataRows[0])
333 return
334 }
335 const selectedKey = getRowKey(selectedRow)
336 const currentIdx = logDataRows.findIndex((row) => getRowKey(row) === selectedKey)
337 if (currentIdx === -1) {
338 focusRow(logDataRows[0])
339 return
340 }
341 if (direction === 'down' && currentIdx < logDataRows.length - 1) {
342 focusRow(logDataRows[currentIdx + 1])
343 } else if (direction === 'up' && currentIdx > 0) {
344 focusRow(logDataRows[currentIdx - 1])
345 }
346 }
347
348 useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_DOWN, () => navigate('down'), {
349 enabled: logDataRows.length > 0,
350 })
351
352 useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_UP, () => navigate('up'), {
353 enabled: logDataRows.length > 0,
354 })
355
356 useShortcut(
357 SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ALL_SELECTION,
358 () => {
359 if (selectedRows.size === logDataRows.length) {
360 setSelectedRows(new Set())
361 } else {
362 setSelectedRows(new Set(logDataRows.map((row) => getRowKey(row))))
363 setSelectedRow(null)
364 onSelectedLogChange?.(null)
365 }
366 },
367 { enabled: logDataRows.length > 0 }
368 )
369
370 useShortcut(
371 SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ROW_SELECTION,
372 () => {
373 if (!selectedRow) return
374 const key = getRowKey(selectedRow)
375 const next = new Set(selectedRows)
376 if (next.has(key)) {
377 next.delete(key)
378 } else {
379 next.add(key)
380 }
381 setSelectedRows(next)
382 },
383 { enabled: selectedRow !== null }
384 )
385
386 useShortcut(
387 SHORTCUT_IDS.LOGS_PREVIEW_CLOSE_PANEL,
388 () => {
389 onSelectedLogChange?.(null)
390 setSelectedRow(null)
391 },
392 { enabled: selectionOpen }
393 )
394
395 useShortcut(
396 SHORTCUT_IDS.LOGS_PREVIEW_EXIT_SELECTION,
397 () => {
398 setSelectedRows(new Set())
399 ;(document.activeElement as HTMLElement | null)?.blur()
400 },
401 { enabled: !selectionOpen && selectedRows.size > 0 }
402 )
403
404 useEffect(() => {
405 if (!isSelectedLogLoading && !selectedLog) {
406 setSelectedRow(null)
407 }
408 }, [selectedLog, isSelectedLogLoading])
409
410 useEffect(() => {
411 if (!isLoading && !selectedRow) {
412 const logData = data.find((x) => x.id === selectedLogId)
413 if (logData) setSelectedRow(logData)
414 }
415 }, [isLoading, data, selectedRow, selectedLogId])
416
417 // Clear multi-select when a new query starts loading
418 useEffect(() => {
419 if (isLoading) {
420 setSelectedRows(new Set())
421 }
422 }, [isLoading])
423
424 // Copy feedback timeout
425 useEffect(() => {
426 if (!copiedFormat) return
427 const timer = setTimeout(() => setCopiedFormat(null), 2000)
428 return () => clearTimeout(timer)
429 }, [copiedFormat])
430
431 function handleCopySelectedRows(format: LogCopyFormat) {
432 let text = ''
433 if (format === 'json') text = formatLogsAsJson(selectedRowsData)
434 if (format === 'markdown') text = formatLogsAsMarkdown(selectedRowsData)
435 if (format === 'csv') text = formatLogsAsCsv(selectedRowsData)
436
437 copyToClipboard(text, () => {
438 setCopiedFormat(format)
439 toast.success(
440 `Copied ${selectedRowsData.length} log${selectedRowsData.length !== 1 ? 's' : ''} as ${format.toUpperCase()}`
441 )
442 })
443 }
444
445 useShortcut(SHORTCUT_IDS.RESULTS_COPY_JSON, () => handleCopySelectedRows('json'), {
446 enabled: selectedRowsData.length > 0,
447 conflictBehavior: 'allow',
448 })
449
450 useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, () => handleCopySelectedRows('markdown'), {
451 enabled: selectedRowsData.length > 0,
452 conflictBehavior: 'allow',
453 })
454
455 useShortcut(SHORTCUT_IDS.RESULTS_COPY_CSV, () => handleCopySelectedRows('csv'), {
456 enabled: selectedRowsData.length > 0,
457 conflictBehavior: 'allow',
458 })
459
460 const logsExplorerTableHeader = (
461 <div
462 className={cn(
463 'flex w-full items-center justify-between border-t bg-surface-100 px-5 py-2',
464 className,
465 { hidden: !showHeader }
466 )}
467 >
468 <div className="flex items-center gap-2">
469 <DownloadResultsButton
470 type="text"
471 text={`Results ${data && data.length ? `(${data.length})` : ''}`}
472 results={data}
473 fileName={`briven-logs-${ref}.csv`}
474 enableCopyShortcuts={selectedRowsData.length === 0}
475 />
476 </div>
477
478 {showHistogramToggle && (
479 <div className="flex items-center gap-2">
480 <Button
481 type="default"
482 icon={isHistogramShowing ? <Eye /> : <EyeOff />}
483 onClick={onHistogramToggle}
484 >
485 Histogram
486 </Button>
487 </div>
488 )}
489
490 <div className="space-x-2">
491 {IS_PLATFORM && (
492 <ButtonTooltip
493 type="default"
494 onClick={onSave}
495 loading={isSaving}
496 disabled={!canCreateLogQuery || !hasEditorValue}
497 tooltip={{
498 content: {
499 side: 'bottom',
500 text: !canCreateLogQuery
501 ? 'You need additional permissions to save your query'
502 : undefined,
503 },
504 }}
505 >
506 Save query
507 </ButtonTooltip>
508 )}
509 <Button
510 title="run-logs-query"
511 type={hasEditorValue ? 'primary' : 'alternative'}
512 disabled={!hasEditorValue}
513 onClick={onRun}
514 iconRight={<Play size={12} />}
515 loading={isLoading}
516 >
517 Run
518 </Button>
519 </div>
520 </div>
521 )
522
523 const renderErrorAlert = () => {
524 if (!error) return null
525 const childProps = {
526 isCustomQuery: queryType ? false : true,
527 error: error!,
528 }
529 if (
530 typeof error === 'object' &&
531 error.error?.errors.find((err) => err.reason === 'resourcesExceeded')
532 ) {
533 return <ResourcesExceededErrorRenderer {...childProps} />
534 }
535 return (
536 <div className="text-foreground flex gap-2 font-mono p-4">
537 <DefaultErrorRenderer {...childProps} />
538 </div>
539 )
540 }
541
542 const renderNoResultAlert = () => {
543 if (EmptyState) return EmptyState
544 return <LogsTableEmptyState />
545 }
546
547 if (!data) return null
548
549 return (
550 <section className={'h-full flex w-full flex-col flex-1'}>
551 {!queryType && logsExplorerTableHeader}
552 <ResizablePanelGroup orientation="horizontal">
553 <ResizablePanel
554 id="log-table-content"
555 minSize={`${panelContentMinSize}`}
556 maxSize={`${panelContentMaxSize}`}
557 defaultSize={`${panelContentMaxSize}`}
558 >
559 <div className="flex flex-col h-full">
560 <div
561 style={{
562 maxHeight: selectedRows.size > 0 ? 40 : 0,
563 overflow: 'hidden',
564 transition: 'max-height 150ms ease',
565 }}
566 >
567 <MultiSelectActionBar
568 selectedRows={selectedRows}
569 selectedRowsData={selectedRowsData}
570 copiedFormat={copiedFormat}
571 onCopy={handleCopySelectedRows}
572 queryType={queryType}
573 sqlQuery={sqlQuery}
574 onClear={() => {
575 setSelectedRows(new Set())
576 }}
577 />
578 </div>
579 <ContextMenu modal={false}>
580 <ContextMenuTrigger asChild>
581 <div ref={triggerRef} className="fixed pointer-events-none w-0 h-0" />
582 </ContextMenuTrigger>
583 <ContextMenuContent key={contextMenuKey}>
584 <ContextMenuItem
585 className="gap-x-2"
586 onSelect={() => {
587 const eventMessage = activeRow?.event_message
588 if (eventMessage) {
589 copyToClipboard(eventMessage, () => {
590 toast.success('Copied to clipboard')
591 })
592 }
593 }}
594 >
595 <Copy size={14} />
596 <span className="text-xs">Copy event message</span>
597 </ContextMenuItem>
598 </ContextMenuContent>
599 </ContextMenu>
600 <DataGrid
601 role="table"
602 style={{ flex: '1 1 0%', minHeight: 0 }}
603 className={cn('border-0', {
604 'data-grid--simple-logs': queryType,
605 'data-grid--logs-explorer': !queryType,
606 })}
607 rowHeight={40}
608 headerRowHeight={queryType ? 0 : 28}
609 columns={columns}
610 rowClass={(row: LogData) => {
611 const key = getRowKey(row)
612 const isMultiSelected = selectedRows.has(key)
613 const isSingleSelected = selectedRow !== null && getRowKey(selectedRow) === key
614 return cn(
615 'font-mono tracking-tight bg-studio! hover:bg-surface-100! cursor-pointer',
616 {
617 'bg-surface-200! rdg-row--focused': isSingleSelected || isMultiSelected,
618 }
619 )
620 }}
621 rows={logDataRows}
622 rowKeyGetter={(r) => {
623 if (!hasId) return JSON.stringify(r)
624 const row = r as LogData
625 return row.id
626 }}
627 renderers={{
628 renderRow: RowRenderer,
629 noRowsFallback: !isLoading ? (
630 // gridColumn: '1 / -1' makes the fallback span all CSS grid columns,
631 // including the checkbox column we prepend, so it fills the full width.
632 <div style={{ gridColumn: '1 / -1' }}>
633 {logDataRows.length === 0 && !error && renderNoResultAlert()}
634 {error && renderErrorAlert()}
635 </div>
636 ) : null,
637 }}
638 />
639 </div>
640 </ResizablePanel>
641
642 {selectionOpen && (
643 <>
644 <ResizableHandle withHandle />
645 <ResizablePanel
646 id="log-table-panel"
647 minSize={`${100 - panelContentMaxSize}`}
648 maxSize={`${100 - panelContentMinSize}`}
649 defaultSize={`${100 - panelContentMaxSize}`}
650 >
651 <LogSelection
652 isLoading={isSelectedLogLoading || false}
653 projectRef={projectRef}
654 onClose={() => {
655 onSelectedLogChange?.(null)
656 }}
657 log={selectedLog}
658 error={selectedLogError}
659 queryType={queryType}
660 />
661 </ResizablePanel>
662 </>
663 )}
664 </ResizablePanelGroup>
665 </section>
666 )
667}