mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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:
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>
|
||||
)
|
||||
}
|
||||
+33
-35
@@ -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'] }),
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user