Add logs cells (#49064)

## Context

Related to Explorer/Notebooks - adds the source selector for query cell
within a notebook

<img width="1108" height="648" alt="image"
src="https://github.com/user-attachments/assets/d1434197-3738-41b0-a8ef-1919c91181d9"
/>

<img width="1103" height="633" alt="image"
src="https://github.com/user-attachments/assets/97f8c6a3-629d-4a41-aab4-3fdc5e8b2c7e"
/>


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

* **New Features**
  * Added support for displaying log cells in the query editor.
  * Added switching between database and log query sources.
  * Log and database cells can display results as tables or charts.
  * Log queries support optional row limits.
  * Improved reliability when changing query settings.
  * Notebook query views now default to table display when unspecified.

* **Bug Fixes**
  * Log cells no longer appear blank or get omitted from notebook views.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-08-14 02:23:06 +07:00
1 parent 794e45378c
commit 2f89014f74
8 files changed
+131 -45

No files matched your search

@@ -116,13 +116,9 @@ export const NotebookEditor = () => {
switch (cell._tag) {
case 'markdown_cell':
return <MarkdownCell key={cell.id} cell={cell} />
case 'database_cell':
return <QueryCell key={cell.id} cell={cell} />
case 'log_cell':
// [Joshen] Will eventually hook it up
return null
return <QueryCell key={cell.id} cell={cell} />
}
})}
</div>
@@ -7,11 +7,19 @@ import { MoveCellDropdownContent } from '../MoveCellDropdownContent'
import { QueryEditor } from '../QueryEditor'
import { type QueryDisplay, type QueryResult } from '../types'
import { SortableSection } from '@/components/ui/SortableSection'
import { type DatabaseCell as DatabaseCellSchema } from '@/data/content/notebooks/notebook-schema'
import {
type DatabaseCell as DatabaseCellSchema,
type LogCell as LogCellSchema,
} from '@/data/content/notebooks/notebook-schema'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
import {
createDefaultCellSource,
type CellSource,
} from '@/data/query-sources/query-source-registry'
import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
interface QueryCellProps {
cell: Snapshot<DatabaseCellSchema>
cell: Snapshot<DatabaseCellSchema | LogCellSchema>
}
/**
@@ -20,9 +28,6 @@ interface QueryCellProps {
* - Database selection logic
*
* QueryCell atm minimally supports running queries and rendering results
*
* [Joshen] TODO: handleUpdateCell might be able to shift into notebook-state, so component
* doesn't need to have context of the other cells
*/
type QueryCellUpdate = { sql: string } | { title: string } | { display: QueryDisplay }
@@ -31,41 +36,88 @@ type QueryCellUpdate = { sql: string } | { title: string } | { display: QueryDis
export const QueryCell = ({ cell }: QueryCellProps) => {
const snap = useNotebooksStateSnapshot()
const currentNotebook = useCurrentNotebook()
const cells = currentNotebook?.notebook.content?.cells ?? []
const [sql, setSql] = useState<string>(cell.unchecked_sql)
const { id, title: cellTitle, view, chart, unchecked_sql } = cell
const rowLimit = 'row_limit' in cell ? cell.row_limit : undefined
const source =
cell._tag === 'database_cell'
? createDefaultCellSource('database')
: createDefaultCellSource('logs')
const [sql, setSql] = useState<string>(unchecked_sql)
const [result, setResult] = useState<QueryResult>()
const title = cell.title ?? 'Untitled snippet'
const title = cellTitle ?? 'Untitled snippet'
const display: QueryDisplay = {
view: cell.view ?? 'table',
chart: cell.chart ? { ...cell.chart, y_columns: [...cell.chart.y_columns] } : undefined,
view: view ?? 'table',
chart: chart ? { ...chart, y_columns: [...chart.y_columns] } : undefined,
}
const handleSourceChange = (source: CellSource) => {
const notebookId = currentNotebook?.notebook.id
if (!notebookId) return
snap.updateCell({
id: notebookId,
cellId: id,
updater: (candidate) => {
if (source.type === 'database' && candidate._tag === 'log_cell') {
const { _tag, time_range, unchecked_sql, ...rest } = candidate
return {
...rest,
_tag: 'database_cell' as const,
row_limit: 100,
unchecked_sql: untrustedSql(unchecked_sql),
}
}
if (source.type === 'logs' && candidate._tag === 'database_cell') {
const { _tag, row_limit, unchecked_sql, ...rest } = candidate
return {
...rest,
_tag: 'log_cell' as const,
time_range: {
_tag: 'relative_time_range' as const,
unit: 'hour' as const,
amount: 1,
},
unchecked_sql: untrustedLogSql(unchecked_sql),
}
}
return candidate
},
})
}
const handleUpdateCell = (payload: QueryCellUpdate) => {
const notebookId = currentNotebook?.notebook.id
if (!notebookId) return
const nextCells = cells.map((candidate) => {
if (candidate.id !== cell.id || candidate._tag !== 'database_cell') return candidate
snap.updateCell({
id: notebookId,
cellId: id,
updater: (candidate) => {
if (candidate._tag !== 'database_cell' && candidate._tag !== 'log_cell') return candidate
if ('sql' in payload) {
return { ...candidate, unchecked_sql: untrustedSql(payload.sql) }
}
if ('sql' in payload) {
return candidate._tag === 'database_cell'
? { ...candidate, unchecked_sql: untrustedSql(payload.sql) }
: { ...candidate, unchecked_sql: untrustedLogSql(payload.sql) }
}
if ('title' in payload) {
const nextTitle = payload.title.trim()
return nextTitle ? { ...candidate, title: nextTitle } : candidate
}
if ('title' in payload) {
const nextTitle = payload.title.trim()
return nextTitle ? { ...candidate, title: nextTitle } : candidate
}
return {
...candidate,
view: payload.display.view,
chart: payload.display.chart,
}
return {
...candidate,
view: payload.display.view,
chart: payload.display.chart,
}
},
})
snap.updateCells({ id: notebookId, cells: nextCells })
}
return (
@@ -76,18 +128,22 @@ export const QueryCell = ({ cell }: QueryCellProps) => {
gripClassName="mt-2 opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition"
>
<QueryEditor
id={cell.id}
id={id}
variant="embedded"
title={title}
sql={sql}
source={source}
result={result}
rowLimit={cell.row_limit}
display={display}
rowLimit={rowLimit}
display={cell._tag === 'database_cell' ? display : undefined}
onTitleChange={(title) => handleUpdateCell({ title })}
onSqlChange={setSql}
onSqlCommit={(sql) => handleUpdateCell({ sql })}
onSourceChange={handleSourceChange}
onResultChange={setResult}
onDisplayChange={(display) => handleUpdateCell({ display })}
onDisplayChange={
cell._tag === 'database_cell' ? (display) => handleUpdateCell({ display }) : undefined
}
/>
</SortableSection>
)
@@ -46,7 +46,7 @@ export type QueryEditorProps = {
sql: string
source?: CellSource
result?: QueryResult
rowLimit: number
rowLimit?: number
display?: QueryDisplay
toolbarActions?: ReactNode
onTitleChange: (title: string) => void
@@ -238,8 +238,12 @@ export const QueryEditor = ({
<ExplorerQueryFooter className="flex items-center gap-x-2">
<p>{(result?.rows ?? []).length.toLocaleString()} rows</p>
<p>·</p>
<p>Limit {rowLimit} rows</p>
{rowLimit && (
<>
<p>·</p>
<p>Limit {rowLimit} rows</p>
</>
)}
</ExplorerQueryFooter>
</Shell>
)
@@ -118,6 +118,7 @@ describe('remapSqlContentField', () => {
_tag: 'database_cell',
id: DATABASE_CELL.id,
row_limit: 100,
view: 'table',
unchecked_sql: untrustedSql(DATABASE_CELL.sql),
})
expect(databaseCell).not.toHaveProperty('sql')
@@ -125,6 +126,7 @@ describe('remapSqlContentField', () => {
_tag: 'log_cell',
id: LOG_CELL.id,
time_range: LOG_CELL.time_range,
view: 'table',
unchecked_sql: untrustedLogSql(LOG_CELL.sql),
})
expect(logCell).not.toHaveProperty('sql')
@@ -225,6 +225,7 @@ describe('notebookDomainSchema', () => {
_tag: 'database_cell',
id: FULL_NOTEBOOK.cells[1].id,
row_limit: 100,
view: 'table',
unchecked_sql: untrustedSql('select * from auth.users limit 100'),
})
expect(databaseCell).not.toHaveProperty('sql')
@@ -51,7 +51,7 @@ const databaseCellSchema = z.object({
title: z.string().optional(),
sql: z.string(),
row_limit: z.number(),
view: z.enum(['table', 'chart']).default('table').optional(),
view: z.enum(['table', 'chart']).optional(),
chart: chartConfigSchema.optional(),
})
@@ -61,6 +61,7 @@ const logCellSchema = z.object({
title: z.string().optional(),
sql: z.string(),
time_range: timeRangeSchema,
view: z.enum(['table', 'chart']).optional(),
chart: chartConfigSchema.optional(),
})
@@ -126,19 +127,19 @@ export const agentNotebookSchema = z.object({
export type AgentNotebook = z.infer<typeof agentNotebookSchema>
export type AgentCell = z.infer<typeof agentCellSchema>
// The domain shape: parses the same wire cell (`cellSchema`) and transforms `sql` into a
// branded `unchecked_sql`.
// The domain shape: parses the same wire cell (`cellSchema`), transforms `sql` into a
// branded `unchecked_sql`, and defaults `view` to 'table'
const cellDomainSchema = cellSchema.transform((cell) => {
switch (cell._tag) {
case 'markdown_cell':
return cell
case 'database_cell': {
const { sql, ...rest } = cell
return { ...rest, unchecked_sql: untrustedSql(sql) }
const { sql, view, ...rest } = cell
return { ...rest, view: view ?? 'table', unchecked_sql: untrustedSql(sql) }
}
case 'log_cell': {
const { sql, ...rest } = cell
return { ...rest, unchecked_sql: untrustedLogSql(sql) }
const { sql, view, ...rest } = cell
return { ...rest, view: view ?? 'table', unchecked_sql: untrustedLogSql(sql) }
}
}
})
@@ -208,12 +208,14 @@ describe('ai/tools/notebook-tools', () => {
_tag: 'database_cell',
id: 'cell-2',
row_limit: 100,
view: 'table',
sql: 'select * from auth.users limit 100',
},
{
_tag: 'log_cell',
id: 'cell-3',
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
view: 'table',
sql: 'select timestamp, event_message from edge_logs limit 10',
},
],
@@ -121,6 +121,30 @@ export const notebooksState = proxy({
notebooksState.updateCells({ id, cells: nextCells })
},
/**
* Update a single cell in a notebook's cell array via an updater callback.
* The caller decides how the cell's content should change (e.g. field
* defaults, tag conversion) since that's a UI concern, not a state one —
* this only finds the cell by id and re-saves the array.
*/
updateCell: ({
id,
cellId,
updater,
}: {
id: string
cellId: string
updater: (cell: Notebooks.Cell) => Notebooks.Cell
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const nextCells = stateNotebook.notebook.content.cells.map((cell) =>
cell.id === cellId ? updater(cell) : cell
)
notebooksState.updateCells({ id, cells: nextCells })
},
/**
* Remove a single cell from a notebook's cell array.
*/