mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: copy multiple logs (#43218)
Allows users to select logs and copy their contents for debugging, or pass them on to assistant with one click. ## To test - go to logs or log explorer - select some logs - try copying as json, markdown, or sending it to assistant.
This commit is contained in:
1 parent
e29f4ca6f3
commit
4b994fcb54
10 files changed
+607
-160
No files matched your search
@@ -1,8 +1,8 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { isEqual } from 'lodash'
|
||||
import { Copy, Eye, EyeOff, Play } from 'lucide-react'
|
||||
import { Key, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Copy, Eye, EyeOff, Play, X as XIcon } from 'lucide-react'
|
||||
import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Item, Menu, useContextMenu } from 'react-contexify'
|
||||
import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -10,6 +10,7 @@ import { toast } from 'sonner'
|
||||
import type { ResponseError } from 'types'
|
||||
import {
|
||||
Button,
|
||||
Checkbox_Shadcn_,
|
||||
cn,
|
||||
copyToClipboard,
|
||||
ResizableHandle,
|
||||
@@ -24,11 +25,12 @@ import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewCol
|
||||
import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender'
|
||||
import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender'
|
||||
import type { LogData, LogQueryError, QueryType } from './Logs.types'
|
||||
import { isDefaultLogPreviewFormat } from './Logs.utils'
|
||||
import { formatLogsAsJson, formatLogsAsMarkdown, isDefaultLogPreviewFormat } from './Logs.utils'
|
||||
import LogSelection from './LogSelection'
|
||||
import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer'
|
||||
import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer'
|
||||
import { LogsTableEmptyState } from './LogsTableEmptyState'
|
||||
import { MultiSelectActionBar } from './MultiSelectActionBar'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
|
||||
import { useSelectedLog } from '@/hooks/analytics/useSelectedLog'
|
||||
@@ -56,6 +58,7 @@ interface Props {
|
||||
isSelectedLogLoading?: boolean
|
||||
selectedLogError?: LogQueryError | ResponseError
|
||||
onSelectedLogChange?: (log: LogData | null) => void
|
||||
sqlQuery?: string
|
||||
}
|
||||
type LogMap = { [id: string]: LogData }
|
||||
|
||||
@@ -84,6 +87,7 @@ export const LogTable = ({
|
||||
isSelectedLogLoading,
|
||||
selectedLogError,
|
||||
onSelectedLogChange,
|
||||
sqlQuery,
|
||||
}: Props) => {
|
||||
const { ref } = useParams()
|
||||
const { profile } = useProfile()
|
||||
@@ -91,8 +95,10 @@ export const LogTable = ({
|
||||
const { show: showContextMenu } = useContextMenu()
|
||||
|
||||
const [cellPosition, setCellPosition] = useState<any>()
|
||||
const [selectionOpen, setSelectionOpen] = useState(false)
|
||||
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
|
||||
const [anchorRowId, setAnchorRowId] = useState<string | null>(null)
|
||||
const [copiedFormat, setCopiedFormat] = useState<'json' | 'markdown' | null>(null)
|
||||
|
||||
const { can: canCreateLogQuery } = useAsyncCheckPermissions(
|
||||
PermissionAction.CREATE,
|
||||
@@ -105,14 +111,10 @@ export const LogTable = ({
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -124,6 +126,82 @@ export const LogTable = ({
|
||||
const panelContentMaxSize = 60
|
||||
|
||||
const LOGS_EXPLORER_CONTEXT_MENU_ID = 'logs-explorer-context-menu'
|
||||
|
||||
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 = () => {
|
||||
const next = new Set(selectedRows)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
setSelectedRows(next)
|
||||
setAnchorRowId(key)
|
||||
if (next.size > 0) {
|
||||
setSelectedRow(null)
|
||||
onSelectedLogChange?.(null)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="absolute group inset-0 flex justify-center px-2 items-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
className="group-hover:border-foreground-muted"
|
||||
checked={selectedRows.has(key)}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={toggle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMNS = columnNames.map((v: keyof LogData, idx) => {
|
||||
const column = `logs-column-${idx}`
|
||||
const result: Column<LogData> = {
|
||||
@@ -148,7 +226,6 @@ export const LogTable = ({
|
||||
},
|
||||
minWidth: 128,
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -161,25 +238,21 @@ export const LogTable = ({
|
||||
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
|
||||
@@ -190,37 +263,25 @@ export const LogTable = ({
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
if (columns.length > 0) {
|
||||
columns = [checkboxColumn, ...columns]
|
||||
}
|
||||
|
||||
const RowRenderer = useCallback<(key: Key, props: RenderRowProps<LogData, unknown>) => ReactNode>(
|
||||
(key, props) => {
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
if (columns.length > 0) {
|
||||
setCellPosition({ row: props.row, column: columns[0] })
|
||||
const firstDataColumn = columns.find((c) => c.key !== 'multi-select')
|
||||
if (firstDataColumn) {
|
||||
setCellPosition({ row: props.row, column: firstDataColumn })
|
||||
}
|
||||
showContextMenu(e, { id: LOGS_EXPLORER_CONTEXT_MENU_ID })
|
||||
}
|
||||
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}
|
||||
@@ -228,6 +289,7 @@ export const LogTable = ({
|
||||
isRowSelected={false}
|
||||
selectedCellIdx={undefined}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -250,6 +312,89 @@ export const LogTable = ({
|
||||
})
|
||||
}
|
||||
|
||||
function onRowClick(row: LogData) {
|
||||
const key = getRowKey(row)
|
||||
|
||||
// Regular single click — clear multi-select, open side panel
|
||||
setSelectedRows(new Set())
|
||||
setAnchorRowId(key)
|
||||
setSelectedRow(row)
|
||||
onSelectedLogChange?.(row)
|
||||
}
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setSelectedRows(new Set())
|
||||
setAnchorRowId(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow navigation only in single-select mode
|
||||
if (!logDataRows.length || !selectedRow || selectedRows.size > 0) 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, selectedRows, onRowClick]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSelectedLogLoading && !selectedLog) {
|
||||
setSelectedRow(null)
|
||||
}
|
||||
}, [selectedLog, isSelectedLogLoading])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleKeyDown])
|
||||
|
||||
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())
|
||||
setAnchorRowId(null)
|
||||
}
|
||||
}, [isLoading])
|
||||
|
||||
// Copy feedback timeout
|
||||
useEffect(() => {
|
||||
if (!copiedFormat) return
|
||||
const timer = setTimeout(() => setCopiedFormat(null), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [copiedFormat])
|
||||
|
||||
function handleCopySelectedRows(format: 'json' | 'markdown') {
|
||||
const text =
|
||||
format === 'json'
|
||||
? formatLogsAsJson(selectedRowsData)
|
||||
: formatLogsAsMarkdown(selectedRowsData)
|
||||
copyToClipboard(text, () => {
|
||||
setCopiedFormat(format)
|
||||
toast.success(
|
||||
`Copied ${selectedRowsData.length} log${selectedRowsData.length !== 1 ? 's' : ''} as ${format.toUpperCase()}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const LogsExplorerTableHeader = () => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -314,19 +459,16 @@ export const LogTable = ({
|
||||
|
||||
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} />
|
||||
@@ -339,55 +481,6 @@ export const LogTable = ({
|
||||
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 (
|
||||
@@ -400,46 +493,70 @@ export const LogTable = ({
|
||||
maxSize={`${panelContentMaxSize}`}
|
||||
defaultSize={`${panelContentMaxSize}`}
|
||||
>
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
<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())
|
||||
setAnchorRowId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DataGrid
|
||||
role="table"
|
||||
style={{ flex: '1 1 0%', minHeight: 0 }}
|
||||
className={cn('border-0', {
|
||||
'data-grid--simple-logs': queryType,
|
||||
'data-grid--logs-explorer': !queryType,
|
||||
})}
|
||||
rowHeight={40}
|
||||
headerRowHeight={queryType ? 0 : 28}
|
||||
onSelectedCellChange={(row) => {
|
||||
setCellPosition(row)
|
||||
}}
|
||||
columns={columns}
|
||||
rowClass={(row: LogData) => {
|
||||
const key = getRowKey(row)
|
||||
const isMultiSelected = selectedRows.has(key)
|
||||
const isSingleSelected = isEqual(row, selectedRow)
|
||||
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>
|
||||
{typeof window !== 'undefined' &&
|
||||
createPortal(
|
||||
<Menu id={LOGS_EXPLORER_CONTEXT_MENU_ID} animation={false}>
|
||||
@@ -466,7 +583,6 @@ export const LogTable = ({
|
||||
projectRef={projectRef}
|
||||
onClose={() => {
|
||||
onSelectedLogChange?.(null)
|
||||
setSelectionOpen(false)
|
||||
}}
|
||||
log={selectedLog}
|
||||
error={selectedLogError}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { LogData } from './Logs.types'
|
||||
import { buildLogsPrompt, formatLogsAsJson, formatLogsAsMarkdown } from './Logs.utils'
|
||||
|
||||
const createLog = (overrides: Partial<LogData> = {}): LogData => ({
|
||||
id: 'test-id',
|
||||
timestamp: 1621323232312,
|
||||
event_message: 'test message',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Logs.utils', () => {
|
||||
describe('formatLogsAsJson', () => {
|
||||
test('formats single log as JSON', () => {
|
||||
const rows: LogData[] = [createLog({ id: '1', event_message: 'test message' })]
|
||||
const result = formatLogsAsJson(rows)
|
||||
expect(result).toContain('"id": "1"')
|
||||
expect(result).toContain('"event_message": "test message"')
|
||||
})
|
||||
|
||||
test('formats multiple logs as JSON array', () => {
|
||||
const rows: LogData[] = [
|
||||
createLog({ id: '1', event_message: 'first' }),
|
||||
createLog({ id: '2', event_message: 'second' }),
|
||||
]
|
||||
const result = formatLogsAsJson(rows)
|
||||
expect(result).toContain('"id": "1"')
|
||||
expect(result).toContain('"id": "2"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatLogsAsMarkdown', () => {
|
||||
test('formats single log with timestamp', () => {
|
||||
const rows: LogData[] = [
|
||||
createLog({
|
||||
id: '123',
|
||||
timestamp: 1621323232312,
|
||||
event_message: 'Test error',
|
||||
status: '500',
|
||||
}),
|
||||
]
|
||||
const result = formatLogsAsMarkdown(rows)
|
||||
expect(result).toContain('## Log 1')
|
||||
expect(result).toContain('**Timestamp:**')
|
||||
expect(result).toContain('**Message:** Test error')
|
||||
expect(result).toContain('**Details:**')
|
||||
})
|
||||
|
||||
test('formats multiple logs with separators', () => {
|
||||
const rows: LogData[] = [
|
||||
createLog({ id: '1', event_message: 'first error' }),
|
||||
createLog({ id: '2', event_message: 'second error' }),
|
||||
]
|
||||
const result = formatLogsAsMarkdown(rows)
|
||||
expect(result).toContain('## Log 1')
|
||||
expect(result).toContain('## Log 2')
|
||||
expect(result).toContain('---')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLogsPrompt', () => {
|
||||
test('builds prompt with single log', () => {
|
||||
const rows: LogData[] = [createLog({ id: '1', event_message: 'error occurred' })]
|
||||
const result = buildLogsPrompt(rows)
|
||||
expect(result).toContain('1 Supabase log entry')
|
||||
expect(result).toContain('error occurred')
|
||||
expect(result).toContain('What do these logs indicate')
|
||||
})
|
||||
|
||||
test('builds prompt with multiple logs', () => {
|
||||
const rows: LogData[] = [
|
||||
createLog({ id: '1', event_message: 'error 1' }),
|
||||
createLog({ id: '2', event_message: 'error 2' }),
|
||||
]
|
||||
const result = buildLogsPrompt(rows)
|
||||
expect(result).toContain('2 Supabase log entries')
|
||||
})
|
||||
|
||||
test('handles singular correctly', () => {
|
||||
const rows: LogData[] = [createLog({ id: '1', event_message: 'single error' })]
|
||||
const result = buildLogsPrompt(rows)
|
||||
expect(result).toContain('1 Supabase log entry')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMonaco } from '@monaco-editor/react'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator'
|
||||
import type { PlanId } from 'data/subscriptions/types'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import { get } from 'lodash'
|
||||
import uniqBy from 'lodash/uniqBy'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator'
|
||||
import logConstants from 'shared-data/logConstants'
|
||||
|
||||
import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants'
|
||||
import type { Filters, LogData, LogsEndpointParams } from './Logs.types'
|
||||
|
||||
@@ -740,3 +742,91 @@ export function role(metadata: any) {
|
||||
|
||||
return payload.role
|
||||
}
|
||||
|
||||
export function formatLogsAsJson(rows: LogData[]): string {
|
||||
return JSON.stringify(rows, null, 2)
|
||||
}
|
||||
|
||||
export function formatLogsAsMarkdown(rows: LogData[]): string {
|
||||
return rows
|
||||
.map((row, i) => {
|
||||
const lines: string[] = [`## Log ${i + 1}`]
|
||||
if (row.timestamp) {
|
||||
const numTs = Number(row.timestamp)
|
||||
let tsString: string
|
||||
if (isFinite(numTs)) {
|
||||
tsString = new Date(numTs / 1000).toISOString()
|
||||
} else if (typeof row.timestamp === 'string') {
|
||||
const d = new Date(row.timestamp)
|
||||
tsString = isNaN(d.getTime()) ? row.timestamp : d.toISOString()
|
||||
} else {
|
||||
tsString = String(row.timestamp)
|
||||
}
|
||||
lines.push(`**Timestamp:** ${tsString}`)
|
||||
}
|
||||
if (row.event_message) {
|
||||
lines.push(`**Message:** ${row.event_message}`)
|
||||
}
|
||||
const { id: _id, timestamp: _ts, event_message: _msg, ...rest } = row as any
|
||||
if (Object.keys(rest).length > 0) {
|
||||
lines.push('', '**Details:**', '```json', JSON.stringify(rest, null, 2), '```')
|
||||
}
|
||||
return lines.join('\n')
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
const QUERY_TYPE_LABELS: Record<string, string> = {
|
||||
api: 'API Gateway (Edge Network)',
|
||||
database: 'Postgres Database',
|
||||
functions: 'Edge Functions',
|
||||
fn_edge: 'Edge Functions (edge runtime)',
|
||||
auth: 'Auth',
|
||||
realtime: 'Realtime',
|
||||
storage: 'Storage',
|
||||
supavisor: 'Supavisor (connection pooling)',
|
||||
postgrest: 'PostgREST',
|
||||
pg_upgrade: 'Postgres upgrade',
|
||||
pg_cron: 'pg_cron',
|
||||
pgbouncer: 'PgBouncer',
|
||||
etl: 'ETL',
|
||||
}
|
||||
|
||||
const LOG_TABLE_TO_SERVICE_LABEL: Record<string, string> = {
|
||||
edge_logs: 'API Gateway (Edge Network)',
|
||||
postgres_logs: 'Postgres Database',
|
||||
function_logs: 'Edge Functions',
|
||||
function_edge_logs: 'Edge Functions (edge runtime)',
|
||||
auth_logs: 'Auth',
|
||||
auth_audit_logs: 'Auth (audit)',
|
||||
realtime_logs: 'Realtime',
|
||||
storage_logs: 'Storage',
|
||||
postgrest_logs: 'PostgREST',
|
||||
supavisor_logs: 'Supavisor (connection pooling)',
|
||||
pgbouncer_logs: 'PgBouncer',
|
||||
pg_upgrade_logs: 'Postgres upgrade',
|
||||
pg_cron_logs: 'pg_cron',
|
||||
etl_replication_logs: 'ETL',
|
||||
}
|
||||
|
||||
function extractServiceLabelFromSql(sql: string): string | null {
|
||||
const match = sql.match(/\bfrom\s+(\w+)/i)
|
||||
const tableName = match?.[1]
|
||||
return tableName ? LOG_TABLE_TO_SERVICE_LABEL[tableName] ?? null : null
|
||||
}
|
||||
|
||||
export function buildLogsPrompt(rows: LogData[], queryType?: string, sqlQuery?: string): string {
|
||||
const serviceLabel =
|
||||
(queryType ? QUERY_TYPE_LABELS[queryType] : null) ??
|
||||
(sqlQuery ? extractServiceLabelFromSql(sqlQuery) : null)
|
||||
const serviceContext = serviceLabel ? ` from the **${serviceLabel}** service` : ''
|
||||
const sqlContext = sqlQuery ? `\n\n**Query used:**\n\`\`\`sql\n${sqlQuery.trim()}\n\`\`\`` : ''
|
||||
const header = `I have ${rows.length} Supabase log entr${rows.length === 1 ? 'y' : 'ies'}${serviceContext} I'd like help debugging:\n\n`
|
||||
const body = formatLogsAsMarkdown(rows)
|
||||
return (
|
||||
header +
|
||||
body +
|
||||
sqlContext +
|
||||
'\n\nWhat do these logs indicate? What steps can I take to resolve it? Keep your answer very concise and actionable. Max 2 or 3 bullet points.'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown'
|
||||
import { Check, ChevronDown, Copy, X as XIcon } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import type { LogData, QueryType } from './Logs.types'
|
||||
import { buildLogsPrompt } from './Logs.utils'
|
||||
|
||||
interface MultiSelectActionBarProps {
|
||||
selectedRows: Set<string>
|
||||
selectedRowsData: LogData[]
|
||||
copiedFormat: 'json' | 'markdown' | null
|
||||
onCopy: (format: 'json' | 'markdown') => void
|
||||
onClear: () => void
|
||||
queryType?: QueryType
|
||||
sqlQuery?: string
|
||||
}
|
||||
|
||||
export function MultiSelectActionBar({
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
copiedFormat,
|
||||
onCopy,
|
||||
onClear,
|
||||
queryType,
|
||||
sqlQuery,
|
||||
}: MultiSelectActionBarProps) {
|
||||
const { openSidebar } = useSidebarManagerSnapshot()
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
|
||||
function handleOpenAiAssistant() {
|
||||
const prompt = buildLogsPrompt(selectedRowsData, queryType, sqlQuery)
|
||||
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
|
||||
aiSnap.newChat({ initialMessage: prompt })
|
||||
}
|
||||
const count = selectedRows.size
|
||||
if (count === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 border-b bg-surface-200 text-sm sticky top-0 z-10"
|
||||
style={{ height: 40 }}
|
||||
>
|
||||
<span className="text-foreground-light font-mono text-xs">
|
||||
{count} row{count !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={copiedFormat ? <Check size={12} className="text-brand" /> : <Copy size={12} />}
|
||||
iconRight={<ChevronDown size={11} />}
|
||||
>
|
||||
{copiedFormat ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={() => onCopy('json')} className="gap-2 text-xs">
|
||||
<Copy size={13} />
|
||||
Copy as JSON
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopy('markdown')} className="gap-2 text-xs">
|
||||
<Copy size={13} />
|
||||
Copy as Markdown
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AiAssistantDropdown
|
||||
label="Explain with AI"
|
||||
buildPrompt={() => buildLogsPrompt(selectedRowsData, queryType, sqlQuery)}
|
||||
onOpenAssistant={handleOpenAiAssistant}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<XIcon size={12} />}
|
||||
onClick={onClear}
|
||||
title="Clear selection"
|
||||
className="text-foreground-lighter px-1.5 hover:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AiPromptCopiedEvent } from 'common/telemetry-constants'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import { Check, ChevronDown, Copy } from 'lucide-react'
|
||||
import { ComponentProps, useEffect, useState } from 'react'
|
||||
import { ComponentProps, ReactNode, useEffect, useState } from 'react'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Button,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -18,6 +19,12 @@ import {
|
||||
|
||||
type TelemetrySource = AiPromptCopiedEvent['properties']['source']
|
||||
|
||||
export interface AiAssistantDropdownItem {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface AiAssistantDropdownProps {
|
||||
buildPrompt: () => string
|
||||
label: string
|
||||
@@ -30,6 +37,7 @@ export interface AiAssistantDropdownProps {
|
||||
loading?: boolean
|
||||
className?: string
|
||||
tooltip?: string
|
||||
additionalDropdownItems?: AiAssistantDropdownItem[]
|
||||
}
|
||||
|
||||
export function AiAssistantDropdown({
|
||||
@@ -44,6 +52,7 @@ export function AiAssistantDropdown({
|
||||
loading = false,
|
||||
className,
|
||||
tooltip,
|
||||
additionalDropdownItems,
|
||||
}: AiAssistantDropdownProps) {
|
||||
const track = useTrack()
|
||||
const [showCopied, setShowCopied] = useState(false)
|
||||
@@ -95,11 +104,22 @@ export function AiAssistantDropdown({
|
||||
icon={<ChevronDown size={12} />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={handleCopyPrompt} className="gap-2">
|
||||
{showCopied ? <Check size={14} className="text-brand" /> : <Copy size={14} />}
|
||||
{showCopied ? 'Copied!' : 'Copy prompt'}
|
||||
</DropdownMenuItem>
|
||||
{additionalDropdownItems && additionalDropdownItems.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{additionalDropdownItems.map((item, i) => (
|
||||
<DropdownMenuItem key={i} onClick={item.onClick} className="gap-2">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -414,6 +414,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
|
||||
projectRef={projectRef}
|
||||
onSelectedLogChange={setSelectedLog}
|
||||
selectedLog={selectedLog || undefined}
|
||||
sqlQuery={editorValue}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end mt-2">
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.rdg-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rdg-cell:first-child {
|
||||
@apply pl-5;
|
||||
}
|
||||
@@ -48,6 +53,11 @@
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.rdg-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rdg-cell:first-child {
|
||||
@apply pl-5;
|
||||
}
|
||||
|
||||
@@ -6,24 +6,42 @@ import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { beforeAll, expect, test, vi } from 'vitest'
|
||||
|
||||
import { render } from '../../helpers'
|
||||
import { customRender as render } from 'tests/lib/custom-render'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock('next/router', () => import('next-router-mock'))
|
||||
vi.mock('nuqs', async () => {
|
||||
let queryValue = 'example'
|
||||
return {
|
||||
useQueryState: () => [queryValue, (v: string) => (queryValue = v)],
|
||||
}
|
||||
})
|
||||
})
|
||||
vi.mock('next/router', () => import('next-router-mock'))
|
||||
|
||||
vi.mock('react-data-grid', () => ({
|
||||
default: ({ columns, rows, renderers, role, headerRowHeight }: any) => (
|
||||
<div role={role ?? 'table'}>
|
||||
{headerRowHeight !== 0 && (
|
||||
<div role="row">
|
||||
{columns.map((col: any, colIdx: number) => (
|
||||
<div key={colIdx} role="columnheader">
|
||||
{col.renderHeaderCell ? col.renderHeaderCell({}) : col.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rows.map((row: any, rowIdx: number) => (
|
||||
<div key={rowIdx} role="row">
|
||||
{columns.map((col: any, colIdx: number) => (
|
||||
<div key={colIdx} role="cell">
|
||||
{col.renderCell?.({ row, rowIdx, isCellSelected: false })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && renderers?.noRowsFallback}
|
||||
</div>
|
||||
),
|
||||
Row: ({ row, ...props }: any) => <div role="row" {...props} />,
|
||||
}))
|
||||
|
||||
const fakeMicroTimestamp = dayjs().unix() * 1000
|
||||
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants'
|
||||
import {
|
||||
LogsPreviewer,
|
||||
calculateBarClickTimeRange,
|
||||
LogsPreviewer,
|
||||
} from 'components/interfaces/Settings/Logs/LogsPreviewer'
|
||||
import { customRender, customRenderHook } from 'tests/lib/custom-render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import useLogsPreview from 'hooks/analytics/useLogsPreview'
|
||||
import { LOGS_API_MOCKS } from './logs.mocks'
|
||||
import { customRender, customRenderHook } from 'tests/lib/custom-render'
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { LOGS_API_MOCKS } from './logs.mocks'
|
||||
|
||||
vi.mock('components/interfaces/Settings/Logs/LogTable', () => ({
|
||||
LogTable: ({ data }: { data: any[] }) => (
|
||||
<div data-testid="log-table-mock">
|
||||
{data.map((row) => (
|
||||
<div key={row.id} data-testid="log-row">
|
||||
{row.event_message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
||||
@@ -80,15 +92,11 @@ test('useLogsPreview returns data from MSW', async () => {
|
||||
expect(result.current.logData).toEqual(LOGS_API_MOCKS.result)
|
||||
})
|
||||
|
||||
test('LogsPreviewer renders the expected data from the API', async () => {
|
||||
test('LogsPreviewer passes API data to LogTable', async () => {
|
||||
customRender(
|
||||
<LogsPreviewer queryType="api" projectRef="default" tableName={LogsTableName.EDGE} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const firstLogEventMessage = LOGS_API_MOCKS.result[0].event_message
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -23,7 +23,7 @@ const Checkbox = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className="h-3 w-3" strokeWidth={4} />
|
||||
<Check className="h-3 w-3 text-background" strokeWidth={4} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
|
||||
Reference in new issue
Block a user