mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): add feature to rewrite queries DEBUG-145 (#47266)
## Problem Moving the Logs Explorer to ClickHouse means users' saved BigQuery queries no longer run. <img width="2430" height="1010" alt="CleanShot 2026-06-29 at 11 36 04@2x" src="https://github.com/user-attachments/assets/ae0ab155-7d3d-4ae9-81c3-22bf3a88cf8c" /> ## Fix Rewrite the query with AI instead of a SQL transpiler. AI handles the long tail of nested fields and dialect differences far better than a rule-based rewriter, and it needs no extra runtime dependency. - `rewriteLogsSqlWithAI` posts the current query to `/api/ai/code/complete` with `dialect: 'clickhouse'`. The endpoint skips the Postgres schema and best-practices for that dialect and uses logs-specific instructions and model so the output is ClickHouse logs SQL (FROM `logs` + `source` filter, no `unnest` joins, nested fields read from `log_attributes['...']`). - The query's `source` is detected and its real `log_attributes` keys are fetched and passed to the model, so it maps to exact paths instead of guessing. - The rewrite runs in the background and is proposed as a side-by-side accept/discard diff in the editor. The AI Assistant panel is not opened. - Entry points: a banner shown only for legacy-looking queries (dismissal persisted), and a "Fix Query" button next to Field Reference. - The Field Reference drawers discover `log_attributes` keys from real data so the listed fields match what the source actually emits. ## Dependencies Built on top of #47265 (Logs Explorer -> OTEL endpoint) — that is the base branch of this PR. Merge #47265 first. Behind `otelLegacyLogs` (off by default). Part of DEBUG-145 (split from #47087). ## How to test - Open the Logs Explorer with a BigQuery logs query (the templates have some), click "Fix Query", and confirm the diff shows valid ClickHouse SQL. Accept it and confirm the applied query runs. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an OTEL legacy logs workflow (behind a feature flag) with an interactive banner and a “Fix Query” ClickHouse rewrite action, including an accept/discard diff review overlay. * Introduced OTEL-aware field reference rendering with dynamic discovery of `log_attributes` keys and updated OTEL source insertion behavior. * Enabled dialect-aware SQL completion for ClickHouse logs, using logs-specific instructions and output constraints. * **Bug Fixes** * Improved rewrite flow validation and handling, including log source detection and cleanup of AI-generated SQL formatting. * **Tests** * Added Vitest coverage for rewrite prompt generation, detection/classification utilities, SQL fence stripping, OTEL field mapping, and OTEL log attribute key discovery. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
15 files changed
+986
-229
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { otelFieldsFromKeys, toOtelFieldSchemas, type LogFieldSchema } from './Logs.fieldReference'
|
||||
|
||||
const edgeSchema: LogFieldSchema = {
|
||||
name: 'API Gateway',
|
||||
reference: 'edge_logs',
|
||||
fields: [
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'identifier', type: 'string' },
|
||||
{ path: 'metadata.request.method', type: 'string' },
|
||||
{ path: 'metadata.request.cf.asn', type: 'number' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('toOtelFieldSchemas', () => {
|
||||
it('keeps source name and reference', () => {
|
||||
const [otel] = toOtelFieldSchemas([edgeSchema])
|
||||
expect(otel.name).toBe('API Gateway')
|
||||
expect(otel.reference).toBe('edge_logs')
|
||||
})
|
||||
|
||||
it('keeps id/timestamp/event_message as real columns and adds source + severity_text', () => {
|
||||
const [otel] = toOtelFieldSchemas([edgeSchema])
|
||||
const paths = otel.fields.map((f) => f.path)
|
||||
expect(paths.slice(0, 5)).toEqual([
|
||||
'id',
|
||||
'timestamp',
|
||||
'event_message',
|
||||
'severity_text',
|
||||
'source',
|
||||
])
|
||||
})
|
||||
|
||||
it('moves non-column fields into log_attributes, dropping the metadata root', () => {
|
||||
const [otel] = toOtelFieldSchemas([edgeSchema])
|
||||
const paths = otel.fields.map((f) => f.path)
|
||||
expect(paths).toContain("log_attributes['identifier']")
|
||||
expect(paths).toContain("log_attributes['request.method']")
|
||||
expect(paths).toContain("log_attributes['request.cf.asn']")
|
||||
expect(paths).not.toContain('metadata.request.method')
|
||||
expect(paths).not.toContain('identifier')
|
||||
})
|
||||
|
||||
it('preserves the original field type for casting hints', () => {
|
||||
const [otel] = toOtelFieldSchemas([edgeSchema])
|
||||
const asn = otel.fields.find((f) => f.path === "log_attributes['request.cf.asn']")
|
||||
expect(asn?.type).toBe('number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('otelFieldsFromKeys', () => {
|
||||
it('starts with the five base OTEL columns', () => {
|
||||
const fields = otelFieldsFromKeys(['request.cf.asn'])
|
||||
const paths = fields.map((f) => f.path)
|
||||
expect(paths.slice(0, 5)).toEqual([
|
||||
'id',
|
||||
'timestamp',
|
||||
'event_message',
|
||||
'severity_text',
|
||||
'source',
|
||||
])
|
||||
})
|
||||
|
||||
it('maps discovered keys to log_attributes lookups', () => {
|
||||
const fields = otelFieldsFromKeys(['request.cf.asn', 'response.headers.x-foo'])
|
||||
const paths = fields.map((f) => f.path)
|
||||
expect(paths).toContain("log_attributes['request.cf.asn']")
|
||||
expect(paths).toContain("log_attributes['response.headers.x-foo']")
|
||||
})
|
||||
|
||||
it('excludes keys that are real OTEL columns', () => {
|
||||
const fields = otelFieldsFromKeys([
|
||||
'id',
|
||||
'timestamp',
|
||||
'event_message',
|
||||
'severity_text',
|
||||
'source',
|
||||
'trace_id',
|
||||
])
|
||||
const paths = fields.map((f) => f.path)
|
||||
for (const column of ['id', 'timestamp', 'event_message', 'severity_text', 'source']) {
|
||||
expect(paths.filter((p) => p === column)).toHaveLength(1)
|
||||
expect(paths).not.toContain(`log_attributes['${column}']`)
|
||||
}
|
||||
expect(paths).toContain("log_attributes['trace_id']")
|
||||
})
|
||||
|
||||
it('escapes quotes and backslashes in discovered keys', () => {
|
||||
const fields = otelFieldsFromKeys(["weird'key", 'back\\slash'])
|
||||
const paths = fields.map((f) => f.path)
|
||||
expect(paths).toContain("log_attributes['weird''key']")
|
||||
expect(paths).toContain("log_attributes['back\\\\slash']")
|
||||
})
|
||||
|
||||
it('returns only base fields for an empty key list', () => {
|
||||
const fields = otelFieldsFromKeys([])
|
||||
expect(fields).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { logConstants } from 'shared-data'
|
||||
|
||||
export type LogFieldSchema = (typeof logConstants.schemas)[number]
|
||||
type LogField = LogFieldSchema['fields'][number]
|
||||
|
||||
const OTEL_BASE_FIELDS: LogField[] = [
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'severity_text', type: 'string' },
|
||||
{ path: 'source', type: 'string' },
|
||||
]
|
||||
|
||||
const OTEL_COLUMN_PATHS = new Set(OTEL_BASE_FIELDS.map(({ path }) => path))
|
||||
|
||||
const toOtelAttributeKey = (path: string) => path.replace(/^metadata\./, '')
|
||||
|
||||
const toLogAttributePath = (key: string) =>
|
||||
`log_attributes['${key.replace(/\\/g, '\\\\').replace(/'/g, "''")}']`
|
||||
|
||||
const toOtelFieldSchema = (schema: LogFieldSchema): LogFieldSchema => ({
|
||||
...schema,
|
||||
fields: [
|
||||
...OTEL_BASE_FIELDS,
|
||||
...schema.fields
|
||||
.filter((field) => !OTEL_COLUMN_PATHS.has(field.path))
|
||||
.map((field) => ({
|
||||
path: toLogAttributePath(toOtelAttributeKey(field.path)),
|
||||
type: field.type,
|
||||
})),
|
||||
],
|
||||
})
|
||||
|
||||
export const toOtelFieldSchemas = (schemas: LogFieldSchema[]): LogFieldSchema[] =>
|
||||
schemas.map(toOtelFieldSchema)
|
||||
|
||||
export function otelFieldsFromKeys(keys: string[]): LogFieldSchema['fields'] {
|
||||
const attributeFields = keys
|
||||
.filter((key) => !OTEL_COLUMN_PATHS.has(key))
|
||||
.map((key) => ({ path: toLogAttributePath(key), type: 'string' as const }))
|
||||
return [...OTEL_BASE_FIELDS, ...attributeFields]
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ export const DefaultErrorRenderer: React.FC<ErrorRendererProps> = ({ error }) =>
|
||||
language="json"
|
||||
hideLineNumbers
|
||||
value={typeof error === 'string' ? error : JSON.stringify(error, null, 2)}
|
||||
className="w-full font-mono"
|
||||
className="w-full font-mono px-4"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
interface LogsExplorerOtelBannerProps {
|
||||
isRewriting: boolean
|
||||
onRewrite: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export const LogsExplorerOtelBanner = ({
|
||||
isRewriting,
|
||||
onRewrite,
|
||||
onDismiss,
|
||||
}: LogsExplorerOtelBannerProps) => {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
layout="horizontal"
|
||||
className="mb-0 rounded-none border-x-0 border-t-0"
|
||||
title="Logs now run on a ClickHouse-backed engine"
|
||||
description="This query needs to be adjusted to ClickHouse SQL, which the Assistant can do for you."
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="default" size="tiny" loading={isRewriting} onClick={onRewrite}>
|
||||
Rewrite with Assistant
|
||||
</Button>
|
||||
<Button variant="text" size="tiny" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { BookOpen, Check, ChevronDown, ChevronsUpDown, Copy, ExternalLink, X } from 'lucide-react'
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { BookOpen, Check, ChevronDown, ChevronsUpDown, Copy, ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { logConstants } from 'shared-data'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
cn,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -20,11 +22,24 @@ import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
SidePanel,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns'
|
||||
|
||||
import {
|
||||
EXPLORER_DATEPICKER_HELPERS,
|
||||
@@ -32,19 +47,23 @@ import {
|
||||
LogsTableName,
|
||||
} from './Logs.constants'
|
||||
import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers'
|
||||
import { otelFieldsFromKeys, toOtelFieldSchemas } from './Logs.fieldReference'
|
||||
import { LogsWarning, LogTemplate } from './Logs.types'
|
||||
import Table from '@/components/to-be-cleaned/Table'
|
||||
import { useOtelLogKeysQuery } from '@/data/logs/otel-log-keys-query'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useShowMultigresLogs } from '@/hooks/misc/useShowMultigresLogs'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export interface LogsQueryPanelProps {
|
||||
interface LogsQueryPanelProps {
|
||||
templates?: LogTemplate[]
|
||||
value: DatePickerValue
|
||||
warnings: LogsWarning[]
|
||||
onSelectTemplate: (template: LogTemplate) => void
|
||||
onSelectSource: (source: string) => void
|
||||
onDateChange: (value: DatePickerValue) => void
|
||||
showRewriteAction?: boolean
|
||||
isRewriting?: boolean
|
||||
onRewrite?: () => void
|
||||
}
|
||||
|
||||
function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: string }) {
|
||||
@@ -56,13 +75,16 @@ function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: strin
|
||||
)
|
||||
}
|
||||
|
||||
const LogsQueryPanel = ({
|
||||
export const LogsQueryPanel = ({
|
||||
templates = [],
|
||||
value,
|
||||
warnings,
|
||||
onSelectTemplate,
|
||||
onSelectSource,
|
||||
onDateChange,
|
||||
showRewriteAction = false,
|
||||
isRewriting = false,
|
||||
onRewrite,
|
||||
}: LogsQueryPanelProps) => {
|
||||
const [showReference, setShowReference] = useState(false)
|
||||
const { logsTemplates } = useIsFeatureEnabled(['logs:templates'])
|
||||
@@ -92,11 +114,20 @@ const LogsQueryPanel = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const showMultigresLogs = useShowMultigresLogs()
|
||||
const schemas = logConstants.schemas.filter(
|
||||
const useOtel = useFlag('otelLegacyLogs')
|
||||
const baseSchemas = logConstants.schemas.filter(
|
||||
(schema) => schema.reference !== 'multigres_logs' || showMultigresLogs
|
||||
)
|
||||
const schemas = useOtel ? toOtelFieldSchemas(baseSchemas) : baseSchemas
|
||||
|
||||
const [selectedSchema, setSelectedSchema] = useState(schemas[0])
|
||||
const [selectedRef, setSelectedRef] = useState(schemas[0]?.reference)
|
||||
const selectedSchema = schemas.find((s) => s.reference === selectedRef) ?? schemas[0]
|
||||
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: discoveredKeys, isPending: isLoadingKeys } = useOtelLogKeysQuery(
|
||||
{ projectRef, source: selectedRef },
|
||||
{ enabled: useOtel && showReference }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b bg-surface-100 h-(--header-height)">
|
||||
@@ -187,116 +218,142 @@ const LogsQueryPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SidePanel
|
||||
size="large"
|
||||
header={
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<h3>Field Reference</h3>
|
||||
<Button
|
||||
variant="text"
|
||||
className="px-1"
|
||||
onClick={() => setShowReference(false)}
|
||||
icon={<X />}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
visible={showReference}
|
||||
cancelText="Close"
|
||||
onCancel={() => setShowReference(false)}
|
||||
hideFooter
|
||||
triggerElement={
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => setShowReference(true)}
|
||||
icon={<BookOpen />}
|
||||
className="px-2"
|
||||
>
|
||||
<span>Field Reference</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="pt-4 pb-2 space-y-1">
|
||||
<p className="text-sm">
|
||||
The following table shows all the available paths that can be queried from each
|
||||
respective source. Do note that to access nested keys, you would need to perform
|
||||
the necessary{' '}
|
||||
<Link
|
||||
href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand"
|
||||
>
|
||||
unnesting joins
|
||||
<ExternalLink
|
||||
size="14"
|
||||
className="ml-1 inline translate-y-[-2px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
<SidePanel.Separator />
|
||||
|
||||
<div className="px-4 pb-4 flex flex-col gap-4">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
{showRewriteAction && onRewrite && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
role="combobox"
|
||||
size={'small'}
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
|
||||
variant="outline"
|
||||
loading={isRewriting}
|
||||
onClick={onRewrite}
|
||||
className="px-2"
|
||||
>
|
||||
{value ? selectedSchema?.name : 'Select source...'}
|
||||
Rewrite query to ClickHouse SQL
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" sameWidthAsTrigger>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search source..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No source found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{schemas.map((schema) => (
|
||||
<CommandItem
|
||||
key={schema.reference}
|
||||
value={schema.reference}
|
||||
onSelect={() => {
|
||||
setSelectedSchema(schema)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedSchema === schema ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{schema.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th className="text-xs p-2!" key="path">
|
||||
Path
|
||||
</Table.th>,
|
||||
<Table.th key="type" className="text-xs p-2!">
|
||||
Type
|
||||
</Table.th>,
|
||||
]}
|
||||
body={selectedSchema.fields.map((field) => (
|
||||
<Field key={field.path} field={field} />
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
</SidePanel>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="w-72 text-center">
|
||||
Logs now run on a ClickHouse-backed engine. Click to rewrite this query with the
|
||||
Assistant.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Sheet open={showReference} onOpenChange={setShowReference}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="text" icon={<BookOpen />} className="px-2">
|
||||
<span>Field Reference</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent size="lg" className="flex w-full flex-col gap-0 p-0">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Field Reference</SheetTitle>
|
||||
{useOtel ? (
|
||||
<SheetDescription>
|
||||
The following table shows the fields available on each source. Nested fields
|
||||
live in the{' '}
|
||||
<code className="text-code-inline text-xs !break-keep">log_attributes</code>{' '}
|
||||
map and are read with{' '}
|
||||
<code className="text-code-inline text-xs !break-keep">
|
||||
log_attributes['key']
|
||||
</code>{' '}
|
||||
— no unnesting joins needed.
|
||||
</SheetDescription>
|
||||
) : (
|
||||
<SheetDescription>
|
||||
The following table shows all the available paths that can be queried from
|
||||
each respective source. Do note that to access nested keys, you would need to
|
||||
perform the necessary{' '}
|
||||
<Link
|
||||
href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand"
|
||||
>
|
||||
unnesting joins
|
||||
<ExternalLink
|
||||
size="14"
|
||||
className="ml-1 inline translate-y-[-2px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Link>
|
||||
</SheetDescription>
|
||||
)}
|
||||
</SheetHeader>
|
||||
|
||||
<SheetSection className="flex flex-col gap-y-4 overflow-y-auto ">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
role="combobox"
|
||||
size={'small'}
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
|
||||
>
|
||||
{value ? selectedSchema?.name : 'Select source...'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" sameWidthAsTrigger>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search source..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No source found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{schemas.map((schema) => (
|
||||
<CommandItem
|
||||
key={schema.reference}
|
||||
value={schema.reference}
|
||||
onSelect={() => {
|
||||
setSelectedRef(schema.reference)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedSchema?.reference === schema.reference
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{schema.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Card className="overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs p-2!">Path</TableHead>
|
||||
<TableHead className="text-xs p-2!">Type</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{useOtel && isLoadingKeys
|
||||
? Array.from({ length: 3 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell colSpan={2} className="p-2">
|
||||
<ShimmeringLoader />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: (useOtel
|
||||
? otelFieldsFromKeys(discoveredKeys ?? [])
|
||||
: selectedSchema.fields
|
||||
).map((field) => <Field key={field.path} field={field} />)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</SheetSection>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,8 +371,8 @@ const Field = ({
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
return (
|
||||
<Table.tr>
|
||||
<Table.td
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="font-mono text-xs p-2! cursor-pointer hover:text-foreground transition flex items-center space-x-2"
|
||||
onClick={() =>
|
||||
copyToClipboard(field.path, () => {
|
||||
@@ -340,10 +397,8 @@ const Field = ({
|
||||
<TooltipContent side="bottom">Copy value</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td className="font-mono text-xs p-2!">{field.type}</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs p-2!">{field.type}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogsQueryPanel
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
buildClickhouseRewritePrompt,
|
||||
detectLogSource,
|
||||
looksLikeLegacyLogsQuery,
|
||||
rewriteLogsSqlWithAI,
|
||||
stripSqlCodeFences,
|
||||
} from './logs-sql-rewrite'
|
||||
|
||||
describe('buildClickhouseRewritePrompt', () => {
|
||||
it('includes the query, the schema, and a reply-with-only-SQL instruction', () => {
|
||||
const prompt = buildClickhouseRewritePrompt('select count(*) from edge_logs')
|
||||
expect(prompt).toContain('select count(*) from edge_logs')
|
||||
expect(prompt).toContain('log_attributes')
|
||||
expect(prompt).toContain("source = 'edge_logs'")
|
||||
expect(prompt.toLowerCase()).toContain('reply with only')
|
||||
})
|
||||
|
||||
it('spells out the FROM-to-logs conversion and shows a worked example', () => {
|
||||
const prompt = buildClickhouseRewritePrompt('select 1 from postgres_logs')
|
||||
expect(prompt).toContain("from logs where source = 'postgres_logs'")
|
||||
expect(prompt.toLowerCase()).toContain('remove every')
|
||||
expect(prompt).toContain('cross join unnest')
|
||||
expect(prompt).toContain('BigQuery:')
|
||||
expect(prompt).toContain('ClickHouse:')
|
||||
expect(prompt).toContain("log_attributes['parsed.error_severity']")
|
||||
})
|
||||
|
||||
it('lists the real log_attributes keys when provided and demands exact paths', () => {
|
||||
const prompt = buildClickhouseRewritePrompt('select 1 from edge_logs', [
|
||||
'request.headers.x_real_ip',
|
||||
'request.cf.country',
|
||||
])
|
||||
expect(prompt).toContain("log_attributes['request.headers.x_real_ip']")
|
||||
expect(prompt).toContain("log_attributes['request.cf.country']")
|
||||
expect(prompt.toLowerCase()).toContain('exact')
|
||||
})
|
||||
|
||||
it('omits the keys section when none are provided', () => {
|
||||
const prompt = buildClickhouseRewritePrompt('select 1 from edge_logs')
|
||||
expect(prompt).not.toContain('actual log_attributes keys present')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectLogSource', () => {
|
||||
it('reads an explicit source filter', () => {
|
||||
expect(detectLogSource("select 1 from logs where source = 'edge_logs'")).toBe('edge_logs')
|
||||
})
|
||||
|
||||
it('falls back to the legacy FROM table name', () => {
|
||||
expect(detectLogSource('select 1 from edge_logs as t')).toBe('edge_logs')
|
||||
})
|
||||
|
||||
it('maps pg_cron_logs to postgres_logs from either the FROM table or the source filter', () => {
|
||||
expect(detectLogSource('select 1 from pg_cron_logs')).toBe('postgres_logs')
|
||||
expect(detectLogSource("select 1 from logs where source = 'pg_cron_logs'")).toBe(
|
||||
'postgres_logs'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for the bare logs table with no source', () => {
|
||||
expect(detectLogSource('select 1 from logs limit 5')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when nothing matches', () => {
|
||||
expect(detectLogSource('select 1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('looksLikeLegacyLogsQuery', () => {
|
||||
it('flags per-service FROM tables', () => {
|
||||
expect(looksLikeLegacyLogsQuery('select 1 from edge_logs')).toBe(true)
|
||||
})
|
||||
|
||||
it('flags unnest joins and the cast-timestamp idiom', () => {
|
||||
expect(looksLikeLegacyLogsQuery('select 1 from logs cross join unnest(metadata) as m')).toBe(
|
||||
true
|
||||
)
|
||||
expect(looksLikeLegacyLogsQuery('select cast(timestamp as datetime) from logs')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag a ClickHouse query against the logs table', () => {
|
||||
expect(
|
||||
looksLikeLegacyLogsQuery("select timestamp from logs where source = 'edge_logs' limit 5")
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripSqlCodeFences', () => {
|
||||
it('removes a ```sql fenced block', () => {
|
||||
expect(stripSqlCodeFences('```sql\nselect 1 from logs\n```')).toBe('select 1 from logs')
|
||||
})
|
||||
|
||||
it('removes a plain ``` fenced block', () => {
|
||||
expect(stripSqlCodeFences('```\nselect 1\n```')).toBe('select 1')
|
||||
})
|
||||
|
||||
it('leaves unfenced SQL untouched (trimmed)', () => {
|
||||
expect(stripSqlCodeFences(' select 1 from logs ')).toBe('select 1 from logs')
|
||||
})
|
||||
|
||||
it('extracts the fenced block when wrapped in prose', () => {
|
||||
expect(stripSqlCodeFences('Here is the rewrite:\n```sql\nselect 1 from logs\n```')).toBe(
|
||||
'select 1 from logs'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteLogsSqlWithAI', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('posts to the completion endpoint and returns the cleaned query', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => '```sql\nselect 1 from logs\n```',
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await rewriteLogsSqlWithAI({
|
||||
sql: 'select 1 from edge_logs',
|
||||
projectRef: 'abc',
|
||||
})
|
||||
|
||||
expect(result).toBe('select 1 from logs')
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toContain('/api/ai/code/complete')
|
||||
const body = JSON.parse(init.body)
|
||||
expect(body.dialect).toBe('clickhouse')
|
||||
expect(body.completionMetadata.selection).toBe('select 1 from edge_logs')
|
||||
expect(body.completionMetadata.prompt.toLowerCase()).toContain('reply with only')
|
||||
})
|
||||
|
||||
it('throws when the request fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => 'boom' }))
|
||||
await expect(rewriteLogsSqlWithAI({ sql: 'select 1', projectRef: 'abc' })).rejects.toThrow(
|
||||
'boom'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when the model returns an empty query', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ' ' }))
|
||||
await expect(rewriteLogsSqlWithAI({ sql: 'select 1', projectRef: 'abc' })).rejects.toThrow(
|
||||
'empty'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
|
||||
export const LOGS_SCHEMA_REFERENCE = `The logs table (ClickHouse) has these columns:
|
||||
- id (String)
|
||||
- timestamp (DateTime64, UTC) formatted like 2026-06-22T09:34:06.215000 (ISO 8601, microsecond precision, no trailing Z)
|
||||
- event_message (String): the raw log line
|
||||
- severity_text (String): log level when present
|
||||
- source (String): the service the log belongs to. Always filter by it, e.g. where source = 'edge_logs'.
|
||||
- log_attributes (Map(String, String)): structured per-source fields, read as log_attributes['key']. Values are strings, so wrap numeric ones in toInt32OrZero(...) for comparisons.
|
||||
|
||||
Sources and their common log_attributes keys:
|
||||
- edge_logs: request.method, request.path, request.search, response.status_code, identifier
|
||||
- postgres_logs: parsed.error_severity, parsed.detail, parsed.hint, parsed.query, identifier
|
||||
- pg_cron logs live under source = 'postgres_logs' (parsed.error_severity, parsed.query)
|
||||
- auth_logs: level, status, path, msg, error
|
||||
- function_edge_logs: response.status_code, request.method, request.pathname, function_id, execution_id, execution_time_ms
|
||||
- function_logs: event_type, function_id, execution_id, level
|
||||
- storage_logs, realtime_logs, postgrest_logs, supavisor_logs, pgbouncer_logs: mostly id, timestamp, event_message, with extra fields in log_attributes
|
||||
|
||||
Rules: always filter by source; the editor applies the selected time range so a timestamp filter is usually unnecessary; the old BigQuery unnest joins become log_attributes['key'] lookups (drop the metadata root).`
|
||||
|
||||
function renderAvailableKeys(availableKeys?: string[]): string {
|
||||
if (!availableKeys || availableKeys.length === 0) return ''
|
||||
const list = availableKeys.map((key) => `- log_attributes['${key}']`).join('\n')
|
||||
return `\nThe actual log_attributes keys present for this source are listed below. Use these EXACT keys — do not invent, shorten, or drop any dotted prefix. If a BigQuery field maps to one of these (e.g. request.headers.x_real_ip, request.cf.country), use the full key shown here:
|
||||
${list}\n`
|
||||
}
|
||||
|
||||
export function buildClickhouseRewritePrompt(sql: string, availableKeys?: string[]): string {
|
||||
return `${LOGS_SCHEMA_REFERENCE}
|
||||
${renderAvailableKeys(availableKeys)}
|
||||
Convert the BigQuery logs query below to ClickHouse SQL for the logs table. There are no per-service tables and no unnest joins in ClickHouse. Follow these rules exactly:
|
||||
|
||||
1. Replace the FROM table with the single logs table and filter by source. The old table name is the source value: "from postgres_logs as t" becomes "from logs where source = 'postgres_logs'". This is required, never select from a table like postgres_logs or edge_logs.
|
||||
2. Remove every join that unnests metadata or its structs. This includes "cross join unnest(...)" and "left join unnest(...) on true".
|
||||
3. Replace any column that came from an unnest alias with a log_attributes lookup. A field off unnest(metadata) becomes log_attributes['field']; a field off a nested struct like unnest(m.parsed) becomes log_attributes['parsed.field'] (keep the struct name as a dotted prefix, drop the metadata root and every alias). When the actual keys are listed above, match against them and use the full dotted key exactly.
|
||||
4. Wrap numeric fields in toInt32OrZero(...) before comparing or aggregating them.
|
||||
5. Replace BigQuery functions with ClickHouse equivalents: regexp_contains(x, 'p') becomes match(x, 'p'), or x ILIKE '%p%' for a plain substring. Replace cast(timestamp as datetime) with timestamp. Use count() instead of count(*).
|
||||
6. Preserve the original select list, filters, group by, order by, and limit intent.
|
||||
|
||||
Example.
|
||||
BigQuery:
|
||||
select count(t.timestamp) as count, p.error_severity
|
||||
from postgres_logs as t
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.parsed) as p
|
||||
where p.error_severity in ('ERROR', 'FATAL', 'PANIC')
|
||||
group by p.error_severity
|
||||
order by count desc
|
||||
limit 100
|
||||
|
||||
ClickHouse:
|
||||
select count() as count, log_attributes['parsed.error_severity'] as error_severity
|
||||
from logs
|
||||
where source = 'postgres_logs'
|
||||
and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC')
|
||||
group by log_attributes['parsed.error_severity']
|
||||
order by count desc
|
||||
limit 100
|
||||
|
||||
Reply with ONLY the rewritten SQL query: no explanation, no comments, and no markdown code fences.
|
||||
|
||||
${sql}`
|
||||
}
|
||||
|
||||
export function stripSqlCodeFences(text: string): string {
|
||||
const trimmed = text.trim()
|
||||
const fenced = trimmed.match(/```(?:sql)?\s*\n?([\s\S]*?)\n?```/i)
|
||||
return (fenced ? fenced[1] : trimmed).trim()
|
||||
}
|
||||
|
||||
const SOURCE_ALIASES: Record<string, string> = {
|
||||
pg_cron_logs: 'postgres_logs',
|
||||
}
|
||||
|
||||
export function detectLogSource(sql: string): string | undefined {
|
||||
const bySource = sql.match(/source\s*=\s*'([^']+)'/i)
|
||||
if (bySource) {
|
||||
const source = bySource[1].toLowerCase()
|
||||
return SOURCE_ALIASES[source] ?? source
|
||||
}
|
||||
const byFrom = sql.match(/\bfrom\s+([a-z_][a-z0-9_]*)/i)
|
||||
if (byFrom) {
|
||||
const table = byFrom[1].toLowerCase()
|
||||
if (table === 'logs') return undefined
|
||||
return SOURCE_ALIASES[table] ?? table
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function looksLikeLegacyLogsQuery(sql: string): boolean {
|
||||
const lower = sql.toLowerCase()
|
||||
if (/\bunnest\s*\(/.test(lower)) return true
|
||||
if (/cast\s*\(\s*timestamp\s+as\s+datetime\s*\)/.test(lower)) return true
|
||||
const byFrom = lower.match(/\bfrom\s+([a-z_][a-z0-9_]*)/)
|
||||
return byFrom ? byFrom[1] !== 'logs' : false
|
||||
}
|
||||
|
||||
export interface RewriteLogsSqlArgs {
|
||||
sql: string
|
||||
projectRef: string
|
||||
connectionString?: string | null
|
||||
orgSlug?: string
|
||||
authorizationHeader?: string | null
|
||||
availableKeys?: string[]
|
||||
}
|
||||
|
||||
export async function rewriteLogsSqlWithAI(args: RewriteLogsSqlArgs) {
|
||||
const { sql, projectRef, connectionString, orgSlug, authorizationHeader, availableKeys } = args
|
||||
|
||||
const response = await fetch(`${BASE_PATH}/api/ai/code/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authorizationHeader ? { Authorization: authorizationHeader } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectRef,
|
||||
connectionString,
|
||||
language: 'sql',
|
||||
dialect: 'clickhouse',
|
||||
orgSlug,
|
||||
completionMetadata: {
|
||||
textBeforeCursor: '',
|
||||
textAfterCursor: '',
|
||||
language: 'pgsql',
|
||||
prompt: buildClickhouseRewritePrompt(sql, availableKeys),
|
||||
selection: sql,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(errorText || 'Failed to rewrite the query')
|
||||
}
|
||||
|
||||
const raw = await response.json()
|
||||
const rewritten = stripSqlCodeFences(typeof raw === 'string' ? raw : String(raw))
|
||||
if (!rewritten) throw new Error('The assistant returned an empty query')
|
||||
return rewritten
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { BookOpen, Check, ChevronsUpDown, Copy, ExternalLink, List, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
@@ -23,7 +24,12 @@ import {
|
||||
|
||||
import { DocsButton } from '../DocsButton'
|
||||
import { LOGS_EXPLORER_DOCS_URL } from '@/components/interfaces/Settings/Logs/Logs.constants'
|
||||
import {
|
||||
otelFieldsFromKeys,
|
||||
toOtelFieldSchemas,
|
||||
} from '@/components/interfaces/Settings/Logs/Logs.fieldReference'
|
||||
import Table from '@/components/to-be-cleaned/Table'
|
||||
import { useOtelLogKeysQuery } from '@/data/logs/otel-log-keys-query'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
export interface LogsExplorerHeaderProps {
|
||||
@@ -33,7 +39,16 @@ export interface LogsExplorerHeaderProps {
|
||||
const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => {
|
||||
const [showReference, setShowReference] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedSchema, setSelectedSchema] = useState(logConstants.schemas[0])
|
||||
const useOtel = useFlag('otelLegacyLogs')
|
||||
const schemas = useOtel ? toOtelFieldSchemas(logConstants.schemas) : logConstants.schemas
|
||||
const [selectedRef, setSelectedRef] = useState(schemas[0]?.reference)
|
||||
const selectedSchema = schemas.find((s) => s.reference === selectedRef) ?? schemas[0]
|
||||
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: discoveredKeys, isPending: isLoadingKeys } = useOtelLogKeysQuery(
|
||||
{ projectRef, source: selectedRef },
|
||||
{ enabled: useOtel && showReference }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 transition-all pb-6 justify-between">
|
||||
@@ -79,24 +94,33 @@ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => {
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="pt-4 pb-2 space-y-1">
|
||||
<p className="text-sm">
|
||||
The following table shows all the available paths that can be queried from each
|
||||
respective source. Do note that to access nested keys, you would need to perform the
|
||||
necessary{' '}
|
||||
<Link
|
||||
href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand"
|
||||
>
|
||||
unnesting joins
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="ml-1 inline translate-y-[-2px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Link>
|
||||
</p>
|
||||
{useOtel ? (
|
||||
<p className="text-sm">
|
||||
The following table shows the fields available on each source. Nested fields live
|
||||
in the <code className="text-xs">log_attributes</code> map and are read with{' '}
|
||||
<code className="text-xs">log_attributes['key']</code> — no unnesting joins
|
||||
needed.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
The following table shows all the available paths that can be queried from each
|
||||
respective source. Do note that to access nested keys, you would need to perform
|
||||
the necessary{' '}
|
||||
<Link
|
||||
href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand"
|
||||
>
|
||||
unnesting joins
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="ml-1 inline translate-y-[-2px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
<SidePanel.Separator />
|
||||
@@ -120,19 +144,21 @@ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => {
|
||||
<CommandList>
|
||||
<CommandEmpty>No source found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{logConstants.schemas.map((schema) => (
|
||||
{schemas.map((schema) => (
|
||||
<CommandItem
|
||||
key={schema.reference}
|
||||
value={schema.reference}
|
||||
onSelect={() => {
|
||||
setSelectedSchema(schema)
|
||||
setSelectedRef(schema.reference)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedSchema === schema ? 'opacity-100' : 'opacity-0'
|
||||
selectedSchema?.reference === schema.reference
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{schema.name}
|
||||
@@ -143,19 +169,26 @@ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => {
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th className="text-xs p-2!" key="path">
|
||||
Path
|
||||
</Table.th>,
|
||||
<Table.th key="type" className="text-xs p-2!">
|
||||
Type
|
||||
</Table.th>,
|
||||
]}
|
||||
body={selectedSchema.fields.map((field) => (
|
||||
<Field key={field.path} field={field} />
|
||||
))}
|
||||
/>
|
||||
{useOtel && isLoadingKeys ? (
|
||||
<p className="text-sm text-foreground-light py-2">Loading fields…</p>
|
||||
) : (
|
||||
<Table
|
||||
head={[
|
||||
<Table.th className="text-xs p-2!" key="path">
|
||||
Path
|
||||
</Table.th>,
|
||||
<Table.th key="type" className="text-xs p-2!">
|
||||
Type
|
||||
</Table.th>,
|
||||
]}
|
||||
body={(() => {
|
||||
const fields = useOtel
|
||||
? otelFieldsFromKeys(discoveredKeys ?? [])
|
||||
: selectedSchema.fields
|
||||
return fields.map((field) => <Field key={field.path} field={field} />)
|
||||
})()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { executeAnalyticsSql } from './execute-analytics-sql'
|
||||
import { logsAllEndpointUrl } from './logs-endpoint'
|
||||
import { analyticsLiteral, safeSql } from './safe-analytics-sql'
|
||||
|
||||
const LOOKBACK_HOURS = 24 * 7
|
||||
|
||||
export async function fetchOtelLogKeys({
|
||||
projectRef,
|
||||
source,
|
||||
signal,
|
||||
}: {
|
||||
projectRef: string
|
||||
source: string
|
||||
signal?: AbortSignal
|
||||
}): Promise<string[]> {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - LOOKBACK_HOURS * 60 * 60 * 1000)
|
||||
const sql = safeSql`SELECT arrayJoin(mapKeys(log_attributes)) AS key, count() AS n FROM logs WHERE source = ${analyticsLiteral(source)} GROUP BY key ORDER BY n DESC LIMIT 500`
|
||||
const data = await executeAnalyticsSql({
|
||||
projectRef,
|
||||
endpoint: logsAllEndpointUrl(true),
|
||||
sql,
|
||||
iso_timestamp_start: start.toISOString(),
|
||||
iso_timestamp_end: end.toISOString(),
|
||||
method: 'post',
|
||||
signal,
|
||||
})
|
||||
const rows = (data?.result ?? []) as { key: string }[]
|
||||
return rows.map((r) => r.key).filter(Boolean)
|
||||
}
|
||||
|
||||
export function useOtelLogKeysQuery(
|
||||
{ projectRef, source }: { projectRef?: string; source?: string },
|
||||
{ enabled = true }: { enabled?: boolean } = {}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['projects', projectRef, 'otel-log-keys', source],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchOtelLogKeys({ projectRef: projectRef ?? '', source: source ?? '', signal }),
|
||||
enabled: enabled && Boolean(projectRef) && Boolean(source),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createParser, useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { Filters } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
|
||||
@@ -67,34 +68,55 @@ export function useLogsUrlState() {
|
||||
const selectedLogId = selectedLogIdValue || null
|
||||
const filters = filtersValue || defaultState.filters
|
||||
|
||||
const setSearch = (value: string) => {
|
||||
setSearchValue(value || defaultState.search)
|
||||
}
|
||||
const setSearch = useCallback(
|
||||
(value: string) => {
|
||||
setSearchValue(value || defaultState.search)
|
||||
},
|
||||
[setSearchValue]
|
||||
)
|
||||
|
||||
const setTimeRange = (start: string, end: string) => {
|
||||
setTimestampStartValue(start || defaultState.timestampStart)
|
||||
setTimestampEndValue(end || defaultState.timestampEnd)
|
||||
}
|
||||
const setTimeRange = useCallback(
|
||||
(start: string, end: string) => {
|
||||
setTimestampStartValue(start || defaultState.timestampStart)
|
||||
setTimestampEndValue(end || defaultState.timestampEnd)
|
||||
},
|
||||
[setTimestampStartValue, setTimestampEndValue]
|
||||
)
|
||||
|
||||
const setSelectedLogId = (value: string | null) => {
|
||||
setSelectedLogIdValue(value || '')
|
||||
}
|
||||
const setSelectedLogId = useCallback(
|
||||
(value: string | null) => {
|
||||
setSelectedLogIdValue(value || '')
|
||||
},
|
||||
[setSelectedLogIdValue]
|
||||
)
|
||||
|
||||
const setFilters = (value: Filters) => {
|
||||
setFiltersValue(value || defaultState.filters)
|
||||
}
|
||||
const setFilters = useCallback(
|
||||
(value: Filters) => {
|
||||
setFiltersValue(value || defaultState.filters)
|
||||
},
|
||||
[setFiltersValue]
|
||||
)
|
||||
|
||||
const updateFilter = (key: string, value: any) => {
|
||||
setFilters({ ...filters, [key]: value })
|
||||
}
|
||||
const updateFilter = useCallback(
|
||||
(key: string, value: any) => {
|
||||
setFilters({ ...filters, [key]: value })
|
||||
},
|
||||
[filters, setFilters]
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
const reset = useCallback(() => {
|
||||
setSearch(defaultState.search)
|
||||
setTimestampStartValue(defaultState.timestampStart)
|
||||
setTimestampEndValue(defaultState.timestampEnd)
|
||||
setSelectedLogIdValue('')
|
||||
setFiltersValue(defaultState.filters)
|
||||
}
|
||||
}, [
|
||||
setSearch,
|
||||
setTimestampStartValue,
|
||||
setTimestampEndValue,
|
||||
setSelectedLogIdValue,
|
||||
setFiltersValue,
|
||||
])
|
||||
|
||||
return {
|
||||
// State
|
||||
|
||||
@@ -51,6 +51,11 @@ export const DEFAULT_COMPLETION_MODEL = openaiModelEntry({
|
||||
reasoningEffort: 'none',
|
||||
})
|
||||
|
||||
export const LOGS_REWRITE_MODEL = openaiModelEntry({
|
||||
id: 'gpt-5.4-nano',
|
||||
reasoningEffort: 'low',
|
||||
})
|
||||
|
||||
// Single source of truth for all Assistant chat model variants and their reasoning levels.
|
||||
// Models with requiresAdvanceModelEntitlement false are available to all users; true requires the assistant.advance_model entitlement.
|
||||
export const ASSISTANT_MODELS = [
|
||||
|
||||
@@ -786,6 +786,15 @@ export const SQL_COMPLETION_INSTRUCTIONS = `
|
||||
Do not quote identifiers unless they actually require it (uppercase letters, reserved words, or special characters). Plain lowercase identifiers should not be quoted.
|
||||
`
|
||||
|
||||
export const CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS = `
|
||||
# Supabase logs SQL (ClickHouse)
|
||||
You are writing SQL for Supabase logs, which run on a ClickHouse-backed engine. This is NOT Postgres and NOT BigQuery. Output valid ClickHouse SQL only.
|
||||
- All logs are in a single table named \`logs\`, keyed by a \`source\` column. There are no per-service tables (no \`edge_logs\`, \`postgres_logs\`, and so on) and no \`unnest\` joins.
|
||||
- Per-source fields live in the \`log_attributes\` Map(String, String), read as \`log_attributes['key']\`. Map values are strings, so wrap numeric ones in \`toInt32OrZero(...)\`.
|
||||
- Use ClickHouse functions, not Postgres or BigQuery ones. Use \`match(col, 'regex')\` or \`col ILIKE '%text%'\` instead of \`regexp_contains\`, \`count()\` instead of \`count(*)\`, and select the \`timestamp\` column directly instead of \`cast(timestamp as datetime)\`.
|
||||
- Do not quote identifiers with double quotes and do not append a trailing semicolon.
|
||||
`
|
||||
|
||||
export const LIMITATIONS_PROMPT = `
|
||||
# Limitations
|
||||
- You are to only answer Supabase, database, or edge function related questions. All other questions should be declined with a polite message.
|
||||
|
||||
@@ -9,8 +9,9 @@ import { executeSql } from '@/data/sql/execute-sql-mutation'
|
||||
import { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
|
||||
import { getOrgAIDetails } from '@/lib/ai/ai-details'
|
||||
import { getModel } from '@/lib/ai/model'
|
||||
import { DEFAULT_COMPLETION_MODEL } from '@/lib/ai/model.utils'
|
||||
import { DEFAULT_COMPLETION_MODEL, LOGS_REWRITE_MODEL } from '@/lib/ai/model.utils'
|
||||
import {
|
||||
CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS,
|
||||
COMPLETION_PROMPT,
|
||||
EDGE_FUNCTION_PROMPT,
|
||||
PG_BEST_PRACTICES,
|
||||
@@ -128,6 +129,7 @@ const requestBodySchema = z.object({
|
||||
connectionString: z.string().nullish(),
|
||||
orgSlug: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
dialect: z.enum(['postgres', 'clickhouse']).optional(),
|
||||
})
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -148,8 +150,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues })
|
||||
}
|
||||
|
||||
const { completionMetadata, projectRef, connectionString, orgSlug, language } = data
|
||||
const { completionMetadata, projectRef, connectionString, orgSlug, language, dialect } = data
|
||||
const { textBeforeCursor, textAfterCursor, prompt, selection } = completionMetadata
|
||||
const isClickhouse = dialect === 'clickhouse'
|
||||
|
||||
const authorization = req.headers.authorization
|
||||
let aiOptInLevel: AiOptInLevel = IS_PLATFORM ? 'disabled' : 'schema'
|
||||
@@ -168,7 +171,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
systemProviderOptions,
|
||||
} = await getModel({
|
||||
provider: 'openai',
|
||||
modelEntry: DEFAULT_COMPLETION_MODEL,
|
||||
modelEntry: isClickhouse ? LOGS_REWRITE_MODEL : DEFAULT_COMPLETION_MODEL,
|
||||
})
|
||||
|
||||
if (modelError) {
|
||||
@@ -180,7 +183,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
...(authorization && { Authorization: authorization }),
|
||||
}
|
||||
|
||||
const includeSchema = aiOptInLevel !== 'disabled'
|
||||
const includeSchema = !isClickhouse && aiOptInLevel !== 'disabled'
|
||||
|
||||
// Fetch schema list first so we can determine which schemas to load DDL for.
|
||||
// These are best-effort — if they fail, we proceed without DDL context.
|
||||
@@ -227,29 +230,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
otherSchemas: schemas.filter((s) => !fetchedSchemaSet.has(s.name)).map((s) => s.name),
|
||||
}
|
||||
|
||||
// Important: do not use dynamic content in the system prompt or Bedrock will not cache it
|
||||
const system = source`
|
||||
${COMPLETION_PROMPT}
|
||||
${language === 'sql' ? SQL_COMPLETION_INSTRUCTIONS : ''}
|
||||
${language === 'sql' ? PG_BEST_PRACTICES : EDGE_FUNCTION_PROMPT}
|
||||
${SECURITY_PROMPT}
|
||||
`
|
||||
const system = isClickhouse
|
||||
? source`
|
||||
You rewrite SQL queries to ClickHouse SQL for the Supabase logs table.
|
||||
Output only the rewritten SQL query: no explanation, no markdown, and no code fences.
|
||||
${CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS}
|
||||
${SECURITY_PROMPT}
|
||||
`
|
||||
: source`
|
||||
${COMPLETION_PROMPT}
|
||||
${language === 'sql' ? `${SQL_COMPLETION_INSTRUCTIONS}\n${PG_BEST_PRACTICES}` : EDGE_FUNCTION_PROMPT}
|
||||
${SECURITY_PROMPT}
|
||||
`
|
||||
|
||||
const userMessage = source`
|
||||
## Database Schema
|
||||
const userMessage = isClickhouse
|
||||
? prompt
|
||||
: source`
|
||||
## Database Schema
|
||||
|
||||
${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })}
|
||||
${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })}
|
||||
|
||||
## Code
|
||||
## Code
|
||||
|
||||
\`\`\`${language ?? ''}
|
||||
${textBeforeCursor}<selection>${selection}</selection>${textAfterCursor}
|
||||
\`\`\`
|
||||
\`\`\`${language ?? ''}
|
||||
${textBeforeCursor}<selection>${selection}</selection>${textAfterCursor}
|
||||
\`\`\`
|
||||
|
||||
## Instruction
|
||||
## Instruction
|
||||
|
||||
${prompt}
|
||||
`
|
||||
${prompt}
|
||||
`
|
||||
|
||||
// Note: these must be of type `CoreMessage` to prevent AI SDK from stripping `providerOptions`
|
||||
// https://github.com/vercel/ai/blob/81ef2511311e8af34d75e37fc8204a82e775e8c3/packages/ai/core/prompt/standardize-prompt.ts#L83-L88
|
||||
|
||||
@@ -6,8 +6,13 @@ import type { editor } from 'monaco-editor'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
|
||||
import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
|
||||
|
||||
import {
|
||||
detectLogSource,
|
||||
looksLikeLegacyLogsQuery,
|
||||
rewriteLogsSqlWithAI,
|
||||
} from '@/components/interfaces/Settings/Logs/logs-sql-rewrite'
|
||||
import {
|
||||
EXPLORER_DATEPICKER_HELPERS,
|
||||
getDefaultHelper,
|
||||
@@ -25,12 +30,14 @@ import {
|
||||
buildLogQueryParams,
|
||||
resolveLogDateRange,
|
||||
} from '@/components/interfaces/Settings/Logs/logsDateRange'
|
||||
import LogsQueryPanel from '@/components/interfaces/Settings/Logs/LogsQueryPanel'
|
||||
import { LogsExplorerOtelBanner } from '@/components/interfaces/Settings/Logs/LogsExplorerOtelBanner'
|
||||
import { LogsQueryPanel } from '@/components/interfaces/Settings/Logs/LogsQueryPanel'
|
||||
import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable'
|
||||
import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
import { DiffEditor } from '@/components/ui/DiffEditor'
|
||||
import LoadingOpacity from '@/components/ui/LoadingOpacity'
|
||||
import ShimmerLine from '@/components/ui/ShimmerLine'
|
||||
import { useContentQuery } from '@/data/content/content-query'
|
||||
@@ -38,17 +45,23 @@ import {
|
||||
UpsertContentPayload,
|
||||
useContentUpsertMutation,
|
||||
} from '@/data/content/content-upsert-mutation'
|
||||
import { constructHeaders } from '@/data/fetchers'
|
||||
import { fetchOtelLogKeys } from '@/data/logs/otel-log-keys-query'
|
||||
import useLogsQuery from '@/hooks/analytics/useLogsQuery'
|
||||
import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
|
||||
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
|
||||
import { uuidv4 } from '@/lib/helpers'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import type { LogSqlSnippets, NextPageWithLayout } from '@/types'
|
||||
|
||||
type SaveQueryFormValues = { name: string; description?: string }
|
||||
|
||||
const LOCAL_PLACEHOLDER_QUERY =
|
||||
'select\n timestamp, event_message, metadata\n from edge_logs limit 5'
|
||||
|
||||
@@ -58,11 +71,16 @@ const PLATFORM_PLACEHOLDER_QUERY =
|
||||
const OTEL_PLACEHOLDER_QUERY =
|
||||
"select\n timestamp,\n event_message,\n log_attributes\nfrom logs\nwhere source = 'edge_logs'\norder by timestamp desc\nlimit 5"
|
||||
|
||||
const otelSourceQuery = (source: string) =>
|
||||
`select\n timestamp,\n event_message,\n log_attributes\nfrom logs\nwhere source = '${source}'\norder by timestamp desc\nlimit 100`
|
||||
|
||||
export const LogsExplorerPage: NextPageWithLayout = () => {
|
||||
useEditorHints()
|
||||
const monaco = useMonaco()
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
const { ref, q, queryId } = useParams()
|
||||
const track = useTrack()
|
||||
const projectRef = ref as string
|
||||
@@ -106,6 +124,15 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
|
||||
const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false)
|
||||
const [warnings, setWarnings] = useState<LogsWarning[]>([])
|
||||
const [selectedLog, setSelectedLog] = useState<LogData | null>(null)
|
||||
const [rewriteProposal, setRewriteProposal] = useState<{
|
||||
original: string
|
||||
modified: string
|
||||
} | null>(null)
|
||||
const [isRewriting, setIsRewriting] = useState<boolean>(false)
|
||||
const [rewriteBannerDismissed, setRewriteBannerDismissed] = useLocalStorage<boolean>(
|
||||
`project-${projectRef}-logs-rewrite-banner-dismissed`,
|
||||
false
|
||||
)
|
||||
|
||||
const [recentLogs, setRecentLogs] = useLocalStorage<LogSqlSnippets.Content[]>(
|
||||
`project-content-${projectRef}-recent-log-sql`,
|
||||
@@ -150,6 +177,8 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
|
||||
const results = logData
|
||||
const isLoading = logsLoading
|
||||
|
||||
const showRewriteCTA = useOtelEndpoint && looksLikeLegacyLogsQuery(editorValue)
|
||||
|
||||
const { mutateAsync: upsertContent, isPending: isUpsertingContent } = useContentUpsertMutation({
|
||||
onError: (e) => {
|
||||
const error = e as { message: string }
|
||||
@@ -202,6 +231,52 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
|
||||
addRecentLogSqlSnippet({ sql: template.searchString })
|
||||
}
|
||||
|
||||
const handleRewrite = async () => {
|
||||
const currentSql = editorRef.current?.getValue() ?? editorValue
|
||||
if (!currentSql.trim()) {
|
||||
toast.info('Write a query to rewrite first')
|
||||
return
|
||||
}
|
||||
setIsRewriting(true)
|
||||
try {
|
||||
const headerData = await constructHeaders()
|
||||
const source = detectLogSource(currentSql)
|
||||
const availableKeys = source
|
||||
? await fetchOtelLogKeys({ projectRef, source }).catch(() => undefined)
|
||||
: undefined
|
||||
const rewritten = await rewriteLogsSqlWithAI({
|
||||
sql: currentSql,
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
orgSlug: organization?.slug,
|
||||
authorizationHeader: headerData.get('Authorization'),
|
||||
availableKeys,
|
||||
})
|
||||
// The editor may have changed while awaiting key discovery and the AI call;
|
||||
// don't offer a proposal that would clobber intervening edits.
|
||||
const latestSql = editorRef.current?.getValue() ?? editorValue
|
||||
if (latestSql !== currentSql) {
|
||||
toast.info('The query changed while rewriting. Please try again.')
|
||||
return
|
||||
}
|
||||
setRewriteProposal({ original: currentSql, modified: rewritten })
|
||||
} catch (error) {
|
||||
toast.error(`Couldn't rewrite the query: ${(error as Error).message}`)
|
||||
} finally {
|
||||
setIsRewriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const acceptRewrite = () => {
|
||||
if (!rewriteProposal) return
|
||||
editorRef.current?.setValue(rewriteProposal.modified)
|
||||
setEditorValue(rewriteProposal.modified)
|
||||
setRewriteProposal(null)
|
||||
toast.success('Applied the ClickHouse rewrite')
|
||||
}
|
||||
|
||||
const discardRewrite = () => setRewriteProposal(null)
|
||||
|
||||
const handleRun = (value?: string | React.MouseEvent) => {
|
||||
track('log_explorer_query_run_button_clicked', { is_saved_query: !!queryId })
|
||||
|
||||
@@ -232,12 +307,20 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
|
||||
if (editorRef.current && monaco) {
|
||||
const editorModel = editorRef.current?.getModel()
|
||||
const currentValue = editorRef.current.getValue()
|
||||
const index = currentValue.indexOf('from')
|
||||
|
||||
const updatedValue =
|
||||
index < 0
|
||||
? `${currentValue}${source}`
|
||||
: `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}`
|
||||
let updatedValue: string
|
||||
if (useOtelEndpoint) {
|
||||
const sourceFilter = /source\s*=\s*'[^']*'/i
|
||||
updatedValue = sourceFilter.test(currentValue)
|
||||
? currentValue.replace(sourceFilter, `source = '${source}'`)
|
||||
: otelSourceQuery(source)
|
||||
} else {
|
||||
const index = currentValue.indexOf('from')
|
||||
updatedValue =
|
||||
index < 0
|
||||
? `${currentValue}${source}`
|
||||
: `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}`
|
||||
}
|
||||
|
||||
editorRef.current.pushUndoStop()
|
||||
editorRef.current.executeEdits(`insert-identifier`, [
|
||||
@@ -251,8 +334,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
|
||||
}
|
||||
}
|
||||
|
||||
type SaveQueryFormValues = { name: string; description?: string }
|
||||
|
||||
const handleCreateQuery = async (values: SaveQueryFormValues) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!profile) return console.error('Profile is required')
|
||||
@@ -333,20 +414,11 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
|
||||
} else if (q) {
|
||||
setEditorValue(q)
|
||||
setSearch(q)
|
||||
} else if (!queryId) {
|
||||
setEditorValue(PLACEHOLDER_QUERY)
|
||||
editorRef.current?.setValue(PLACEHOLDER_QUERY)
|
||||
}
|
||||
}, [q, search, setSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!useOtelEndpoint || q || search || queryId) return
|
||||
if (editorValue === OTEL_PLACEHOLDER_QUERY) return
|
||||
const isUntouchedDefault =
|
||||
editorValue === LOCAL_PLACEHOLDER_QUERY ||
|
||||
editorValue === PLATFORM_PLACEHOLDER_QUERY ||
|
||||
editorValue === logsDefaultQuery
|
||||
if (!isUntouchedDefault) return
|
||||
setEditorValue(OTEL_PLACEHOLDER_QUERY)
|
||||
editorRef.current?.setValue(OTEL_PLACEHOLDER_QUERY)
|
||||
}, [useOtelEndpoint, q, search, queryId, editorValue, logsDefaultQuery])
|
||||
}, [q, search, queryId, setSearch, PLACEHOLDER_QUERY])
|
||||
|
||||
useEffect(() => {
|
||||
// prevents overwriting when the user selects a helper.
|
||||
@@ -402,18 +474,55 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
|
||||
templates={allTemplates.filter((template) => template.mode === 'custom')}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
warnings={warnings}
|
||||
showRewriteAction={showRewriteCTA && rewriteBannerDismissed}
|
||||
isRewriting={isRewriting}
|
||||
onRewrite={handleRewrite}
|
||||
/>
|
||||
{showRewriteCTA && !rewriteBannerDismissed && (
|
||||
<LogsExplorerOtelBanner
|
||||
isRewriting={isRewriting}
|
||||
onRewrite={handleRewrite}
|
||||
onDismiss={() => setRewriteBannerDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
<ShimmerLine active={isLoading} />
|
||||
<CodeEditor
|
||||
// Ensure we reset the editor to the query content whenever the selected query changes
|
||||
key={queryId}
|
||||
id={editorId}
|
||||
editorRef={editorRef}
|
||||
language="pgsql"
|
||||
defaultValue={editorValue}
|
||||
onInputChange={(v) => setEditorValue(v || '')}
|
||||
actions={{ runQuery: { enabled: true, callback: handleRun } }}
|
||||
/>
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
// Ensure we reset the editor to the query content whenever the selected query changes
|
||||
key={queryId}
|
||||
id={editorId}
|
||||
editorRef={editorRef}
|
||||
language="pgsql"
|
||||
defaultValue={editorValue}
|
||||
onInputChange={(v) => setEditorValue(v || '')}
|
||||
actions={{ runQuery: { enabled: true, callback: handleRun } }}
|
||||
/>
|
||||
{rewriteProposal && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col bg-studio">
|
||||
<div className="flex items-center justify-between gap-2 border-b bg-surface-100 px-4 py-2">
|
||||
<span className="text-xs text-foreground-light">
|
||||
Review the ClickHouse SQL rewrite before accepting it
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="default" size="tiny" onClick={discardRewrite}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button variant="primary" size="tiny" onClick={acceptRewrite}>
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<DiffEditor
|
||||
language="pgsql"
|
||||
original={rewriteProposal.original}
|
||||
modified={rewriteProposal.modified}
|
||||
options={{ renderSideBySide: true, renderGutterMenu: false }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel collapsible minSize="5" className="overflow-auto">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import LogsQueryPanel from '@/components/interfaces/Settings/Logs/LogsQueryPanel'
|
||||
import { LogsQueryPanel } from '@/components/interfaces/Settings/Logs/LogsQueryPanel'
|
||||
import { render } from '@/tests/helpers'
|
||||
|
||||
test('run and clear', async () => {
|
||||
|
||||
Reference in new issue
Block a user