diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index af8ba8714de..5b9bfd21a3b 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -859,7 +859,11 @@ export function role(metadata: any) { return payload.role } -export function formatLogsAsJson(rows: LogData[]): string { +type LogExportData = Pick & { + timestamp: string | number +} & Record + +export function formatLogsAsJson(rows: LogExportData[]): string { return JSON.stringify(rows, null, 2) } @@ -867,7 +871,7 @@ export function formatLogsAsCsv(rows: LogData[]): string { return convertResultsToCSV(rows as unknown as Record[]) ?? '' } -export function formatLogsAsMarkdown(rows: LogData[]): string { +export function formatLogsAsMarkdown(rows: LogExportData[]): string { return rows .map((row, i) => { const lines: string[] = [`## Log ${i + 1}`] @@ -887,7 +891,7 @@ export function formatLogsAsMarkdown(rows: LogData[]): string { if (row.event_message) { lines.push(`**Message:** ${row.event_message}`) } - const { id: _id, timestamp: _ts, event_message: _msg, ...rest } = row as any + const { id: _id, timestamp: _ts, event_message: _msg, ...rest } = row if (Object.keys(rest).length > 0) { lines.push('', '**Details:**', '```json', JSON.stringify(rest, null, 2), '```') } @@ -947,7 +951,11 @@ function extractServiceLabelFromSql(sql: string): string | null { return tableName && isLogsTableName(tableName) ? LOG_TABLE_TO_SERVICE_LABEL[tableName] : null } -export function buildLogsPrompt(rows: LogData[], queryType?: string, sqlQuery?: string): string { +export function buildLogsPrompt( + rows: LogExportData[], + queryType?: string, + sqlQuery?: string +): string { const serviceLabel = (queryType && isQueryType(queryType) ? QUERY_TYPE_LABELS[queryType] : null) ?? (sqlQuery ? extractServiceLabelFromSql(sqlQuery) : null) diff --git a/apps/studio/components/interfaces/UnifiedLogs/LogSelection.test.tsx b/apps/studio/components/interfaces/UnifiedLogs/LogSelection.test.tsx new file mode 100644 index 00000000000..abf1f4b9c3f --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/LogSelection.test.tsx @@ -0,0 +1,315 @@ +import { QueryClient } from '@tanstack/react-query' +import { getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { fireEvent, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useQueryStates } from 'nuqs' +import { ResizablePanelGroup } from 'ui' +import { describe, expect, it, vi } from 'vitest' + +import { generateDynamicColumns } from './components/Columns' +import { LogSelectionActions } from './LogSelectionActions' +import { ServiceFlowPanelControls } from './ServiceFlow/components/ServiceFlowPanelControls' +import { ServiceFlowPanel } from './ServiceFlowPanel' +import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' +import { ColumnSchema } from './UnifiedLogs.schema' +import { DataTableInfinite } from '@/components/ui/DataTable/DataTableInfinite' +import { DataTableProvider } from '@/components/ui/DataTable/providers/DataTableProvider' +import { RowSelectionModifiers } from '@/components/ui/DataTable/rowSelection.utils' +import { useTableRowSelection } from '@/components/ui/DataTable/useTableRowSelection' +import { mapUnifiedLogRow } from '@/data/logs/unified-logs.utils' +import { miscKeys } from '@/data/misc/keys' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +const logs: ColumnSchema[] = ['first', 'second', 'third', 'fourth'].map((id) => ({ + id, + log_type: 'realtime', + event_message: id, + level: 'success', + timestamp: 1000000, + date: new Date(1000), + method: null, + pathname: null, + status: null, +})) +const { columns } = generateDynamicColumns({ data: logs }) + +function SelectionHarness({ + scope = 'logs', + showPanel = false, +}: { + scope?: string + showPanel?: boolean +}) { + const [searchParameters] = useQueryStates(SEARCH_PARAMS_PARSER) + const { selection, selectRow, clearSelection } = useTableRowSelection({ scope }) + const table = useReactTable({ + data: logs, + columns, + getRowId: (row) => row.id, + getCoreRowModel: getCoreRowModel(), + state: { rowSelection: selection.selected }, + }) + const selectedRows = table.getSelectedRowModel().rows + const activeId = + selectedRows.find((row) => row.id === selection.activeId)?.id ?? selectedRows.at(-1)?.id + const onSelectRow = (id: string, modifiers?: RowSelectionModifiers) => + selectRow( + logs.map((row) => row.id), + id, + modifiers + ) + return ( + (id ? onSelectRow(id) : clearSelection())} + > + + {activeId && !showPanel && } + {activeId && showPanel && ( + + row.original)} + searchParameters={searchParameters} + /> + + )} + + ) +} +const row = (message: string) => within(screen.getByRole('table')).getByText(message).closest('tr')! +const selected = () => screen.getAllByRole('row', { selected: true }).map((element) => element.id) + +describe('log row selection', () => { + it('unifies row clicks, additive clicks, and checkbox toggles', () => { + customRender() + fireEvent.click(row('first')) + expect(selected()).toEqual(['first']) + expect(within(row('first')).getByRole('checkbox')).toBeChecked() + fireEvent.click(row('third'), { metaKey: true }) + expect(selected()).toEqual(['first', 'third']) + fireEvent.click(within(row('second')).getByRole('checkbox')) + expect(selected()).toEqual(['first', 'second', 'third']) + fireEvent.click(within(row('first')).getByRole('checkbox')) + expect(selected()).toEqual(['second', 'third']) + fireEvent.click(row('fourth')) + expect(selected()).toEqual(['fourth']) + }) + + it('supports shift-click ranges and extends and shrinks with the keyboard', () => { + customRender() + fireEvent.click(row('second')) + fireEvent.click(row('fourth'), { shiftKey: true }) + expect(selected()).toEqual(['second', 'third', 'fourth']) + fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp', shiftKey: true }) + fireEvent.keyUp(document, { key: 'ArrowUp', code: 'ArrowUp', shiftKey: true }) + expect(selected()).toEqual(['second', 'third']) + fireEvent.keyDown(document, { key: 'ArrowDown', code: 'ArrowDown', shiftKey: true }) + fireEvent.keyUp(document, { key: 'ArrowDown', code: 'ArrowDown', shiftKey: true }) + expect(selected()).toEqual(['second', 'third', 'fourth']) + fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp' }) + fireEvent.keyUp(document, { key: 'ArrowUp', code: 'ArrowUp' }) + expect(selected()).toEqual(['third']) + }) + + it('supports keyboard activation and clears on closing or changing filters', () => { + const { rerender } = customRender() + fireEvent.keyDown(row('first'), { key: 'Enter' }) + expect(selected()).toEqual(['first']) + fireEvent.keyDown(row('third'), { key: ' ', ctrlKey: true }) + expect(selected()).toEqual(['first', 'third']) + fireEvent.click(screen.getByRole('button', { name: 'Clear selection' })) + expect(screen.queryAllByRole('row', { selected: true })).toHaveLength(0) + fireEvent.click(row('second')) + rerender() + expect(screen.queryAllByRole('row', { selected: true })).toHaveLength(0) + }) +}) + +describe('selected log details', () => { + it.each([1788424716876000, '2026-09-03T09:58:36.876000'])( + 'enables actions and copies mapped logs with timestamp %s', + async (timestamp) => { + const user = userEvent.setup() + const copy = vi.spyOn(navigator.clipboard, 'writeText') + const row = mapUnifiedLogRow({ + id: 'mapped-log', + log_type: 'realtime', + timestamp, + event_message: 'Connection opened', + method: null, + pathname: null, + status: '200', + level: null, + log_count: null, + logs: null, + }) + renderPanel() + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Copy selected logs' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Explain with AI' })).not.toHaveAttribute( + 'aria-disabled', + 'true' + ) + await user.click(screen.getByRole('button', { name: 'Copy selected logs' })) + expect(JSON.parse(copy.mock.calls[0][0])).toEqual([ + expect.objectContaining({ id: 'mapped-log', timestamp, log_count: null, status: '200' }), + ]) + } + ) + + it('disables copy and AI actions when a selected log is invalid', async () => { + const user = userEvent.setup() + const copy = vi.spyOn(navigator.clipboard, 'writeText') + renderPanel() + + expect(screen.getByRole('alert')).toHaveTextContent('Selected logs contain invalid data') + expect(screen.getByRole('button', { name: 'Copy selected logs' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Explain with AI' })).toHaveAttribute( + 'aria-disabled', + 'true' + ) + await user.click(screen.getByRole('button', { name: 'Copy selected logs' })) + expect(copy).not.toHaveBeenCalled() + }) + + it.each([false, true])( + 'copies selected logs with metadata visibility %s', + async (metadataVisible) => { + const user = userEvent.setup() + const copy = vi.spyOn(navigator.clipboard, 'writeText') + const rows = [ + { + ...logs[0], + event_message: undefined, + metadata: { source: 'worker_guest_logs' }, + raw_log_data: { + event_message: 'Worker failed', + metadata: { request_id: 'request-id' }, + }, + }, + { ...logs[1], log_type: 'compute' as const, metadata: { host: 'compute-host' } }, + ] + renderPanel(, metadataVisible) + + await user.click(screen.getByRole('button', { name: 'Copy selected logs' })) + + expect(copy).toHaveBeenCalledOnce() + expect(JSON.parse(copy.mock.calls[0][0])).toEqual( + JSON.parse( + JSON.stringify([ + { + ...rows[0], + event_message: '', + metadata: metadataVisible ? rows[0].metadata : undefined, + raw_log_data: { + event_message: 'Worker failed', + metadata: metadataVisible ? { request_id: 'request-id' } : undefined, + }, + }, + { + id: rows[1].id, + timestamp: rows[1].timestamp, + event_message: rows[1].event_message, + metadata: metadataVisible ? rows[1].metadata : undefined, + }, + ]) + ) + ) + expect(rows[0]).toMatchObject({ + raw_log_data: { metadata: { request_id: 'request-id' } }, + }) + } + ) + + function renderPanel(children = , metadataVisible = true) { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: { + cloud_provider: 'AWS', + db_host: 'db.default.supabase.co', + high_availability: false, + id: 1, + inserted_at: '2026-01-01T00:00:00Z', + integration_source: null, + is_branch_enabled: false, + is_physical_backups_enabled: false, + name: 'Test project', + organization_id: 1, + ref: 'default', + region: 'us-east-1', + restUrl: 'https://default.supabase.co', + status: 'ACTIVE_HEALTHY', + subscription_id: 'subscription-1', + updated_at: '2026-01-01T00:00:00Z', + connectionString: 'postgresql://postgres:password@localhost:5432/postgres', + }, + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient.setQueryData(miscKeys.enabledFeaturesOverride(), { + disabled_features: metadataVisible ? [] : ['logs:metadata'], + }) + return customRender(children, { queryClient }) + } + + it('offers both tabs for a log without a specialized overview', async () => { + const user = userEvent.setup() + renderPanel() + await user.click(row('first')) + expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('event_message')).toBeVisible() + expect(screen.getByRole('button', { name: 'Copy selected logs' })).toBeVisible() + await user.click(screen.getByRole('tab', { name: 'Raw JSON' })) + expect(screen.getByRole('button', { name: 'Copy log as JSON' })).toBeVisible() + await user.click(screen.getByRole('tab', { name: 'Overview' })) + expect(screen.getByText('event_message')).toBeVisible() + }) + + it('shows and copies JSON directly for multiple logs and restores single-log tabs', async () => { + const user = userEvent.setup() + const copy = vi.spyOn(navigator.clipboard, 'writeText') + renderPanel() + fireEvent.click(row('first')) + expect(screen.getByRole('status')).toHaveTextContent('first') + fireEvent.click(row('third'), { metaKey: true }) + expect(screen.getByRole('status')).toHaveTextContent('2 logs selected') + expect(screen.queryByRole('tab')).not.toBeInTheDocument() + const json = screen.getByRole('region', { name: 'Selected logs JSON' }) + expect(json).toHaveTextContent('first') + expect(json).toHaveTextContent('third') + expect(json).not.toHaveTextContent('second') + expect(screen.getByRole('button', { name: 'Explain with AI' })).not.toHaveAttribute( + 'aria-haspopup' + ) + expect(screen.getByRole('button', { name: 'Copy selected logs' }).textContent?.trim()).toBe('') + const copyButton = screen.getByRole('button', { name: 'Copy selected logs' }) + expect(copyButton).not.toHaveAttribute('aria-haspopup') + await user.click(copyButton) + expect(copy).toHaveBeenCalledOnce() + expect(JSON.parse(copy.mock.calls[0][0])).toEqual( + JSON.parse(JSON.stringify([logs[0], logs[2]])) + ) + await user.click(row('second')) + expect(screen.getByRole('status')).toHaveTextContent('second') + expect(screen.getByRole('tab', { name: 'Overview' })).toBeVisible() + expect(screen.getByRole('tab', { name: 'Raw JSON' })).toBeVisible() + }) +}) diff --git a/apps/studio/components/interfaces/UnifiedLogs/LogSelectionActions.tsx b/apps/studio/components/interfaces/UnifiedLogs/LogSelectionActions.tsx new file mode 100644 index 00000000000..c59a8e9c42c --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/LogSelectionActions.tsx @@ -0,0 +1,76 @@ +import { useRef } from 'react' +import { AiIconAnimation } from 'ui' + +import { buildLogsPrompt, formatLogsAsJson } from '../Settings/Logs/Logs.utils' +import { parseSelectedLogs } from './LogSelectionActions.utils' +import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import CopyButton from '@/components/ui/CopyButton' +import { Shortcut } from '@/components/ui/Shortcut' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useTrack } from '@/lib/telemetry/track' +import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' + +export const LogSelectionActions = ({ rows }: { rows: unknown[] }) => { + const { openSidebar } = useSidebarManagerSnapshot() + const aiSnap = useAiAssistantStateSnapshot() + const track = useTrack() + const copyButtonRef = useRef(null) + + const { logsMetadata } = useIsFeatureEnabled(['logs:metadata']) + const selectedLogs = parseSelectedLogs(rows, logsMetadata) + const selectedRows = selectedLogs.success ? selectedLogs.data : [] + const hasValidSelection = selectedRows.length > 0 + + const handleOpenAiAssistant = () => { + if (!hasValidSelection) return + const prompt = buildLogsPrompt(selectedRows) + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + aiSnap.newChat({ initialMessage: prompt }) + track('ai_assistant_dropdown_button_clicked', { source: 'log_explorer' }) + } + + // Click the button so the shortcut shows the same copied feedback + const handleCopyShortcut = () => copyButtonRef.current?.click() + + return ( +
+ {!selectedLogs.success && ( + + Selected logs contain invalid data. + + )} + + formatLogsAsJson(selectedRows)} + /> + + + } + aria-label="Explain with AI" + disabled={!hasValidSelection} + tooltip={{ content: { side: 'bottom', text: 'Explain with AI' } }} + onClick={handleOpenAiAssistant} + /> +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/LogSelectionActions.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/LogSelectionActions.utils.ts new file mode 100644 index 00000000000..ee26f4cac3f --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/LogSelectionActions.utils.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' + +import { getLogDataForMetadataVisibility } from './ServiceFlowPanel.utils' +import { columnSchema } from './UnifiedLogs.schema' +import { getRawLogData } from './UnifiedLogs.utils' +import { parseOtelTimestamp } from '@/data/logs/otel-inspection.utils' + +const selectedLogSchema = z + .object({ + id: z.string(), + timestamp: z.union([ + z.number().finite(), + z + .string() + .refine( + (value) => value.trim() !== '' && Number.isFinite(parseOtelTimestamp(value).getTime()) + ), + ]), + event_message: z.string(), + metadata: z + .record(z.unknown()) + .nullish() + .transform((value) => value ?? undefined), + }) + .passthrough() + +const selectedLogsSchema = z + .array( + columnSchema + .pick({ id: true, log_type: true, event_message: true, metadata: true }) + .extend({ timestamp: z.union([z.string(), z.number()]) }) + .passthrough() + ) + .transform((rows) => + rows.map((row) => ({ ...getRawLogData(row), event_message: row.event_message ?? '' })) + ) + +export function parseSelectedLogs(rows: unknown, metadataVisible: boolean) { + return selectedLogsSchema + .transform((logs) => logs.map((log) => getLogDataForMetadataVisibility(log, metadataVisible))) + .pipe(z.array(selectedLogSchema)) + .safeParse(rows) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/RowSelectionHeader.tsx b/apps/studio/components/interfaces/UnifiedLogs/RowSelectionHeader.tsx deleted file mode 100644 index 88e4a710b0b..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/RowSelectionHeader.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion' -import { Copy, X } from 'lucide-react' -import { toast } from 'sonner' -import { - copyToClipboard, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' - -import { type LogData } from '../Settings/Logs/Logs.types' -import { - buildLogsPrompt, - formatLogsAsJson, - formatLogsAsMarkdown, -} from '../Settings/Logs/Logs.utils' -import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' -import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { useDataTable } from '@/components/ui/DataTable/providers/DataTableProvider' -import { ShortcutBadge } from '@/components/ui/ShortcutBadge' -import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' -import { SHORTCUT_IDS } from '@/state/shortcuts/registry' -import { useShortcut } from '@/state/shortcuts/useShortcut' -import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' - -// TODO - format Logs as JSON, as markdown, and as prompt - -export const RowSelectionHeader = () => { - const { openSidebar } = useSidebarManagerSnapshot() - const aiSnap = useAiAssistantStateSnapshot() - - const { table } = useDataTable() - const selectedRows = table.getSelectedRowModel().rows.map((x) => x.original) as LogData[] - - const handleOpenAiAssistant = () => { - const prompt = buildLogsPrompt(selectedRows) - openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) - aiSnap.newChat({ initialMessage: prompt }) - } - - const onCopy = (format: 'json' | 'markdown') => { - const text = - format === 'json' ? formatLogsAsJson(selectedRows) : formatLogsAsMarkdown(selectedRows) - copyToClipboard(text, () => { - toast.success( - `Copied ${selectedRows.length} log${selectedRows.length !== 1 ? 's' : ''} as ${format === 'json' ? format.toUpperCase() : format}` - ) - }) - } - - const hasSelection = selectedRows.length > 0 - useShortcut(SHORTCUT_IDS.RESULTS_COPY_JSON, () => onCopy('json'), { - enabled: hasSelection, - registerInCommandMenu: true, - }) - useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, () => onCopy('markdown'), { - enabled: hasSelection, - registerInCommandMenu: true, - }) - - return ( -
- - {selectedRows.length > 0 && ( - -

- {selectedRows.length} row{selectedRows.length > 1 ? 's' : ''} selected -

- -
- - - } - className="w-7" - tooltip={{ content: { side: 'bottom', text: 'Copy selected logs' } }} - /> - - - onCopy('json')} className="gap-2 text-xs"> - - Copy as JSON - - - onCopy('markdown')} className="gap-2 text-xs"> - - Copy as Markdown - - - - - - buildLogsPrompt(selectedRows)} - onOpenAssistant={handleOpenAiAssistant} - telemetrySource="log_explorer" - /> - - } - className="px-1" - onClick={() => table.resetRowSelection()} - tooltip={{ content: { side: 'bottom', text: 'Clear selection' } }} - /> -
-
- )} -
-
- ) -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowPanelControls.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowPanelControls.tsx index 699903e5b3f..fe4ad74d4fe 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowPanelControls.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowPanelControls.tsx @@ -1,18 +1,17 @@ import { Check, ChevronDown, ChevronUp, PanelBottom, PanelRight, X } from 'lucide-react' -import { useCallback, useMemo } from 'react' import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Separator, } from 'ui' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useDataTable } from '@/components/ui/DataTable/providers/DataTableProvider' import { Shortcut } from '@/components/ui/Shortcut' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' interface ServiceFlowPanelControlsProps { dock: 'bottom' | 'right' @@ -23,38 +22,30 @@ export const ServiceFlowPanelControls = ({ dock = 'bottom', setDock, }: ServiceFlowPanelControlsProps) => { - const { table, openRowId, setOpenRowId, isLoading } = useDataTable() + const { table, openRowId, setOpenRowId, onSelectRow } = useDataTable() + const rows = table.getRowModel().rows + const index = rows.findIndex((row) => row.id === openRowId) + const prevId = rows[index - 1]?.id + const nextId = rows[index + 1]?.id - const selectedRowData = useMemo(() => { - if (isLoading && !openRowId) return - return table.getCoreRowModel().flatRows.find((row) => row.id === openRowId) - }, [openRowId, isLoading, table]) + const handleNavigate = (id: string | undefined, shiftKey = false) => { + if (!id) return + if (onSelectRow) onSelectRow(id, { shiftKey }) + else setOpenRowId(id) + const row = document.getElementById(id) + row?.scrollIntoView({ block: 'nearest' }) + if (document.activeElement?.closest('tbody')) row?.focus({ preventScroll: true }) + } + const onPrev = () => handleNavigate(prevId) + const onNext = () => handleNavigate(nextId) + const onClose = () => setOpenRowId(undefined) - const index = table.getCoreRowModel().flatRows.findIndex((row) => row.id === selectedRowData?.id) - - const nextId = useMemo( - () => table.getCoreRowModel().flatRows[index + 1]?.id, - // eslint-disable-next-line react-hooks/exhaustive-deps - [index, isLoading, table] - ) - - const prevId = useMemo( - () => table.getCoreRowModel().flatRows[index - 1]?.id, - // eslint-disable-next-line react-hooks/exhaustive-deps - [index, isLoading, table] - ) - - const onPrev = useCallback(() => { - if (prevId) setOpenRowId(prevId) - }, [prevId, setOpenRowId]) - - const onNext = useCallback(() => { - if (nextId) setOpenRowId(nextId) - }, [nextId, setOpenRowId]) - - const onClose = useCallback(() => { - setOpenRowId(undefined) - }, [setOpenRowId]) + useShortcut(SHORTCUT_IDS.UNIFIED_LOGS_EXTEND_PREV_ROW, () => handleNavigate(prevId, true), { + enabled: !!prevId, + }) + useShortcut(SHORTCUT_IDS.UNIFIED_LOGS_EXTEND_NEXT_ROW, () => handleNavigate(nextId, true), { + enabled: !!nextId, + }) return (
@@ -67,6 +58,7 @@ export const ServiceFlowPanelControls = ({
) diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx index bd2eedc7fba..eeca5afd89c 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx @@ -8,36 +8,44 @@ import { TabsList, TabsTrigger, } from 'ui' +import { CodeBlock } from 'ui-patterns/CodeBlock' import { LogDetail } from './components/LogDetail' import { LogLevelDot } from './components/LogLevelDot' +import { LogSelectionActions } from './LogSelectionActions' import { ServiceFlowPanelControls } from './ServiceFlow/components/ServiceFlowPanelControls' +import { getLogDataForMetadataVisibility } from './ServiceFlowPanel.utils' import { ColumnSchema } from './UnifiedLogs.schema' import { QuerySearchParamsType } from './UnifiedLogs.types' -import { getEventMessageDisplay } from './UnifiedLogs.utils' +import { getEventMessageDisplay, getRawLogData } from './UnifiedLogs.utils' +import { ShortcutBadge } from '@/components/ui/ShortcutBadge' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' interface ServiceFlowPanelProps { dock: 'bottom' | 'right' setDock: (value: 'bottom' | 'right') => void - selectedRow?: ColumnSchema - selectedRowKey: string + selectedRows: ColumnSchema[] searchParameters: QuerySearchParamsType } export function ServiceFlowPanel({ dock, setDock, - selectedRow, - selectedRowKey, + selectedRows, searchParameters, }: ServiceFlowPanelProps) { const [activeTab, setActiveTab] = useState('overview') - - if (!selectedRowKey || !selectedRow) return null - - const title = - getEventMessageDisplay(selectedRow.log_type, selectedRow.event_message).message || - selectedRow.id + const hasMultiple = selectedRows.length > 1 + const selectedRow = selectedRows[0] + const title = hasMultiple + ? `${selectedRows.length} logs selected` + : getEventMessageDisplay(selectedRow?.log_type ?? '', selectedRow?.event_message).message || + selectedRow?.id + const { logsMetadata } = useIsFeatureEnabled(['logs:metadata']) + const selectedJson = selectedRows.map((row) => + getLogDataForMetadataVisibility(getRawLogData(row), logsMetadata) + ) return ( <> @@ -51,9 +59,11 @@ export function ServiceFlowPanel({
-
- -
+ {!hasMultiple && ( +
+ +
+ )}
+
- - - Overview - Raw JSON - - - {['overview', 'raw-json'].map((tab) => ( - + - - - ))} - + {JSON.stringify(selectedJson, null, 2)} + +
+ ) : ( + + + Overview + Raw JSON + + + {['overview', 'raw-json'].map((tab) => ( + + {selectedRow && ( + + )} + + ))} + + )} +
+ + + Extend selection +
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index 7dd37f7a810..e61aaefc6a9 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -7,7 +7,6 @@ import { getFacetedMinMaxValues as getTTableFacetedMinMaxValues, getFacetedUniqueValues as getTTableFacetedUniqueValues, Row, - RowSelectionState, SortingState, Table, useReactTable, @@ -33,7 +32,6 @@ import { DownloadLogsButton } from './components/DownloadLogsButton' import { LogsFilterBar } from './components/LogsFilterBar' import { LogsListPanel } from './components/LogsListPanel' import { TooltipLabel } from './components/TooltipLabel' -import { RowSelectionHeader } from './RowSelectionHeader' import { ServiceFlowPanel } from './ServiceFlowPanel' import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' import { filterFields as defaultFilterFields } from './UnifiedLogs.fields' @@ -63,7 +61,9 @@ import { DataTableViewOptions } from '@/components/ui/DataTable/DataTableViewOpt import { FilterSideBar } from '@/components/ui/DataTable/FilterSideBar' import { LiveButton } from '@/components/ui/DataTable/LiveButton' import { DataTableProvider } from '@/components/ui/DataTable/providers/DataTableProvider' +import { RowSelectionModifiers } from '@/components/ui/DataTable/rowSelection.utils' import { TimelineChart } from '@/components/ui/DataTable/TimelineChart' +import { useTableRowSelection } from '@/components/ui/DataTable/useTableRowSelection' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useUnifiedLogsChartQuery } from '@/data/logs/unified-logs-chart-query' import { useUnifiedLogsCountQuery } from '@/data/logs/unified-logs-count-query' @@ -131,10 +131,6 @@ export const UnifiedLogs = () => { const [sorting, setSorting] = useState(defaultColumnSorting) const [columnFilters, setColumnFilters] = useState(defaultColumnFilters) - const [rowSelection, setRowSelection] = useState({}) - // Last row the user toggled, used as the start of a shift-click range - const selectionAnchorRef = useRef(null) - const [openRowId, setOpenRowId] = useState(search.id ?? undefined) const [dock, setDock] = useLocalStorageQuery<'bottom' | 'right'>( LOCAL_STORAGE_KEYS.UNIFIED_LOGS_DOCK, @@ -173,6 +169,12 @@ export const UnifiedLogs = () => { return parameters }, [search, showMultigresLogs, computeAvailability.canQueryCompute]) + const { selection, selectRow, clearSelection } = useTableRowSelection({ + scope: JSON.stringify([projectRef, searchParameters]), + initialId: search.id ?? undefined, + }) + const rowSelection = selection.selected + const { data: unifiedLogsData, error, @@ -259,7 +261,7 @@ export const UnifiedLogs = () => { // Generate dynamic columns based on current data const { columns: dynamicColumns, columnVisibility: dynamicColumnVisibility } = useMemo(() => { - return generateDynamicColumns({ data: flatData, selectionAnchorRef }) + return generateDynamicColumns({ data: flatData }) }, [flatData]) const table: Table = useReactTable({ @@ -279,7 +281,6 @@ export const UnifiedLogs = () => { getRowId: (row) => row.id, onColumnVisibilityChange: setColumnVisibility, onColumnFiltersChange: setColumnFilters, - onRowSelectionChange: setRowSelection, onSortingChange: setSorting, onColumnOrderChange: setColumnOrder, getSortedRowModel: getSortedRowModel(), @@ -290,10 +291,21 @@ export const UnifiedLogs = () => { getFacetedMinMaxValues: getTTableFacetedMinMaxValues(), }) - const selectedRow = useMemo(() => { - if ((isLoading || isFetching) && !flatData.length) return - return table.getCoreRowModel().flatRows.find((row) => row.id === openRowId) - }, [isLoading, isFetching, flatData.length, table, openRowId]) + const selectedRows = table.getSelectedRowModel().rows + const selectedRow = + selectedRows.find((row) => row.id === selection.activeId) ?? selectedRows.at(-1) + const openRowId = selectedRow?.id + const handleSelectRow = (id: string, modifiers?: RowSelectionModifiers) => { + selectRow( + table.getRowModel().rows.map((row) => row.id), + id, + modifiers + ) + } + const setOpenRowId = (id: string | undefined) => { + if (id) handleSelectRow(id) + else clearSelection() + } // Will need to refactor this bit // - Each facet just handles its own state, rather than getting passed down like this @@ -389,10 +401,6 @@ export const UnifiedLogs = () => { } }, [isMobile]) - useEffect(() => { - table.resetRowSelection() - }, [searchParameters, table]) - return ( { rowSelection={rowSelection} openRowId={openRowId} setOpenRowId={setOpenRowId} + onSelectRow={handleSelectRow} columnOrder={columnOrder} columnVisibility={columnVisibility} searchParameters={searchParameters} @@ -484,8 +493,6 @@ export const UnifiedLogs = () => { )} - - { 'h-full [&>div]:h-full', '[&_thead_th]:[border-top:none]! [&_thead_th]:[border-bottom:none]!', '[&_thead_th]:[box-shadow:inset_0_-1px_0_var(--border-default)]!', - '[&_thead_th]:text-foreground-lighter! [&_thead_tr:hover]:bg-surface-75', + '[&_thead_th]:text-foreground-lighter! [&_thead_tr]:bg-background! [&_thead_tr:hover]:bg-background!', '[&_thead_tr]:border-b-0! [&_tbody_tr]:border-b-0!' )} > @@ -534,12 +541,11 @@ export const UnifiedLogs = () => { {!!openRowId && !!selectedRow && ( <> - + {selectedRows.length === 1 && } row.original)} searchParameters={searchParameters} /> diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts index 3027e48b6c7..fdecdf1ec66 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts @@ -6,7 +6,6 @@ import { parseLogsFilterUrlParams } from './UnifiedLogs.filters' import { ColumnSchema, FacetMetadataSchema } from './UnifiedLogs.schema' import { LEVELS } from '@/components/ui/DataTable/DataTable.constants' import { Option } from '@/components/ui/DataTable/DataTable.types' -import type { UnifiedLogInspectionEntry } from '@/data/logs/unified-log-inspection-query' export type UnifiedLogType = keyof typeof LOG_TYPES_LABELS @@ -83,11 +82,11 @@ export function getRowTimestampMs( return null } -type ComputeRawLogData = Pick +type ComputeRawLogData = Pick & { + timestamp: string | number +} -export function getRawLogData( - row: ColumnSchema | UnifiedLogInspectionEntry -): ColumnSchema | UnifiedLogInspectionEntry | ComputeRawLogData { +export function getRawLogData(row: T): T | ComputeRawLogData { if (!('log_type' in row) || row.log_type !== 'compute') return row return { diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx index a82914fa8f4..1c25052308c 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -1,15 +1,14 @@ -import { ColumnDef, RowSelectionState } from '@tanstack/react-table' -import type { RefObject } from 'react' +import { ColumnDef } from '@tanstack/react-table' import { Checkbox, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { STATUS_CODE_LABELS } from '../UnifiedLogs.constants' import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema' import { getEventMessageDisplay } from '../UnifiedLogs.utils' import { HoverCardTimestamp } from './HoverCardTimestamp' +import { LogLevelDot } from './LogLevelDot' import { LogTypeIcon } from './LogTypeIcon' -import { DataTableColumnLevelIndicator } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator' import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' -import { getShiftClickRowSelection } from '@/lib/shift-click-selection' +import { useDataTable } from '@/components/ui/DataTable/providers/DataTableProvider' /** * Determines if a column should be hidden based on its values in the data. @@ -32,13 +31,7 @@ function shouldHideColumn(data: ColumnSchema[], columnKey: keyof ColumnSchema): } // Generate dynamic columns based on data -export function generateDynamicColumns({ - data, - selectionAnchorRef, -}: { - data: ColumnSchema[] - selectionAnchorRef: RefObject -}): { +export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { columns: ColumnDef[] columnVisibility: Record } { @@ -48,53 +41,15 @@ export function generateDynamicColumns({ const columns: ColumnDef[] = [ { - accessorKey: 'select', + accessorKey: 'level', header: '', - cell: ({ row, table }) => { - const handleToggle = (isShiftClick: boolean) => { - const currentSelection = table.getState().rowSelection - const hasSelection = Object.values(currentSelection).some(Boolean) - // An empty selection means no anchor, so resetRowSelection() (filter change, clear - // button) drops the anchor without those paths having to touch the ref - const anchorRowId = hasSelection ? selectionAnchorRef.current : null - - let next: RowSelectionState - if (isShiftClick) { - next = getShiftClickRowSelection({ - orderedRowIds: table.getRowModel().rows.map((tableRow) => tableRow.id), - rowSelection: currentSelection, - anchorRowId, - targetRowId: row.id, - }) - } else { - next = { ...currentSelection } - if (next[row.id]) { - delete next[row.id] - } else { - next[row.id] = true - } - } - - selectionAnchorRef.current = Object.keys(next).length > 0 ? row.id : null - table.setRowSelection(next) - } - - return ( - { - if (e.shiftKey) e.preventDefault() - }} - // use onClick instead of onCheckedChange so the shift key is available for range selection - onClick={(e) => { - e.stopPropagation() - handleToggle(e.shiftKey) - }} - /> - ) - }, + cell: ({ row }) => ( + + ), enableHiding: false, enableResizing: false, enableSorting: false, @@ -108,26 +63,6 @@ export function generateDynamicColumns({ headerClassName: 'w-[42px] min-w-[42px] pl-3.5 pr-3', }, }, - // Level column - always visible - { - accessorKey: 'level', - header: '', - cell: ({ row }) => { - const level = row.getValue('level') - return level ? : null - }, - enableHiding: false, - enableResizing: false, - enableSorting: false, - filterFn: () => true, - size: 8, - minSize: 8, - maxSize: 8, - meta: { - cellClassName: 'w-2 min-w-2 px-0', - headerClassName: 'w-2 min-w-2 px-0', - }, - }, // Date column - always visible { accessorKey: 'date', @@ -334,9 +269,49 @@ export function generateDynamicColumns({ return { columns, columnVisibility } } -// Static fallback columns. These render before any data arrives, so nothing is selectable yet -// and the anchor never has to survive past this call. +// Static fallback columns export const UNIFIED_LOGS_COLUMNS: ColumnDef[] = generateDynamicColumns({ data: [], - selectionAnchorRef: { current: null }, }).columns + +function LogSelectionIndicator({ + id, + level, + isSelected, +}: { + id: string + level: ColumnSchema['level'] + isSelected: boolean +}) { + const { onSelectRow } = useDataTable() + return ( +
+
+ +
+ { + event.stopPropagation() + onSelectRow?.(id, { + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + toggle: true, + }) + }} + /> +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx index 187b7987e61..f10b562d448 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx @@ -38,8 +38,10 @@ export const LogTypeIcon = ({ return ( - - + + + +
{type}
diff --git a/apps/studio/state/shortcuts/registry/unified-logs.ts b/apps/studio/state/shortcuts/registry/unified-logs.ts index 31f4bcaacd7..1be0ed0a8e6 100644 --- a/apps/studio/state/shortcuts/registry/unified-logs.ts +++ b/apps/studio/state/shortcuts/registry/unified-logs.ts @@ -9,7 +9,7 @@ import { RegistryDefinations } from '../types' * Grid/detail-panel bindings (prev/next, close) mirror the Logs Explorer * (`logs-preview`) and Table Editor patterns so the keyboard model stays * consistent across our data-grid surfaces. Copying selected logs reuses the - * shared `results.copy-*` shortcuts (see `RowSelectionHeader`). + * shared `results.copy-*` shortcuts (see `LogSelectionActions`). */ export const UNIFIED_LOGS_SHORTCUT_IDS = { UNIFIED_LOGS_RESET_FOCUS: 'unified-logs.reset-focus', @@ -19,6 +19,8 @@ export const UNIFIED_LOGS_SHORTCUT_IDS = { UNIFIED_LOGS_CLEAR_FILTERS: 'unified-logs.clear-filters', UNIFIED_LOGS_PREV_ROW: 'unified-logs.prev-row', UNIFIED_LOGS_NEXT_ROW: 'unified-logs.next-row', + UNIFIED_LOGS_EXTEND_PREV_ROW: 'unified-logs.extend-prev-row', + UNIFIED_LOGS_EXTEND_NEXT_ROW: 'unified-logs.extend-next-row', UNIFIED_LOGS_CLOSE_PANEL: 'unified-logs.close-panel', } @@ -80,6 +82,22 @@ export const unifiedLogsRegistry: RegistryDefinations = { referenceGroup: SHORTCUT_REFERENCE_GROUPS.UNIFIED_LOGS, options: { ignoreInputs: true }, }, + [UNIFIED_LOGS_SHORTCUT_IDS.UNIFIED_LOGS_EXTEND_PREV_ROW]: { + id: UNIFIED_LOGS_SHORTCUT_IDS.UNIFIED_LOGS_EXTEND_PREV_ROW, + label: 'Extend log selection upward', + sequence: ['Shift+ArrowUp'], + showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.UNIFIED_LOGS, + options: { ignoreInputs: true }, + }, + [UNIFIED_LOGS_SHORTCUT_IDS.UNIFIED_LOGS_EXTEND_NEXT_ROW]: { + id: UNIFIED_LOGS_SHORTCUT_IDS.UNIFIED_LOGS_EXTEND_NEXT_ROW, + label: 'Extend log selection downward', + sequence: ['Shift+ArrowDown'], + showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.UNIFIED_LOGS, + options: { ignoreInputs: true }, + }, [UNIFIED_LOGS_SHORTCUT_IDS.UNIFIED_LOGS_CLOSE_PANEL]: { id: UNIFIED_LOGS_SHORTCUT_IDS.UNIFIED_LOGS_CLOSE_PANEL, label: 'Close log details panel', diff --git a/apps/studio/tests/components/UnifiedLogs/LogSelectionActions.utils.test.ts b/apps/studio/tests/components/UnifiedLogs/LogSelectionActions.utils.test.ts new file mode 100644 index 00000000000..1ab7440e554 --- /dev/null +++ b/apps/studio/tests/components/UnifiedLogs/LogSelectionActions.utils.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' + +import { buildLogsPrompt, formatLogsAsJson } from '@/components/interfaces/Settings/Logs/Logs.utils' +import { parseSelectedLogs } from '@/components/interfaces/UnifiedLogs/LogSelectionActions.utils' +import type { ColumnSchema } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.schema' +import { mapUnifiedLogRow, parseUnifiedLogsQueryRows } from '@/data/logs/unified-logs.utils' + +const row: ColumnSchema = { + id: 'log-id', + log_type: 'realtime', + timestamp: 1_000_000, + date: new Date(1000), + event_message: 'Connection opened', + method: null, + pathname: null, + level: null, + status: null, +} + +describe('parseSelectedLogs', () => { + it.each(['edge', 'auth', 'compute', 'realtime'])( + 'accepts mapped %s logs with numeric and OTEL timestamps', + (logType) => { + const timestamps = [ + 1788424716876000, + '1788424716876000', + '2026-09-03T09:58:36.876000', + '2026-09-03T09:58:36.876Z', + ] + const rows = parseUnifiedLogsQueryRows( + timestamps.map((timestamp) => ({ + id: `${logType}-${timestamp}`, + log_type: logType, + timestamp, + event_message: 'Connection opened', + method: null, + pathname: null, + status: '200', + level: null, + log_count: null, + logs: null, + })) + ).map(mapUnifiedLogRow) + + const result = parseSelectedLogs(rows, true) + + expect(result.success).toBe(true) + if (!result.success) throw result.error + expect(result.data.map((log) => log.timestamp)).toEqual(timestamps) + expect(JSON.parse(formatLogsAsJson(result.data))).toHaveLength(timestamps.length) + expect(buildLogsPrompt(result.data)).toContain('Connection opened') + if (logType !== 'compute') expect(result.data[0].log_count).toBeNull() + } + ) + + it.each([false, true])('preserves log fields with metadata visibility %s', (metadataVisible) => { + const input = { + ...row, + metadata: { host: 'host' }, + raw_log_data: { message: 'original', metadata: { request_id: 'request' } }, + custom_field: 'custom', + } + + const result = parseSelectedLogs([input], metadataVisible) + + expect(result).toMatchObject({ + success: true, + data: [ + { + ...input, + metadata: metadataVisible ? input.metadata : undefined, + raw_log_data: { + message: 'original', + metadata: metadataVisible ? input.raw_log_data.metadata : undefined, + }, + }, + ], + }) + expect(input.raw_log_data.metadata).toEqual({ request_id: 'request' }) + expect(input.metadata).toEqual({ host: 'host' }) + }) + + it.each([false, true])( + 'normalizes compute logs with metadata visibility %s', + (metadataVisible) => { + const result = parseSelectedLogs( + [{ ...row, log_type: 'compute', event_message: undefined, metadata: { host: 'host' } }], + metadataVisible + ) + + expect(result).toEqual({ + success: true, + data: [ + { + id: row.id, + timestamp: row.timestamp, + event_message: '', + metadata: metadataVisible ? { host: 'host' } : undefined, + }, + ], + }) + } + ) + + it('accepts an empty selection', () => { + expect(parseSelectedLogs([], true)).toEqual({ success: true, data: [] }) + }) + + it('normalizes nullable metadata', () => { + expect(parseSelectedLogs([{ ...row, metadata: null }], true)).toMatchObject({ + success: true, + data: [{ metadata: undefined }], + }) + }) + + it.each([ + null, + {}, + [null], + [{ ...row, id: undefined }], + [{ ...row, event_message: 123 }], + [{ ...row, log_type: 'unknown' }], + [{ ...row, metadata: 'invalid' }], + [{ ...row, timestamp: true }], + [{ ...row, timestamp: 'invalid timestamp' }], + [{ ...row, timestamp: '' }], + ])('returns a validation error for malformed input: %j', (input) => { + expect(parseSelectedLogs(input, false).success).toBe(false) + }) + + it('validates the transformed output and rejects the whole selection', () => { + const result = parseSelectedLogs([row, { ...row, timestamp: Infinity }], true) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues).toEqual([ + expect.objectContaining({ code: 'not_finite', path: [1, 'timestamp'] }), + ]) + } + }) +})