Files
Alaister YoungandAlaister Young 240bfce7f6 [FE-4198] feat(studio): select a range of logs with shift-click (#50381)
Shift-clicking a log row checkbox now selects every row between the last
clicked row and the clicked one, so you can grab a consecutive block of
logs to copy without checking each one. Applies everywhere the shared
`LogTable` renders: Postgres/API/Auth/Edge Functions logs and the Logs
Explorer.

**Added:**
- `getShiftClickSelection` in `Logs.utils.ts`: pure helper that computes
the next selection from the ordered row keys, the current selection, the
anchor row, and the clicked row. Adds the inclusive range in either
direction. If the whole range is already selected it deselects the range
instead. Falls back to a plain toggle when there's no usable anchor.
Covered by unit tests, plus `LogTable` component tests for range select,
the no-anchor fallback, anchor clearing, and range deselect.

**Changed:**
- `LogTable` tracks the last toggled row as the range anchor (a ref,
since it's only read in handlers). The anchor is set by plain clicks,
shift-clicks, and the Shift+Space row toggle, and cleared whenever the
selection becomes empty (toggling off the last row, plain row click,
Escape, action bar clear, select-all then deselect-all, or a new query
loading).
- The checkbox cell handles `onClick` instead of `onCheckedChange` so
the shift key is available. Keyboard Space on a focused checkbox still
toggles it, since Radix dispatches a click for it.
- A shift mousedown on the checkbox cell is prevented so the browser
doesn't start a text selection across rows.

Unified Logs has its own row selection (TanStack Table) and is not
changed here.

## To test

- Open any log page with a decent number of rows, e.g. Postgres logs.
Click one checkbox, then shift-click a checkbox several rows below.
Every row in between should be checked and the action bar should show
the count. Repeat upward.
- Shift-click a range that's already fully selected: the range should
clear, and rows outside it stay as they were.
- Plain-click a row's message text (not the checkbox): side panel opens
and the selection clears. A following shift-click should just toggle
that one row.
- Press Escape or the action bar's clear button, then shift-click: also
just a single toggle.
- Focus a row with the arrow keys, press Shift+Space, then shift-click a
lower checkbox: the range should extend from the keyboard-toggled row.
- Tab to a checkbox and press Space: it should still toggle.
- After a shift-click, confirm no text is highlighted across the rows.
- Copy as JSON/Markdown/CSV still copies the selected rows in display
order.


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

- **New Features**
- Added shift-click range selection to the logs table for selecting or
deselecting consecutive rows.
  - Preserved single-row selection when range selection is unavailable.
- Improved selection behavior when clearing selections or changing log
queries, preventing stale range anchors.

- **Tests**
- Added coverage for forward and reverse range selection, deselection,
partial selections, fallback behavior, and input immutability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-09-16 18:18:28 +08:00

472 lines
16 KiB
TypeScript

import { describe, expect, test } from 'vitest'
import { LogsTableName } from './Logs.constants'
import type { Filters, LogData } from './Logs.types'
import {
buildLogsPrompt,
checkForLimitClause,
ensureNoTimestampConflict,
extractEdgeFunctionName,
formatLogsAsCsv,
formatLogsAsJson,
formatLogsAsMarkdown,
genChartQuery,
genDefaultQuery,
getAuthLogSeverity,
getShiftClickSelection,
parseMultigresEventMessage,
} from './Logs.utils'
const createLog = (overrides: Partial<LogData> = {}): LogData => ({
id: 'test-id',
timestamp: 1621323232312,
event_message: 'test message',
...overrides,
})
describe('Logs.utils', () => {
describe('formatLogsAsJson', () => {
test('formats single log as JSON', () => {
const rows: LogData[] = [createLog({ id: '1', event_message: 'test message' })]
const result = formatLogsAsJson(rows)
expect(result).toContain('"id": "1"')
expect(result).toContain('"event_message": "test message"')
})
test('formats multiple logs as JSON array', () => {
const rows: LogData[] = [
createLog({ id: '1', event_message: 'first' }),
createLog({ id: '2', event_message: 'second' }),
]
const result = formatLogsAsJson(rows)
expect(result).toContain('"id": "1"')
expect(result).toContain('"id": "2"')
})
})
describe('formatLogsAsCsv', () => {
test('returns empty string for empty list', () => {
expect(formatLogsAsCsv([])).toBe('')
})
test('formats single row with header line', () => {
const rows: LogData[] = [createLog({ id: '1', event_message: 'hello' })]
const result = formatLogsAsCsv(rows)
const [header, row] = result.split('\r\n')
expect(header.split(',').sort()).toEqual(['event_message', 'id', 'timestamp'].sort())
expect(row).toContain('1')
expect(row).toContain('hello')
})
test('formats multiple rows', () => {
const rows: LogData[] = [
createLog({ id: '1', event_message: 'first' }),
createLog({ id: '2', event_message: 'second' }),
]
const lines = formatLogsAsCsv(rows).split('\r\n')
expect(lines).toHaveLength(3)
expect(lines[1]).toContain('first')
expect(lines[2]).toContain('second')
})
test('escapes commas, quotes, and newlines per RFC 4180', () => {
const rows: LogData[] = [
createLog({
id: 'a,b',
event_message: 'line1\nline2',
}),
createLog({
id: 'c"d',
event_message: 'has "quotes"',
}),
]
const result = formatLogsAsCsv(rows)
expect(result).toContain('"a,b"')
expect(result).toContain('"line1\nline2"')
expect(result).toContain('"c""d"')
expect(result).toContain('"has ""quotes"""')
})
test('emits columns based on the first row', () => {
const rows: LogData[] = [
{ id: '1', event_message: 'first', timestamp: 1 },
// Extra `status` key on later rows is dropped because headers
// come from the first row.
{ id: '2', event_message: 'second', timestamp: 2, status: '500' } as LogData,
]
const result = formatLogsAsCsv(rows)
expect(result).not.toContain('500')
expect(result.split('\r\n')[0]).toBe('id,event_message,timestamp')
})
test('renders null as the string "null" and undefined as empty', () => {
const rows: LogData[] = [
{ id: '1', event_message: null as unknown as string, timestamp: undefined as any },
]
const result = formatLogsAsCsv(rows)
const dataRow = result.split('\r\n')[1]
// Order matches first-row keys: id, event_message, timestamp
expect(dataRow).toBe('1,null,')
})
})
describe('formatLogsAsMarkdown', () => {
test('formats single log with timestamp', () => {
const rows: LogData[] = [
createLog({
id: '123',
timestamp: 1621323232312,
event_message: 'Test error',
status: '500',
}),
]
const result = formatLogsAsMarkdown(rows)
expect(result).toContain('## Log 1')
expect(result).toContain('**Timestamp:**')
expect(result).toContain('**Message:** Test error')
expect(result).toContain('**Details:**')
})
test('formats multiple logs with separators', () => {
const rows: LogData[] = [
createLog({ id: '1', event_message: 'first error' }),
createLog({ id: '2', event_message: 'second error' }),
]
const result = formatLogsAsMarkdown(rows)
expect(result).toContain('## Log 1')
expect(result).toContain('## Log 2')
expect(result).toContain('---')
})
})
describe('buildLogsPrompt', () => {
test('builds prompt with single log', () => {
const rows: LogData[] = [createLog({ id: '1', event_message: 'error occurred' })]
const result = buildLogsPrompt(rows)
expect(result).toContain('1 Supabase log entry')
expect(result).toContain('error occurred')
expect(result).toContain('What do these logs indicate')
})
test('builds prompt with multiple logs', () => {
const rows: LogData[] = [
createLog({ id: '1', event_message: 'error 1' }),
createLog({ id: '2', event_message: 'error 2' }),
]
const result = buildLogsPrompt(rows)
expect(result).toContain('2 Supabase log entries')
})
test('handles singular correctly', () => {
const rows: LogData[] = [createLog({ id: '1', event_message: 'single error' })]
const result = buildLogsPrompt(rows)
expect(result).toContain('1 Supabase log entry')
})
})
describe('extractEdgeFunctionName', () => {
test('extracts function name from full pathname', () => {
expect(extractEdgeFunctionName('/functions/v1/hello-world-1')).toBe('hello-world-1')
})
test('returns empty string for null', () => {
expect(extractEdgeFunctionName(null)).toBe('')
})
test('returns empty string for undefined', () => {
expect(extractEdgeFunctionName(undefined)).toBe('')
})
test('returns empty string for non-string values', () => {
expect(extractEdgeFunctionName(42)).toBe('')
expect(extractEdgeFunctionName({})).toBe('')
})
test('handles pathname with no slashes', () => {
expect(extractEdgeFunctionName('my-function')).toBe('my-function')
})
test('handles trailing slash', () => {
expect(extractEdgeFunctionName('/functions/v1/hello-world-1/')).toBe('hello-world-1')
})
})
describe('parseMultigresEventMessage', () => {
test('parses a JSON object event_message into a plain object', () => {
const eventMessage = JSON.stringify({
time: '2026-06-02T15:44:52.84043038Z',
level: 'ERROR',
msg: 'Failed to write heartbeat',
error: 'context deadline exceeded',
})
expect(parseMultigresEventMessage(eventMessage)).toEqual({
time: '2026-06-02T15:44:52.84043038Z',
level: 'ERROR',
msg: 'Failed to write heartbeat',
error: 'context deadline exceeded',
})
})
test('returns null when event_message is not valid JSON', () => {
expect(parseMultigresEventMessage('connection closed')).toBeNull()
})
test('returns null when event_message parses to an array', () => {
expect(parseMultigresEventMessage('[1, 2, 3]')).toBeNull()
})
test('returns null when event_message parses to a primitive', () => {
expect(parseMultigresEventMessage('42')).toBeNull()
expect(parseMultigresEventMessage('"a string"')).toBeNull()
})
test('returns null for non-string input', () => {
expect(parseMultigresEventMessage(undefined)).toBeNull()
expect(parseMultigresEventMessage(null)).toBeNull()
expect(parseMultigresEventMessage(42)).toBeNull()
})
})
describe('getAuthLogSeverity', () => {
test('reports server errors (5xx) as error', () => {
expect(getAuthLogSeverity('info', 500)).toBe('error')
expect(getAuthLogSeverity('info', 503)).toBe('error')
})
test('reports client errors (4xx) as warning', () => {
expect(getAuthLogSeverity('info', 400)).toBe('warning')
expect(getAuthLogSeverity('info', 401)).toBe('warning')
expect(getAuthLogSeverity('info', 429)).toBe('warning')
expect(getAuthLogSeverity('info', 499)).toBe('warning')
})
test('handles status passed as a string', () => {
expect(getAuthLogSeverity('info', '500')).toBe('error')
expect(getAuthLogSeverity('info', '404')).toBe('warning')
expect(getAuthLogSeverity('info', '200')).toBe('info')
})
test('preserves the original level for non-error statuses', () => {
expect(getAuthLogSeverity('info', 200)).toBe('info')
expect(getAuthLogSeverity('info', 302)).toBe('info')
expect(getAuthLogSeverity('info', 399)).toBe('info')
})
test('falls back to the level when status is missing or invalid', () => {
expect(getAuthLogSeverity('info')).toBe('info')
expect(getAuthLogSeverity('warning', null)).toBe('warning')
expect(getAuthLogSeverity('info', 'not-a-number')).toBe('info')
})
test('keeps explicit error levels even with a non-error status', () => {
expect(getAuthLogSeverity('error')).toBe('error')
expect(getAuthLogSeverity('fatal')).toBe('fatal')
expect(getAuthLogSeverity('error', 200)).toBe('error')
expect(getAuthLogSeverity('fatal', 404)).toBe('fatal')
})
test('returns an empty string when level is missing', () => {
expect(getAuthLogSeverity()).toBe('')
expect(getAuthLogSeverity(null, 200)).toBe('')
})
test('ignores non-string levels and non-numeric statuses', () => {
expect(getAuthLogSeverity({}, {})).toBe('')
expect(getAuthLogSeverity(42, 500)).toBe('error')
expect(getAuthLogSeverity('info', {})).toBe('info')
expect(getAuthLogSeverity('info', [500])).toBe('info')
})
})
describe('auth_logs severity filter query', () => {
const queryFor = (severity: Filters['severity']) =>
genDefaultQuery(LogsTableName.AUTH, { severity } as Filters, 100)
test('error severity filter matches 5xx status as well as the log level', () => {
const sql = queryFor({ error: true })
expect(sql).toContain("IFNULL(metadata.level, '') IN ('error', 'fatal')")
expect(sql).toContain('IFNULL(SAFE_CAST(metadata.status AS INT64), 0) >= 500')
})
test('warning severity filter matches 4xx status as well as the log level', () => {
const sql = queryFor({ warning: true })
expect(sql).toContain("IFNULL(metadata.level, '') = 'warning'")
expect(sql).toContain('IFNULL(SAFE_CAST(metadata.status AS INT64), 0) BETWEEN 400 AND 499')
})
test('info severity filter excludes error and warning rows', () => {
const sql = queryFor({ info: true })
expect(sql).toContain('NOT (')
})
})
describe('checkForLimitClause', () => {
test('detects a limit clause regardless of casing', () => {
expect(checkForLimitClause('select event_message from edge_logs limit 100')).toBe(true)
expect(checkForLimitClause('SELECT event_message FROM edge_logs LIMIT 5')).toBe(true)
})
test('detects a limit clause across newlines', () => {
expect(checkForLimitClause('select event_message\nfrom edge_logs\nlimit 50')).toBe(true)
})
test('returns false when no limit clause is present', () => {
expect(checkForLimitClause('select event_message from edge_logs')).toBe(false)
})
test('returns false for a bare limit keyword without a row count', () => {
expect(checkForLimitClause('select event_message from edge_logs limit')).toBe(false)
})
test('ignores a column named like limit', () => {
expect(checkForLimitClause('select limit_reached from edge_logs')).toBe(false)
})
test('ignores the word limit inside a string literal', () => {
expect(checkForLimitClause("select event_message from edge_logs where s = 'limit 10'")).toBe(
false
)
})
test('ignores a limit clause that is commented out', () => {
expect(checkForLimitClause('select event_message from edge_logs -- limit 10')).toBe(false)
})
})
describe('genChartQuery', () => {
// Regression: an unparseable iso_timestamp_start used to reach
// startOffset.toISOString() as an Invalid Date and throw RangeError.
test('falls back to minute buckets for an unparseable time range without throwing', () => {
const params = { iso_timestamp_start: 'not-a-date', iso_timestamp_end: 'also-bad' }
expect(() => genChartQuery(LogsTableName.AUTH, params as any, {})).not.toThrow()
expect(genChartQuery(LogsTableName.AUTH, params as any, {})).toContain(
'timestamp_trunc(t.timestamp, minute)'
)
})
})
describe('ensureNoTimestampConflict', () => {
test('does not throw for unparseable initial timestamps', () => {
expect(() =>
ensureNoTimestampConflict(['not-a-date', 'also-bad'], ['', '2024-01-01T00:00:00.000Z'])
).not.toThrow()
})
})
describe('getShiftClickSelection', () => {
const orderedKeys = ['a', 'b', 'c', 'd', 'e']
test('selects the range downward from the anchor', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['b']),
anchorKey: 'b',
targetKey: 'd',
})
expect([...result].sort()).toEqual(['b', 'c', 'd'])
})
test('selects the range upward from the anchor', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['d']),
anchorKey: 'd',
targetKey: 'b',
})
expect([...result].sort()).toEqual(['b', 'c', 'd'])
})
test('toggles just the anchor when it is also the target', () => {
const added = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(),
anchorKey: 'c',
targetKey: 'c',
})
expect([...added]).toEqual(['c'])
const removed = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['c']),
anchorKey: 'c',
targetKey: 'c',
})
expect([...removed]).toEqual([])
})
test('removes the range when it is already fully selected, keeping keys outside it', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a', 'b', 'c', 'd']),
anchorKey: 'b',
targetKey: 'd',
})
expect([...result].sort()).toEqual(['a'])
})
test('selects the whole range when it is only partially selected', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['c']),
anchorKey: 'b',
targetKey: 'd',
})
expect([...result].sort()).toEqual(['b', 'c', 'd'])
})
test('falls back to adding the target when there is no anchor', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a']),
anchorKey: null,
targetKey: 'c',
})
expect([...result].sort()).toEqual(['a', 'c'])
})
test('falls back to removing the target when there is no anchor and it is selected', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a', 'c']),
anchorKey: null,
targetKey: 'c',
})
expect([...result].sort()).toEqual(['a'])
})
test('falls back to a plain toggle when the anchor is no longer in the rows', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(),
anchorKey: 'gone',
targetKey: 'd',
})
expect([...result]).toEqual(['d'])
})
test('falls back to a plain toggle when the target is not in the rows', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a']),
anchorKey: 'a',
targetKey: 'gone',
})
expect([...result].sort()).toEqual(['a', 'gone'])
})
test('does not mutate its inputs', () => {
const selectedKeys = new Set(['b'])
const keys = [...orderedKeys]
const result = getShiftClickSelection({
orderedKeys: keys,
selectedKeys,
anchorKey: 'b',
targetKey: 'd',
})
expect(result).not.toBe(selectedKeys)
expect([...selectedKeys]).toEqual(['b'])
expect(keys).toEqual(orderedKeys)
})
})
})