feat: Add option to switch between SQL and natural language query mode

This commit is contained in:
Jordi Enric committed 2026-03-12 15:49:37 +01:00
1 parent 1201f1cdf9
commit 0eb6098fa1
4 files changed
+309 -51

No files matched your search

@@ -1,6 +1,7 @@
import { IS_PLATFORM } from 'common'
import Table from 'components/to-be-cleaned/Table'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { usePHFlag } from 'hooks/ui/useFlag'
import { DOCS_URL } from 'lib/constants'
import { BookOpen, Check, ChevronDown, Copy, ExternalLink, X } from 'lucide-react'
import Link from 'next/link'
@@ -36,6 +37,8 @@ export interface LogsQueryPanelProps {
onSelectTemplate: (template: LogTemplate) => void
onSelectSource: (source: string) => void
onDateChange: (value: DatePickerValue) => void
queryMode?: 'sql' | 'natural'
onQueryModeChange?: (mode: 'sql' | 'natural') => void
}
function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: string }) {
@@ -54,9 +57,12 @@ const LogsQueryPanel = ({
onSelectTemplate,
onSelectSource,
onDateChange,
queryMode = 'sql',
onQueryModeChange,
}: LogsQueryPanelProps) => {
const [showReference, setShowReference] = useState(false)
const { logsTemplates } = useIsFeatureEnabled(['logs:templates'])
const logsExplorerAiQuerying = usePHFlag('logsExplorerAiQuerying')
const {
projectAuthAll: authEnabled,
@@ -85,50 +91,74 @@ const LogsQueryPanel = ({
<div className="flex w-full items-center justify-between px-4 md:px-5 py-2 overflow-x-scroll no-scrollbar">
<div className="flex w-full flex-row items-center justify-between gap-x-4">
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Insert source
{IS_PLATFORM && logsExplorerAiQuerying && onQueryModeChange && (
<div className="flex items-center rounded-md border border-control overflow-hidden">
<Button
type={queryMode === 'sql' ? 'primary' : 'text'}
size="tiny"
className="rounded-none border-0"
onClick={() => onQueryModeChange('sql')}
>
SQL
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
className="max-h-[390px] overflow-auto"
>
{logsTableNames
.sort((a, b) => a.localeCompare(b))
.map((source) => (
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
<DropdownMenuItemContent
name={source}
desc={LOGS_SOURCE_DESCRIPTION[source]}
/>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
type={queryMode === 'natural' ? 'primary' : 'text'}
size="tiny"
className="rounded-none border-0 border-l border-control"
onClick={() => onQueryModeChange('natural')}
>
AI
</Button>
</div>
)}
{queryMode !== 'natural' && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Insert source
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
className="max-h-[390px] overflow-auto"
>
{logsTableNames
.sort((a, b) => a.localeCompare(b))
.map((source) => (
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
<DropdownMenuItemContent
name={source}
desc={LOGS_SOURCE_DESCRIPTION[source]}
/>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{IS_PLATFORM && logsTemplates && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Templates
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
{templates
.sort((a, b) => a.label!.localeCompare(b.label!))
.map((template) => (
<DropdownMenuItem
key={template.label}
onClick={() => onSelectTemplate(template)}
>
<p>{template.label}</p>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{IS_PLATFORM && logsTemplates && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Templates
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
{templates
.sort((a, b) => a.label!.localeCompare(b.label!))
.map((template) => (
<DropdownMenuItem
key={template.label}
onClick={() => onSelectTemplate(template)}
>
<p>{template.label}</p>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
<LogsDatePicker
@@ -0,0 +1,57 @@
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { constructHeaders, fetchHandler } from 'data/fetchers'
import { BASE_PATH } from 'lib/constants'
import { ResponseError, UseCustomMutationOptions } from 'types'
export type LogsQueryGenerateVariables = { prompt: string }
export type LogsQueryGenerateResponse = { sql: string }
export async function generateLogsQuery({ prompt }: LogsQueryGenerateVariables) {
const url = `${BASE_PATH}/api/ai/sql/logs-query-v1`
const headers = await constructHeaders({ 'Content-Type': 'application/json' })
const response = await fetchHandler(url, {
headers,
method: 'POST',
body: JSON.stringify({ prompt }),
})
if (!response.ok) {
let errorMessage = 'Failed to generate log query'
try {
const errorBody = await response.json()
errorMessage = errorBody?.error || errorBody?.message || errorMessage
} catch {}
throw new ResponseError(errorMessage, response.status)
}
return (await response.json()) as LogsQueryGenerateResponse
}
type LogsQueryGenerateData = Awaited<ReturnType<typeof generateLogsQuery>>
export const useLogsQueryGenerateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<LogsQueryGenerateData, ResponseError, LogsQueryGenerateVariables>,
'mutationFn'
> = {}) => {
return useMutation<LogsQueryGenerateData, ResponseError, LogsQueryGenerateVariables>({
mutationFn: (vars) => generateLogsQuery(vars),
async onSuccess(data, variables, context) {
await onSuccess?.(data, variables, context)
},
async onError(error, variables, context) {
if (onError === undefined) {
toast.error(`Failed to generate log query: ${error.message}`)
} else {
onError(error, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,127 @@
import { generateText } from 'ai'
import { source } from 'common-tags'
import { getModel } from 'lib/ai/model'
import apiWrapper from 'lib/api/apiWrapper'
import { NextApiRequest, NextApiResponse } from 'next'
import { logConstants } from 'shared-data'
import { z } from 'zod'
const requestSchema = z.object({
prompt: z.string().min(1).max(2000),
})
const LOG_TABLES = [
{ name: 'edge_logs', description: 'Logs obtained from the network edge, containing all API requests' },
{ name: 'postgres_logs', description: 'Database logs obtained directly from Postgres' },
{ name: 'function_logs', description: 'Function logs generated from runtime execution' },
{ name: 'function_edge_logs', description: 'Function call logs, containing the request and response' },
{ name: 'auth_logs', description: 'Errors, warnings, and performance details from the auth service' },
{ name: 'auth_audit_logs', description: 'Audit records of user signups, logins, and account changes' },
{ name: 'realtime_logs', description: 'Realtime server for Postgres logical replication broadcasting' },
{ name: 'storage_logs', description: 'Object storage logs' },
{ name: 'postgrest_logs', description: 'RESTful API web server logs' },
{ name: 'supavisor_logs', description: 'Shared connection pooler logs for PostgreSQL' },
{ name: 'pgbouncer_logs', description: 'Dedicated connection pooler for PostgreSQL' },
{ name: 'pg_upgrade_logs', description: 'Logs generated by the Postgres version upgrade process' },
{ name: 'pg_cron_logs', description: 'Postgres logs from pg_cron cron jobs' },
{ name: 'etl_replication_logs', description: 'Logs from the replication process' },
]
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
switch (method) {
case 'POST':
return handlePost(req, res)
default:
res.setHeader('Allow', ['POST'])
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
}
}
export async function handlePost(req: NextApiRequest, res: NextApiResponse) {
const parseResult = requestSchema.safeParse(req.body)
if (!parseResult.success) {
const errorMessage = parseResult.error.errors.map((e) => e.message).join(', ')
return res.status(400).json({ error: errorMessage })
}
const { prompt } = parseResult.data
try {
const {
model,
error: modelError,
providerOptions,
} = await getModel({
provider: 'openai',
routingKey: 'sql',
})
if (modelError) {
return res.status(500).json({ error: modelError.message })
}
const result = await generateText({
model,
providerOptions,
system: source`
You are an expert at writing SQL queries for Supabase Log Explorer.
The system uses BigQuery-compatible SQL syntax with standard PostgreSQL functions.
Available log tables:
${JSON.stringify(LOG_TABLES, null, 2)}
Schema fields per table (nested paths require UNNEST joins to access):
${JSON.stringify(
logConstants.schemas.map((s) => ({ table: s.reference, fields: s.fields })),
null,
2
)}
Key SQL syntax rules:
- To access nested metadata fields, use LEFT JOIN UNNEST(metadata) AS m ON TRUE
- For deeper nesting (e.g. request inside metadata): LEFT JOIN UNNEST(m.request) AS req ON TRUE
- Use cast(timestamp as datetime) for readable timestamps
- Use regexp_contains(field, r'pattern') for regex matching
- Always include a LIMIT clause (default to 100 if not specified)
- If the user doesn't specify a log source, default to edge_logs
- Return ONLY the SQL query — no markdown code blocks, no explanation, no comments
`,
prompt,
})
const sql = result.text.trim()
// Strip any markdown code blocks the model may have added
const cleanedSql = sql
.replace(/^```(?:sql)?\n?/i, '')
.replace(/\n?```$/i, '')
.trim()
return res.json({ sql: cleanedSql })
} catch (error) {
if (error instanceof Error) {
console.error(`AI log query generation failed: ${error.message}`)
if (error.message.includes('context_length') || error.message.includes('too long')) {
return res.status(400).json({
error:
'Your query prompt is too large for Supabase Assistant to ingest. Try a shorter description.',
})
}
} else {
console.error(`Unknown error: ${error}`)
}
return res.status(500).json({
error: 'There was an unknown error generating the log query. Please try again.',
})
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper
@@ -25,6 +25,7 @@ import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import LoadingOpacity from 'components/ui/LoadingOpacity'
import ShimmerLine from 'components/ui/ShimmerLine'
import { useLogsQueryGenerateMutation } from 'data/ai/logs-query-mutation'
import { useContentQuery } from 'data/content/content-query'
import {
UpsertContentPayload,
@@ -105,6 +106,8 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false)
const [warnings, setWarnings] = useState<LogsWarning[]>([])
const [selectedLog, setSelectedLog] = useState<LogData | null>(null)
const [queryMode, setQueryMode] = useState<'sql' | 'natural'>('sql')
const [nlQuery, setNlQuery] = useState<string>('')
const [recentLogs, setRecentLogs] = useLocalStorage<LogSqlSnippets.Content[]>(
`project-content-${projectRef}-recent-log-sql`,
@@ -169,6 +172,19 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
},
})
const { mutate: generateLogsQuery, isPending: isGenerating } = useLogsQueryGenerateMutation({
onSuccess: (data) => {
setEditorValue(data.sql)
handleRun(data.sql)
track('log_explorer_nl_query_generated')
},
})
const handleNlSubmit = () => {
if (!nlQuery.trim() || isGenerating) return
generateLogsQuery({ prompt: nlQuery.trim() })
}
const addRecentLogSqlSnippet = (snippet: Partial<LogSqlSnippets.Content>) => {
const defaults: LogSqlSnippets.Content = {
schema_version: '1',
@@ -389,16 +405,44 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
templates={allTemplates.filter((template) => template.mode === 'custom')}
onSelectTemplate={onSelectTemplate}
warnings={warnings}
queryMode={queryMode}
onQueryModeChange={setQueryMode}
/>
<ShimmerLine active={isLoading} />
<CodeEditor
id={editorId}
editorRef={editorRef}
language="pgsql"
defaultValue={editorValue}
onInputChange={(v) => setEditorValue(v || '')}
actions={{ runQuery: { enabled: true, callback: handleRun } }}
/>
<ShimmerLine active={isLoading || isGenerating} />
{queryMode === 'sql' ? (
<CodeEditor
id={editorId}
editorRef={editorRef}
language="pgsql"
defaultValue={editorValue}
onInputChange={(v) => setEditorValue(v || '')}
actions={{ runQuery: { enabled: true, callback: handleRun } }}
/>
) : (
<div className="flex flex-col h-full p-4 gap-3">
<textarea
className="flex-1 resize-none rounded-md border border-control bg-surface-100 px-3 py-2 text-sm placeholder:text-foreground-muted focus:outline-none focus:ring-2 focus:ring-brand disabled:opacity-50"
placeholder="Describe what logs you want to find... e.g. 'Show me all 500 errors from the last hour'"
value={nlQuery}
onChange={(e) => setNlQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleNlSubmit()
}}
disabled={isGenerating}
/>
<div className="flex items-center justify-between">
<span className="text-xs text-foreground-light">⌘+Enter to run</span>
<Button
type="primary"
loading={isGenerating}
disabled={isGenerating || !nlQuery.trim()}
onClick={handleNlSubmit}
>
Generate & Run
</Button>
</div>
</div>
)}
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel collapsible minSize="5" className="overflow-auto">