mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 12:25:05 +03:00
feat: Add option to switch between SQL and natural language query mode
This commit is contained in:
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">
|
||||
|
||||
Reference in new issue
Block a user