Add source selector for explorer query tab (#49063)

## Context

This is just pulling out the relevant changes from
https://github.com/supabase/supabase/pull/49028 as I might have messed
up the stack while making changes down the PRs 🙏

Builds on the Query Tab in the Explorer UI, adds the source selector
component to run either a database query or a logs query - will
subsequently be looking into have the source selector component in the
QueryCell as well (within notebooks)

<img width="1095" height="594" alt="image"
src="https://github.com/user-attachments/assets/ab6e8d91-aed3-4845-9777-acbd7a3a7cb1"
/>
<img width="1086" height="667" alt="image"
src="https://github.com/user-attachments/assets/75d99063-755d-4ba6-a128-7ddcbbc9f161"
/>


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

## Summary by CodeRabbit

- **New Features**
  - Added query-source selection for Explorer and notebook queries.
- Supports log queries, database selection, and read-replica
connections.
- Added validation for custom log time ranges and retention limits, with
upgrade guidance when applicable.
  - Query source choices are saved and restored across sessions.
  - Changing sources clears previous results to prevent stale data.

- **Bug Fixes**
- Improved handling of unavailable log querying and missing database
connections.
- Legacy saved queries now fall back safely to the default database
source.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-08-14 01:07:20 +07:00
1 parent 93d9d80535
commit fc3f6aaea8
5 files changed
+323 -17

No files matched your search

@@ -0,0 +1,153 @@
import { useFlag, useParams } from 'common'
import dayjs from 'dayjs'
import { Check, ChevronDown } from 'lucide-react'
import { useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/DatabaseParametersSubMenu'
import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog'
import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu'
import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourceIcon'
import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils'
import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt'
import {
createDefaultCellSource,
QUERY_SOURCE_LABELS,
QUERY_SOURCES,
type CellSource,
} from '@/data/query-sources/query-source-registry'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
export type ExplorerQuerySourceMenuProps = {
source: CellSource
onSourceChange: (source: CellSource) => void
}
/**
* Source binding and parameter controls shared by standalone Explorer queries
* and notebook query-cell toolbars. The consumer owns the binding; this menu
* only emits complete, validated-by-construction `CellSource` values.
*/
export const ExplorerQuerySourceMenu = ({
source,
onSourceChange,
}: ExplorerQuerySourceMenuProps) => {
const { ref } = useParams()
const isLogsSourceEnabled = useFlag('sqlEditorLogsSource')
const isOtelLogsEnabled = useFlag('otelLegacyLogs')
const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false)
const [showUpgradePrompt, setShowUpgradePrompt] = useState(false)
const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
const entitledToLogDays = getEntitlementNumericValue()
const availableSources = QUERY_SOURCES.filter(
(candidate) =>
candidate.type !== 'logs' ||
(isLogsSourceEnabled && isOtelLogsEnabled) ||
source.type === 'logs'
)
const applyCustomRange = ({ from, to }: { from: Date; to: Date }) => {
const fromIso = dayjs(from).startOf('day').toISOString()
if (maybeShowUpgradePromptIfNotEntitled(fromIso, entitledToLogDays)) {
setShowUpgradePrompt(true)
return
}
onSourceChange({
id: 'logs',
type: 'logs',
parameters: {
time_range: {
type: 'absolute',
from: fromIso,
to: dayjs(to).endOf('day').toISOString(),
},
},
})
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="text"
size="tiny"
aria-label={`Query source: ${QUERY_SOURCE_LABELS[source.id]}`}
icon={<QuerySourceIcon source={source.id} className="text-foreground-light" />}
iconRight={<ChevronDown className="text-foreground-light" />}
>
{QUERY_SOURCE_LABELS[source.id]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
{availableSources.map((candidate) => (
<DropdownMenuItem
key={candidate.id}
className="justify-between"
onSelect={(event) => {
event.preventDefault()
if (candidate.id !== source.id) {
onSourceChange(createDefaultCellSource(candidate.id))
}
}}
>
<span className="flex items-center gap-x-2">
<QuerySourceIcon source={candidate.id} className="text-foreground-light" />
{QUERY_SOURCE_LABELS[candidate.id]}
</span>
{source.id === candidate.id && <Check size={14} />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{source.type === 'database' ? (
<DatabaseParametersSubMenu
identifier={source.parameters.identifier ?? ref}
onIdentifierChange={(identifier) =>
onSourceChange({
id: 'database',
type: 'database',
parameters: { identifier },
})
}
/>
) : (
<LogsTimeRangeSubMenu
range={source.parameters.time_range}
onRangeChange={(timeRange) =>
onSourceChange({
id: 'logs',
type: 'logs',
parameters: { time_range: timeRange },
})
}
onOpenCustomRange={() => setIsCustomRangeOpen(true)}
onShowUpgrade={() => setShowUpgradePrompt(true)}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
{source.type === 'logs' && (
<>
<LogsCustomRangeDialog
open={isCustomRangeOpen}
onOpenChange={setIsCustomRangeOpen}
onApply={applyCustomRange}
/>
<UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
</>
)}
</>
)
}
@@ -1,8 +1,10 @@
import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta'
import { useFlag } from 'common'
import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { cn } from 'ui'
import { resolveLogTimeRange } from '../QuerySources/LogTimeRange.utils'
import {
ExplorerQuery,
ExplorerQueryEditor,
@@ -10,6 +12,7 @@ import {
ExplorerQueryResults,
ExplorerQueryViewport,
} from './ExplorerQuery'
import { ExplorerQuerySourceMenu } from './ExplorerQuerySourceMenu'
import {
ExplorerToolbar,
ExplorerToolbarAction,
@@ -23,6 +26,15 @@ import { QueryResultTable } from './QueryResultTable'
import { type QueryDisplay, type QueryResult } from './types'
import { applyAutoLimit } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
import { isValidConnString } from '@/data/fetchers'
import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation'
import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql'
import {
createDefaultCellSource,
QUERY_SOURCE_REGISTRY,
type CellSource,
} from '@/data/query-sources/query-source-registry'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { useLatest } from '@/hooks/misc/useLatest'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -32,6 +44,7 @@ export type QueryEditorProps = {
variant: 'embedded' | 'viewport'
title: string
sql: string
source?: CellSource
result?: QueryResult
rowLimit: number
display?: QueryDisplay
@@ -39,6 +52,7 @@ export type QueryEditorProps = {
onTitleChange: (title: string) => void
onSqlChange: (sql: string) => void
onSqlCommit?: (sql: string) => void
onSourceChange?: (source: CellSource) => void
onResultChange: (result: QueryResult) => void
onDisplayChange?: (display: QueryDisplay) => void
}
@@ -53,6 +67,7 @@ export const QueryEditor = ({
variant,
title,
sql,
source,
result,
rowLimit,
display,
@@ -60,35 +75,88 @@ export const QueryEditor = ({
onTitleChange,
onSqlChange,
onSqlCommit,
onSourceChange,
onResultChange,
onDisplayChange,
}: QueryEditorProps) => {
const sqlRef = useLatest(sql)
const onSqlCommitRef = useLatest(onSqlCommit)
const isOtelLogsEnabled = useFlag('otelLegacyLogs')
const { data: project, isPending: isLoadingProject } = useSelectedProjectQuery()
const view = display?.view ?? 'table'
const columns = Object.keys(result?.rows?.[0] ?? {})
const sourceBinding = source ?? createDefaultCellSource('database')
const [showQuery, setShowQuery] = useState(true)
const { mutate: executeSql, isPending: isExecuting } = useExecuteSqlMutation({
const databaseIdentifier =
sourceBinding.type === 'database' ? sourceBinding.parameters.identifier : undefined
const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery(
{ projectRef: project?.ref },
{
enabled:
databaseIdentifier !== undefined &&
project?.ref !== undefined &&
databaseIdentifier !== project.ref,
}
)
const { mutate: executeSql, isPending: isExecutingSql } = useExecuteSqlMutation({
onSuccess: (data) => onResultChange({ rows: data.result }),
onError: (error) => onResultChange({ error }),
})
const { mutate: executeLogsSql, isPending: isExecutingLogs } = useExecuteLogsSqlMutation({
onSuccess: (data) => onResultChange({ rows: data.rows as readonly Record<string, unknown>[] }),
onError: (error) => onResultChange({ error }),
})
const isResolvingDatabase =
databaseIdentifier !== undefined && databaseIdentifier !== project?.ref && isLoadingDatabases
const isExecuting = isExecutingSql || isExecutingLogs
const isBusy = isLoadingProject || isResolvingDatabase || isExecuting
const handleRunQuery = (sqlToRun: string = sql) => {
if (!project || isLoadingProject || isExecuting || sqlToRun.trim().length === 0) return
if (!project || isBusy || sqlToRun.trim().length === 0) return
onSqlCommit?.(sql)
if (sourceBinding.type === 'logs') {
if (!isOtelLogsEnabled) {
onResultChange({
error: { message: "Querying logs isn't available for this project yet." },
})
return
}
executeLogsSql({
projectRef: project.ref,
sql: acceptUntrustedLogsSql(untrustedLogSql(sqlToRun)),
range: resolveLogTimeRange(sourceBinding.parameters.time_range),
endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint,
})
return
}
const safeSql = acceptUntrustedSql(untrustedSql(sqlToRun))
const limitedSql = applyAutoLimit(safeSql, rowLimit)
const connectionString =
databaseIdentifier === undefined || databaseIdentifier === project.ref
? project.connectionString
: databases?.find((database) => database.identifier === databaseIdentifier)
?.connectionString
if (!isValidConnString(connectionString)) {
onResultChange({ error: { message: 'Unable to run query: Connection string is missing' } })
return
}
executeSql({
projectRef: project.ref,
connectionString: project.connectionString,
connectionString,
sql: limitedSql.sql,
autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined,
contextualInvalidation: true,
@@ -107,6 +175,9 @@ export const QueryEditor = ({
<ExplorerToolbarTitle onSaveTitle={onTitleChange}>{title}</ExplorerToolbarTitle>
<ExplorerToolbarActions>
{toolbarActions}
{source && onSourceChange && (
<ExplorerQuerySourceMenu source={source} onSourceChange={onSourceChange} />
)}
{display && onDisplayChange && (
<DisplaySettingsButton
result={result}
@@ -75,6 +75,7 @@ export const QueryTab = () => {
variant="viewport"
title={draft.name}
sql={draft.uncheckedSql}
source={draft.source}
result={result}
rowLimit={QUERY_ROW_LIMIT}
onTitleChange={(value) => {
@@ -83,6 +84,7 @@ export const QueryTab = () => {
tabs.updateTab(createTabId('query', { id }), { label: name })
}}
onSqlChange={(sql) => explorerQueryState.updateDraft({ id, sql })}
onSourceChange={(source) => explorerQueryState.updateDraft({ id, source })}
onResultChange={handleResultChange}
/>
)
+45
View File
@@ -26,12 +26,57 @@ describe('explorer query drafts', () => {
expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(secondState.drafts['query-1']).toMatchObject({
name: 'Active users',
source: { id: 'database', type: 'database', parameters: {} },
uncheckedSql: 'select * from users',
projectRef: 'project-a',
})
expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-b' })).toBe(false)
})
it('persists source parameters and clears stale results when they change', () => {
const storage = createMemoryStorage()
const state = createExplorerQueryState(storage)
state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select 1' })
state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } })
state.updateDraft({
id: 'query-1',
source: {
id: 'logs',
type: 'logs',
parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } },
},
})
expect(state.results['query-1']).toBeUndefined()
const restored = createExplorerQueryState(storage)
expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(restored.drafts['query-1'].source).toEqual({
id: 'logs',
type: 'logs',
parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } },
})
})
it('restores pre-source drafts as database queries', () => {
const storage = createMemoryStorage()
storage.setItem(
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
JSON.stringify({
'query-1': { name: 'Legacy query', sql: 'select 1', updatedAt: 1 },
})
)
const state = createExplorerQueryState(storage)
expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(state.drafts['query-1'].source).toEqual({
id: 'database',
type: 'database',
parameters: {},
})
})
it('removes the persisted draft and its session result when its tab closes', () => {
const storage = createMemoryStorage()
const state = createExplorerQueryState(storage)
+49 -14
View File
@@ -3,11 +3,17 @@ import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common'
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
import { type QueryResult } from '@/components/interfaces/Explorer/types'
import {
cellSourceSchema,
createDefaultCellSource,
type CellSource,
} from '@/data/query-sources/query-source-registry'
export type ExplorerQueryDraft = {
id: string
projectRef: string
name: string
source: CellSource
uncheckedSql: UntrustedSqlFragment
updatedAt: number
}
@@ -18,6 +24,7 @@ export type ExplorerQueryResult = QueryResult & {
type PersistedExplorerQueryDraft = {
name: string
source: CellSource
sql: string
updatedAt: number
}
@@ -35,18 +42,27 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, PersistedExplorerQueryDraft] => {
const draft = entry[1]
return (
draft !== null &&
typeof draft === 'object' &&
'name' in draft &&
typeof draft.name === 'string' &&
'sql' in draft &&
typeof draft.sql === 'string' &&
'updatedAt' in draft &&
typeof draft.updatedAt === 'number'
)
Object.entries(parsed).flatMap(([id, value]) => {
if (
value === null ||
typeof value !== 'object' ||
!('name' in value) ||
typeof value.name !== 'string' ||
!('sql' in value) ||
typeof value.sql !== 'string' ||
!('updatedAt' in value) ||
typeof value.updatedAt !== 'number'
) {
return []
}
const parsedSource =
'source' in value ? cellSourceSchema.safeParse(value.source) : { success: false as const }
const source = parsedSource.success
? parsedSource.data
: createDefaultCellSource('database')
return [[id, { name: value.name, source, sql: value.sql, updatedAt: value.updatedAt }]]
})
)
} catch {
@@ -74,23 +90,26 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
projectRef,
name = 'Untitled query',
sql = '',
source = createDefaultCellSource('database'),
}: {
id: string
projectRef: string
name?: string
sql?: string
source?: CellSource
}) => {
const draft: ExplorerQueryDraft = {
id,
projectRef,
name,
source: cellSourceSchema.parse(source),
uncheckedSql: untrustedSql(sql),
updatedAt: Date.now(),
}
state.drafts[id] = draft
const persisted = readPersistedDrafts(storage, projectRef)
persisted[id] = { name, sql, updatedAt: draft.updatedAt }
persisted[id] = { name, source: draft.source, sql, updatedAt: draft.updatedAt }
writePersistedDrafts(storage, projectRef, persisted)
return id
@@ -106,23 +125,39 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
id,
projectRef,
name: persisted.name,
source: persisted.source,
uncheckedSql: untrustedSql(persisted.sql),
updatedAt: persisted.updatedAt,
}
return true
},
updateDraft: ({ id, name, sql }: { id: string; name?: string; sql?: string }) => {
updateDraft: ({
id,
name,
source,
sql,
}: {
id: string
name?: string
source?: CellSource
sql?: string
}) => {
const draft = state.drafts[id]
if (!draft) return
if (name !== undefined) draft.name = name
if (source !== undefined) {
draft.source = cellSourceSchema.parse(source)
delete state.results[id]
}
if (sql !== undefined) draft.uncheckedSql = untrustedSql(sql)
draft.updatedAt = Date.now()
const persisted = readPersistedDrafts(storage, draft.projectRef)
persisted[id] = {
name: draft.name,
source: draft.source,
sql: draft.uncheckedSql,
updatedAt: draft.updatedAt,
}