LogTable.tsx667 lines · main
| 1 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 2 | import { ContextMenuContent } from '@ui/components/shadcn/ui/context-menu' |
| 3 | import { IS_PLATFORM, useParams } from 'common' |
| 4 | import { Copy, Eye, EyeOff, Play } from 'lucide-react' |
| 5 | import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| 6 | import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid' |
| 7 | import { toast } from 'sonner' |
| 8 | import { |
| 9 | Button, |
| 10 | Checkbox, |
| 11 | cn, |
| 12 | ContextMenu, |
| 13 | ContextMenuItem, |
| 14 | ContextMenuTrigger, |
| 15 | copyToClipboard, |
| 16 | ResizableHandle, |
| 17 | ResizablePanel, |
| 18 | ResizablePanelGroup, |
| 19 | } from 'ui' |
| 20 | |
| 21 | import AuthColumnRenderer from './LogColumnRenderers/AuthColumnRenderer' |
| 22 | import DatabaseApiColumnRender from './LogColumnRenderers/DatabaseApiColumnRender' |
| 23 | import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresColumnRender' |
| 24 | import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewColumnRenderer' |
| 25 | import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender' |
| 26 | import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender' |
| 27 | import type { LogData, LogQueryError, QueryType } from './Logs.types' |
| 28 | import { |
| 29 | formatLogsAsCsv, |
| 30 | formatLogsAsJson, |
| 31 | formatLogsAsMarkdown, |
| 32 | isDefaultLogPreviewFormat, |
| 33 | } from './Logs.utils' |
| 34 | import LogSelection from './LogSelection' |
| 35 | import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer' |
| 36 | import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer' |
| 37 | import { LogsTableEmptyState } from './LogsTableEmptyState' |
| 38 | import { MultiSelectActionBar, type LogCopyFormat } from './MultiSelectActionBar' |
| 39 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 40 | import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton' |
| 41 | import { useSelectedLog } from '@/hooks/analytics/useSelectedLog' |
| 42 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 43 | import { useProfile } from '@/lib/profile' |
| 44 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 45 | import { useShortcut } from '@/state/shortcuts/useShortcut' |
| 46 | import type { ResponseError } from '@/types' |
| 47 | |
| 48 | interface 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 | } |
| 71 | type 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 | */ |
| 78 | export 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 | } |