feat(studio): multi-select logs with click, shift, and keyboard ranges (#50689)

<img width="1454" height="786" alt="image"
src="https://github.com/user-attachments/assets/995790b1-a242-4a61-a0a0-349de7234923"
/>


Stack 4/4 · previous: #50688

## Problem

Selecting several logs to copy or send to the Assistant meant using a
separate checkbox column and a banner above the table. Clicking a row
and checking a row were two different selections.

## Solution

- Unified Logs uses `useTableRowSelection`, so a row click,
Cmd/Ctrl-click, Shift-click, and the checkbox all act on one selection.
The selection resets when the project or filters change, and a linked
`?id=` still restores its log.
- The level indicator and checkbox now share one column. The checkbox
shows on hover, focus, or when the row is selected.
- The detail panel shows every selected log: one log gets the tabs, and
two or more show their combined JSON. Copy-as-JSON and "Explain with AI"
move into the panel header (`LogSelectionActions`), replacing
`RowSelectionHeader`.
- New Shift+↑ / Shift+↓ shortcuts extend the selection, with a hint in
the panel footer. Prev/next navigation follows display order and keeps
the row in view.
- `LogTypeIcon` no longer puts a nested button inside clickable rows.

## Review instructions

1. Click a log, then Shift-click another. The range should be selected
and the panel should show the combined JSON.
2. Cmd-click to add or remove single logs. Use the copy button (or its
shortcut) and "Explain with AI".
3. With the panel open, press Shift+↑ / Shift+↓ to grow and shrink the
selection.
4. Change a filter. The selection should clear.

## Checklist

- [ ] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary

* **New Features**
* Added multi-row selection in Unified Logs, including additive, range,
checkbox, and keyboard selection.
* Added bulk JSON viewing and copying, plus AI-assisted actions for
selected logs.
* Added Shift+Arrow shortcuts to extend selections and improved row
navigation.
* **Accessibility**
* Improved labels and focus behavior for log controls and log type
indicators.
* **Bug Fixes**
* Kept single-log Overview and Raw JSON views available alongside
multi-log selection.
* Applied metadata visibility settings to selected-log actions and
disabled actions for invalid log data.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Saxon FletcherandClaude Opus 5 authored and GitHub committed 2026-09-25 19:03:01 +10:00
1 parent c3c741c20e
commit 9b12165400
13 files changed
+798 -316

No files matched your search

@@ -859,7 +859,11 @@ export function role(metadata: any) {
return payload.role
}
export function formatLogsAsJson(rows: LogData[]): string {
type LogExportData = Pick<LogData, 'id' | 'event_message'> & {
timestamp: string | number
} & Record<string, unknown>
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<string, unknown>[]) ?? ''
}
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)
@@ -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 (
<DataTableProvider
table={table}
columns={columns}
filterFields={[]}
error={null}
isError={false}
isLoading={false}
isFetching={false}
isLoadingCounts={false}
openRowId={activeId}
onSelectRow={onSelectRow}
setOpenRowId={(id) => (id ? onSelectRow(id) : clearSelection())}
>
<DataTableInfinite
columns={columns}
fetchNextPage={vi.fn()}
setColumnOrder={vi.fn()}
setColumnVisibility={vi.fn()}
/>
{activeId && !showPanel && <ServiceFlowPanelControls dock="right" setDock={vi.fn()} />}
{activeId && showPanel && (
<ResizablePanelGroup orientation="horizontal">
<ServiceFlowPanel
dock="right"
setDock={vi.fn()}
selectedRows={selectedRows.map((row) => row.original)}
searchParameters={searchParameters}
/>
</ResizablePanelGroup>
)}
</DataTableProvider>
)
}
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(<SelectionHarness />)
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(<SelectionHarness />)
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(<SelectionHarness />)
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(<SelectionHarness scope="new-filters" />)
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(<LogSelectionActions rows={[row]} />)
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(<LogSelectionActions rows={[{ ...logs[0], timestamp: Infinity }]} />)
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(<LogSelectionActions rows={rows} />, 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 = <SelectionHarness showPanel />, 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()
})
})
@@ -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<HTMLButtonElement>(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 (
<div className="flex items-center gap-1">
{!selectedLogs.success && (
<span role="alert" className="text-xs text-destructive">
Selected logs contain invalid data.
</span>
)}
<Shortcut
id={SHORTCUT_IDS.RESULTS_COPY_JSON}
onTrigger={handleCopyShortcut}
options={{ enabled: hasValidSelection, registerInCommandMenu: true }}
side="bottom"
label="Copy selected logs as JSON"
>
<CopyButton
ref={copyButtonRef}
iconOnly
size="tiny"
variant="text"
className="px-1"
aria-label="Copy selected logs"
disabled={!hasValidSelection}
asyncText={() => formatLogsAsJson(selectedRows)}
/>
</Shortcut>
<ButtonTooltip
size="tiny"
variant="text"
className="px-1"
icon={<AiIconAnimation size={16} />}
aria-label="Explain with AI"
disabled={!hasValidSelection}
tooltip={{ content: { side: 'bottom', text: 'Explain with AI' } }}
onClick={handleOpenAiAssistant}
/>
</div>
)
}
@@ -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)
}
@@ -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 (
<div className="relative">
<AnimatePresence>
{selectedRows.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: '36px', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
type: 'spring',
stiffness: 420,
damping: 30,
mass: 0.4,
}}
className="pl-4 pr-2 flex items-center justify-between border-t absolute top-0 z-2 bg-surface-75 w-full"
style={{ overflow: 'hidden' }}
>
<p className="text-xs">
{selectedRows.length} row{selectedRows.length > 1 ? 's' : ''} selected
</p>
<div className="flex items-center justify-center gap-x-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonTooltip
size="tiny"
icon={<Copy size={12} />}
className="w-7"
tooltip={{ content: { side: 'bottom', text: 'Copy selected logs' } }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => onCopy('json')} className="gap-2 text-xs">
<Copy size={13} />
Copy as JSON
<ShortcutBadge
shortcutId={SHORTCUT_IDS.RESULTS_COPY_JSON}
className="ml-auto"
/>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onCopy('markdown')} className="gap-2 text-xs">
<Copy size={13} />
Copy as Markdown
<ShortcutBadge
shortcutId={SHORTCUT_IDS.RESULTS_COPY_MARKDOWN}
className="ml-auto"
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AiAssistantDropdown
label="Explain with AI"
buildPrompt={() => buildLogsPrompt(selectedRows)}
onOpenAssistant={handleOpenAiAssistant}
telemetrySource="log_explorer"
/>
<ButtonTooltip
variant="text"
icon={<X />}
className="px-1"
onClick={() => table.resetRowSelection()}
tooltip={{ content: { side: 'bottom', text: 'Clear selection' } }}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@@ -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 (
<div className="flex h-7 items-center gap-1">
@@ -67,6 +58,7 @@ export const ServiceFlowPanelControls = ({
<Button
size="tiny"
variant="text"
aria-label="Previous log"
disabled={!prevId}
onClick={onPrev}
className="px-1"
@@ -83,6 +75,7 @@ export const ServiceFlowPanelControls = ({
<Button
size="tiny"
variant="text"
aria-label="Next log"
disabled={!nextId}
onClick={onNext}
className="px-1"
@@ -90,8 +83,6 @@ export const ServiceFlowPanelControls = ({
/>
</Shortcut>
<Separator orientation="vertical" className="mx-1 h-4" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonTooltip
@@ -125,7 +116,14 @@ export const ServiceFlowPanelControls = ({
options={{ conflictBehavior: 'allow' }}
side="top"
>
<Button size="tiny" variant="text" onClick={onClose} className="px-1" icon={<X />} />
<Button
aria-label="Clear selection"
size="tiny"
variant="text"
onClick={onClose}
className="px-1"
icon={<X />}
/>
</Shortcut>
</div>
)
@@ -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({
<div className="flex h-full flex-col overflow-hidden">
<div className="flex min-w-0 items-center gap-6 px-4 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<LogLevelDot level={selectedRow.level} />
</div>
{!hasMultiple && (
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<LogLevelDot level={selectedRow?.level} />
</div>
)}
<span
className="min-w-0 flex-1 truncate heading-meta text-foreground"
role="status"
@@ -63,29 +73,54 @@ export function ServiceFlowPanel({
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<LogSelectionActions rows={selectedRows} />
<ServiceFlowPanelControls dock={dock} setDock={setDock} />
</div>
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="shrink-0 gap-x-4 px-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="raw-json">Raw JSON</TabsTrigger>
<TabsIndicator />
</TabsList>
{['overview', 'raw-json'].map((tab) => (
<TabsContent
key={`${tab}-${selectedRow.id}`}
value={tab}
className="mt-0 min-h-0 flex-1 overflow-auto"
{hasMultiple ? (
<div
className="min-h-0 flex-1 overflow-auto"
role="region"
aria-label="Selected logs JSON"
>
<CodeBlock
language="json"
hideCopy
wrapperClassName="!overflow-visible bg-surface-100/50 [&_pre]:!bg-surface-100/50"
className="rounded-none border-none !overflow-x-visible [&_code]:!leading-tight [&_pre]:!leading-tight"
>
<LogDetail row={selectedRow} tab={tab} searchParameters={searchParameters} />
</TabsContent>
))}
</Tabs>
{JSON.stringify(selectedJson, null, 2)}
</CodeBlock>
</div>
) : (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="shrink-0 gap-x-4 px-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="raw-json">Raw JSON</TabsTrigger>
<TabsIndicator />
</TabsList>
{['overview', 'raw-json'].map((tab) => (
<TabsContent
key={`${tab}-${selectedRow?.id}`}
value={tab}
className="mt-0 min-h-0 flex-1 overflow-auto"
>
{selectedRow && (
<LogDetail row={selectedRow} tab={tab} searchParameters={searchParameters} />
)}
</TabsContent>
))}
</Tabs>
)}
<div className="flex shrink-0 flex-wrap items-center gap-2 border-t px-4 py-2 text-xs text-foreground-lighter">
<ShortcutBadge shortcutId={SHORTCUT_IDS.UNIFIED_LOGS_EXTEND_PREV_ROW} />
<ShortcutBadge shortcutId={SHORTCUT_IDS.UNIFIED_LOGS_EXTEND_NEXT_ROW} />
<span>Extend selection</span>
</div>
</div>
</ResizablePanel>
</>
@@ -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<SortingState>(defaultColumnSorting)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(defaultColumnFilters)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
// Last row the user toggled, used as the start of a shift-click range
const selectionAnchorRef = useRef<string | null>(null)
const [openRowId, setOpenRowId] = useState<string | undefined>(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<ColumnSchema> = 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 (
<DataTableProvider
table={table}
@@ -404,6 +412,7 @@ export const UnifiedLogs = () => {
rowSelection={rowSelection}
openRowId={openRowId}
setOpenRowId={setOpenRowId}
onSelectRow={handleSelectRow}
columnOrder={columnOrder}
columnVisibility={columnVisibility}
searchParameters={searchParameters}
@@ -484,8 +493,6 @@ export const UnifiedLogs = () => {
)}
</div>
<RowSelectionHeader />
<ResizablePanelGroup
key="main-logs"
className="flex-1 border-t"
@@ -504,7 +511,7 @@ 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 && (
<>
<LogsListPanel selectedRow={selectedRow} />
{selectedRows.length === 1 && <LogsListPanel selectedRow={selectedRow} />}
<ServiceFlowPanel
dock={dock}
setDock={setDock}
selectedRow={selectedRow?.original}
selectedRowKey={openRowId}
selectedRows={selectedRows.map((row) => row.original)}
searchParameters={searchParameters}
/>
</>
@@ -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<ColumnSchema, 'id' | 'timestamp' | 'event_message' | 'metadata'>
type ComputeRawLogData = Pick<ColumnSchema, 'id' | 'event_message' | 'metadata'> & {
timestamp: string | number
}
export function getRawLogData(
row: ColumnSchema | UnifiedLogInspectionEntry
): ColumnSchema | UnifiedLogInspectionEntry | ComputeRawLogData {
export function getRawLogData<T extends ComputeRawLogData>(row: T): T | ComputeRawLogData {
if (!('log_type' in row) || row.log_type !== 'compute') return row
return {
@@ -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<string | null>
}): {
export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): {
columns: ColumnDef<ColumnSchema>[]
columnVisibility: Record<string, boolean>
} {
@@ -48,53 +41,15 @@ export function generateDynamicColumns({
const columns: ColumnDef<ColumnSchema>[] = [
{
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 (
<Checkbox
className="hit-area-2 hover:border-foreground-muted"
checked={row.getIsSelected()}
// Prevent a shift-click from starting a browser text selection across rows
onMouseDown={(e) => {
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 }) => (
<LogSelectionIndicator
id={row.id}
level={row.original.level}
isSelected={row.getIsSelected()}
/>
),
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<ColumnSchema['level']>('level')
return level ? <DataTableColumnLevelIndicator value={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<ColumnSchema>[] = generateDynamicColumns({
data: [],
selectionAnchorRef: { current: null },
}).columns
function LogSelectionIndicator({
id,
level,
isSelected,
}: {
id: string
level: ColumnSchema['level']
isSelected: boolean
}) {
const { onSelectRow } = useDataTable()
return (
<div className="relative flex h-4 w-4 items-center justify-center">
<div
className={cn(
'pointer-events-none group-hover/row:opacity-0 group-focus-within/row:opacity-0',
isSelected && 'opacity-0'
)}
>
<LogLevelDot level={level} />
</div>
<Checkbox
aria-label="Select log"
tabIndex={-1}
className={cn(
'absolute inset-0 cursor-pointer opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 focus-visible:opacity-100',
isSelected && 'opacity-100'
)}
checked={isSelected}
onClick={(event) => {
event.stopPropagation()
onSelectRow?.(id, {
shiftKey: event.shiftKey,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
toggle: true,
})
}}
/>
</div>
)
}
@@ -38,8 +38,10 @@ export const LogTypeIcon = ({
return (
<Tooltip>
<TooltipTrigger>
<Icon size={size} strokeWidth={strokeWidth} className={className} />
<TooltipTrigger asChild>
<span className="inline-flex" role="img" aria-label={type}>
<Icon size={size} strokeWidth={strokeWidth} className={className} />
</span>
</TooltipTrigger>
<TooltipContent side="left">
<div className="text-xs">{type}</div>
@@ -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<UnifiedLogsShortcutId> = {
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',
@@ -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'] }),
])
}
})
})