From 4b994fcb543db8e53c7955e1dde04329578a1fdf Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:24:37 +0100 Subject: [PATCH] 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. --- .../interfaces/Settings/Logs/LogTable.tsx | 376 ++++++++++++------ .../Settings/Logs/Logs.utils.test.ts | 86 ++++ .../interfaces/Settings/Logs/Logs.utils.ts | 94 ++++- .../Settings/Logs/MultiSelectActionBar.tsx | 98 +++++ .../components/ui/AiAssistantDropdown.tsx | 24 +- .../project/[ref]/logs/explorer/index.tsx | 1 + apps/studio/styles/react-data-grid-logs.scss | 10 + .../tests/features/logs/LogTable.test.tsx | 42 +- .../features/logs/LogsPreviewer.test.tsx | 34 +- .../ui/src/components/shadcn/ui/checkbox.tsx | 2 +- 10 files changed, 607 insertions(+), 160 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts create mode 100644 apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index 09c2fab36b3..194a175f8bd 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -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() - const [selectionOpen, setSelectionOpen] = useState(false) const [selectedRow, setSelectedRow] = useState(null) + const [selectedRows, setSelectedRows] = useState>(new Set()) + const [anchorRowId, setAnchorRowId] = useState(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 = { + 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 ( +
{ + e.stopPropagation() + toggle() + }} + > + e.stopPropagation()} + onCheckedChange={toggle} + /> +
+ ) + }, + } + const DEFAULT_COLUMNS = columnNames.map((v: keyof LogData, idx) => { const column = `logs-column-${idx}` const result: Column = { @@ -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) => 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 ( ) }, @@ -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 = () => (
{ 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 } - return (
@@ -339,55 +481,6 @@ export const LogTable = ({ else return } - 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}`} > - { - 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 && } - {error && } - - ) : null, - }} - /> +
+
0 ? 40 : 0, + overflow: 'hidden', + transition: 'max-height 150ms ease', + }} + > + { + setSelectedRows(new Set()) + setAnchorRowId(null) + }} + /> +
+ { + 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. +
+ {logDataRows.length === 0 && !error && } + {error && } +
+ ) : null, + }} + /> +
{typeof window !== 'undefined' && createPortal( @@ -466,7 +583,6 @@ export const LogTable = ({ projectRef={projectRef} onClose={() => { onSelectedLogChange?.(null) - setSelectionOpen(false) }} log={selectedLog} error={selectedLogError} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts new file mode 100644 index 00000000000..8eb6de48d95 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts @@ -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 => ({ + 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') + }) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index adb35fd6860..51a5df7bb82 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -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 = { + 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 = { + 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.' + ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx b/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx new file mode 100644 index 00000000000..d5d9455c02e --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx @@ -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 + 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 ( +
+ + {count} row{count !== 1 ? 's' : ''} selected + + +
+ + + + + + onCopy('json')} className="gap-2 text-xs"> + + Copy as JSON + + onCopy('markdown')} className="gap-2 text-xs"> + + Copy as Markdown + + + + + buildLogsPrompt(selectedRowsData, queryType, sqlQuery)} + onOpenAssistant={handleOpenAiAssistant} + /> + +
+
+ ) +} diff --git a/apps/studio/components/ui/AiAssistantDropdown.tsx b/apps/studio/components/ui/AiAssistantDropdown.tsx index 9a44649f4f8..84e17b0e396 100644 --- a/apps/studio/components/ui/AiAssistantDropdown.tsx +++ b/apps/studio/components/ui/AiAssistantDropdown.tsx @@ -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={} /> - + {showCopied ? : } {showCopied ? 'Copied!' : 'Copy prompt'} + {additionalDropdownItems && additionalDropdownItems.length > 0 && ( + <> + + {additionalDropdownItems.map((item, i) => ( + + {item.icon} + {item.label} + + ))} + + )}
diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index f0d6a7c6387..df6375cc053 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -414,6 +414,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { projectRef={projectRef} onSelectedLogChange={setSelectedLog} selectedLog={selectedLog || undefined} + sqlQuery={editorValue} />
diff --git a/apps/studio/styles/react-data-grid-logs.scss b/apps/studio/styles/react-data-grid-logs.scss index 1ec5c499e83..5274dc6b14d 100644 --- a/apps/studio/styles/react-data-grid-logs.scss +++ b/apps/studio/styles/react-data-grid-logs.scss @@ -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; } diff --git a/apps/studio/tests/features/logs/LogTable.test.tsx b/apps/studio/tests/features/logs/LogTable.test.tsx index d25554b3ad8..9ce8b87478a 100644 --- a/apps/studio/tests/features/logs/LogTable.test.tsx +++ b/apps/studio/tests/features/logs/LogTable.test.tsx @@ -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) => ( +
+ {headerRowHeight !== 0 && ( +
+ {columns.map((col: any, colIdx: number) => ( +
+ {col.renderHeaderCell ? col.renderHeaderCell({}) : col.name} +
+ ))} +
+ )} + {rows.map((row: any, rowIdx: number) => ( +
+ {columns.map((col: any, colIdx: number) => ( +
+ {col.renderCell?.({ row, rowIdx, isCellSelected: false })} +
+ ))} +
+ ))} + {rows.length === 0 && renderers?.noRowsFallback} +
+ ), + Row: ({ row, ...props }: any) =>
, +})) const fakeMicroTimestamp = dayjs().unix() * 1000 diff --git a/apps/studio/tests/features/logs/LogsPreviewer.test.tsx b/apps/studio/tests/features/logs/LogsPreviewer.test.tsx index 9f11eaefeea..463f2351ab6 100644 --- a/apps/studio/tests/features/logs/LogsPreviewer.test.tsx +++ b/apps/studio/tests/features/logs/LogsPreviewer.test.tsx @@ -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[] }) => ( +
+ {data.map((row) => ( +
+ {row.event_message} +
+ ))} +
+ ), +})) 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( ) - await waitFor(() => { - expect(screen.getByRole('table')).toBeInTheDocument() - }) - const firstLogEventMessage = LOGS_API_MOCKS.result[0].event_message await waitFor(() => { diff --git a/packages/ui/src/components/shadcn/ui/checkbox.tsx b/packages/ui/src/components/shadcn/ui/checkbox.tsx index 09d634e25ba..ecc4e38410b 100644 --- a/packages/ui/src/components/shadcn/ui/checkbox.tsx +++ b/packages/ui/src/components/shadcn/ui/checkbox.tsx @@ -23,7 +23,7 @@ const Checkbox = React.forwardRef< {...props} > - + ))