mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
Add clickhouse migration banner to QueryEditor (#49184)
## Context Adds the clickhouse migration banner into the QueryEditor for explorer if the source selected is logs - will apply for both the notebook query cells and query tab JFYI i've omitted out the diffing view for now, like what've currently got for the SQL Editor Got a separate ticket to look into that, but was thinking of waiting for [this PR](https://github.com/supabase/supabase/pull/49112) from Charis to go in first <img width="1391" height="369" alt="image" src="https://github.com/user-attachments/assets/5880df99-44b5-4a9f-8ff7-c9d7af3bcb93" /> <img width="1054" height="474" alt="image" src="https://github.com/user-attachments/assets/ef5d225b-3b53-4d6d-ab6c-19804e3358e6" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added guidance in SQL editors to identify and rewrite legacy logs queries. * Integrated rewrite suggestions into the query editor’s existing SQL diff workflow. * Increased the height of embedded query editors for improved usability. * Kept rewrite guidance available when no rewrite is needed or an attempt is unsuccessful. * **Bug Fixes** * Improved spacing for empty query-result messages. * **Tests** * Added coverage for rewrite visibility, acceptance, dismissal, and no-change outcomes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
1 parent
79fbe467ba
commit
ce2ed77c02
5 files changed
+266
-70
No files matched your search
@@ -14,7 +14,7 @@ export const QueryResultRenderer = ({ result, view, chart }: QueryResultRenderer
|
||||
const { rows, error, autoLimit } = result ?? {}
|
||||
|
||||
if (!result) {
|
||||
return <p className="text-xs text-foreground-lighter">Run the query to see results</p>
|
||||
return <p className="text-xs text-foreground-lighter py-8">Run the query to see results</p>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -22,7 +22,7 @@ export const QueryResultRenderer = ({ result, view, chart }: QueryResultRenderer
|
||||
}
|
||||
|
||||
if ((rows ?? []).length === 0) {
|
||||
return <p className="text-xs text-foreground-lighter">Success. No rows returned</p>
|
||||
return <p className="text-xs text-foreground-lighter py-8">Success. No rows returned</p>
|
||||
}
|
||||
|
||||
if (rows && rows.length > 0) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { type QueryDisplay, type QueryResult } from '../types'
|
||||
import { DisplaySettingsButton } from './DisplaySettingsButton'
|
||||
import { QueryResultRenderer } from './QueryResultRenderer'
|
||||
import { QuerySourceMenu } from './QuerySourceMenu'
|
||||
import { LegacyLogsRewriteBanner } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteBanner'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
import {
|
||||
type DatabaseSourceParameters,
|
||||
@@ -256,24 +257,35 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
</ExplorerToolbar>
|
||||
|
||||
{showQuery && (
|
||||
<ExplorerQueryEditor
|
||||
className={cn('relative', variant === 'viewport' ? 'h-[45%] min-h-48' : undefined)}
|
||||
>
|
||||
<CodeEditor
|
||||
id={`explorer-query-${id}`}
|
||||
language="pgsql"
|
||||
value={sql}
|
||||
placeholder="select * from your_table limit 100;"
|
||||
placeholderClassName="top-[13px]"
|
||||
className={variant === 'embedded' ? 'h-32' : undefined}
|
||||
actions={{ runQuery: { enabled: true, callback: handleRunQuery } }}
|
||||
options={{ minimap: { enabled: false }, padding: { top: 8 } }}
|
||||
onInputChange={(value) => onSqlChange(value ?? '')}
|
||||
onMount={(editor) => {
|
||||
editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current))
|
||||
<>
|
||||
<LegacyLogsRewriteBanner
|
||||
isLogsSource={query._tag === 'logs'}
|
||||
sql={sql}
|
||||
readSql={() => sqlRef.current}
|
||||
onProposal={({ modified }) => {
|
||||
onSqlChange(modified)
|
||||
onSqlCommit?.(modified)
|
||||
}}
|
||||
/>
|
||||
</ExplorerQueryEditor>
|
||||
<ExplorerQueryEditor
|
||||
className={cn('relative', variant === 'viewport' ? 'h-[45%] min-h-48' : undefined)}
|
||||
>
|
||||
<CodeEditor
|
||||
id={`explorer-query-${id}`}
|
||||
language="pgsql"
|
||||
value={sql}
|
||||
placeholder="select * from your_table limit 100;"
|
||||
placeholderClassName="top-[13px]"
|
||||
className={variant === 'embedded' ? 'h-44' : undefined}
|
||||
actions={{ runQuery: { enabled: true, callback: handleRunQuery } }}
|
||||
options={{ minimap: { enabled: false }, padding: { top: 8 } }}
|
||||
onInputChange={(value) => onSqlChange(value ?? '')}
|
||||
onMount={(editor) => {
|
||||
editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current))
|
||||
}}
|
||||
/>
|
||||
</ExplorerQueryEditor>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExplorerQueryResults
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { useFlag } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DiffType } from './SQLEditor.types'
|
||||
import { useSqlEditorAssistant, useSqlEditorRun, useSqlEditorSnippet } from './SQLEditorControllers'
|
||||
import { LegacyLogsRewriteAdmonition } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition'
|
||||
import {
|
||||
LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS,
|
||||
shouldOfferLegacyLogsRewrite,
|
||||
} from '@/data/logs/logs-sql-rewrite'
|
||||
import { useLegacyLogsRewrite } from '@/hooks/analytics/useLegacyLogsRewrite'
|
||||
import { LegacyLogsRewriteBanner as SharedLegacyLogsRewriteBanner } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteBanner'
|
||||
import {
|
||||
getSqlEditorV2StateSnapshot,
|
||||
useSqlEditorV2StateSnapshot,
|
||||
} from '@/state/sql-editor/sql-editor-state'
|
||||
|
||||
/**
|
||||
* Offers to rewrite a logs snippet still written in the old BigQuery dialect
|
||||
* (per-service `FROM` tables, `unnest(metadata)` joins) to ClickHouse SQL. Legacy
|
||||
* Logs Explorer saved queries open in the SQL editor as `log_sql` snippets, and
|
||||
* those queries error against the ClickHouse-backed endpoint the editor runs them
|
||||
* on — this is the in-editor path out.
|
||||
*
|
||||
* The request itself is `useLegacyLogsRewrite` (shared with the Logs Explorer);
|
||||
* this decides when to offer it and routes the result into the editor's existing
|
||||
* AI diff view, so the user accepts or discards it the same way as any other AI
|
||||
* edit rather than having the snippet rewritten under them.
|
||||
* Legacy Logs Explorer saved queries open in the SQL editor as `log_sql` snippets,
|
||||
* and those queries error against the ClickHouse-backed endpoint the editor runs
|
||||
* them on. This adapts the shared rewrite offer to the SQL editor's snippet store
|
||||
* and routes an accepted rewrite into the editor's existing AI diff view, so the
|
||||
* user accepts or discards it the same way as any other AI edit rather than
|
||||
* having the snippet rewritten under them.
|
||||
*
|
||||
* Mount with `key={id}` so the offer resets when the user switches snippets. This
|
||||
* component must NOT be conditionally mounted by its parent — it hides itself, so
|
||||
@@ -38,40 +25,23 @@ export const LegacyLogsRewriteBanner = () => {
|
||||
diff: { isDiffOpen, setSourceSqlDiff, setSelectedDiffType },
|
||||
} = useSqlEditorAssistant()
|
||||
|
||||
const isOtelLogsEnabled = useFlag('otelLegacyLogs')
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
|
||||
// The store is written on every keystroke, so debounce before running the
|
||||
// dialect heuristics — the banner's visibility doesn't need per-character
|
||||
// precision, and a settled value avoids flapping mid-edit.
|
||||
const liveSql = snapV2.snippets[id]?.snippet.content?.unchecked_sql ?? ''
|
||||
const settledSql = useDebounce(liveSql, LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS)
|
||||
|
||||
const isLogsSnippetNeedingRewrite = useMemo(
|
||||
() =>
|
||||
runSource._tag === 'logs' &&
|
||||
shouldOfferLegacyLogsRewrite({ sql: settledSql, isClickhouseLogsEnabled: isOtelLogsEnabled }),
|
||||
[runSource._tag, settledSql, isOtelLogsEnabled]
|
||||
)
|
||||
|
||||
const { state, requestRewrite, dismiss } = useLegacyLogsRewrite({
|
||||
// Rewrite exactly what's in the editor now, not the debounced value the
|
||||
// visibility check used — they differ if the user clicked mid-edit.
|
||||
readSql: () => getSqlEditorV2StateSnapshot().snippets[id]?.snippet.content?.unchecked_sql ?? '',
|
||||
onProposal: ({ original, modified }) => {
|
||||
setSourceSqlDiff({ original, modified })
|
||||
setSelectedDiffType(DiffType.Modification)
|
||||
},
|
||||
})
|
||||
|
||||
// An outcome the user hasn't acknowledged stays up even once the query no longer
|
||||
// looks legacy — otherwise a successful proposal would yank its own result away.
|
||||
const hasUnacknowledgedOutcome = state.status === 'failed' || state.status === 'noRewriteNeeded'
|
||||
const canShowBanner = !isDiffOpen && (isLogsSnippetNeedingRewrite || hasUnacknowledgedOutcome)
|
||||
|
||||
if (!canShowBanner) return null
|
||||
const sql = snapV2.snippets[id]?.snippet.content?.unchecked_sql ?? ''
|
||||
|
||||
return (
|
||||
<LegacyLogsRewriteAdmonition state={state} onRewrite={requestRewrite} onDismiss={dismiss} />
|
||||
<SharedLegacyLogsRewriteBanner
|
||||
isLogsSource={runSource._tag === 'logs'}
|
||||
sql={sql}
|
||||
// Rewrite exactly what's in the editor now, not the debounced value the
|
||||
// visibility check used — they differ if the user clicked mid-edit.
|
||||
readSql={() =>
|
||||
getSqlEditorV2StateSnapshot().snippets[id]?.snippet.content?.unchecked_sql ?? ''
|
||||
}
|
||||
onProposal={({ original, modified }) => {
|
||||
setSourceSqlDiff({ original, modified })
|
||||
setSelectedDiffType(DiffType.Modification)
|
||||
}}
|
||||
hidden={isDiffOpen}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { platformComponents } from 'api-types'
|
||||
import { FeatureFlagContext } from 'common'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LegacyLogsRewriteBanner } from './LegacyLogsRewriteBanner'
|
||||
import { API_URL } from '@/lib/constants'
|
||||
import { createMockOrganizationResponse } from '@/tests/helpers'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock, mswServer } from '@/tests/lib/msw'
|
||||
|
||||
type ProjectDetailResponse = platformComponents['schemas']['ProjectDetailResponse']
|
||||
|
||||
const PROJECT_MOCK: ProjectDetailResponse = {
|
||||
id: 1,
|
||||
ref: 'default',
|
||||
organization_id: 1,
|
||||
name: 'Test Project',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
cloud_provider: 'AWS',
|
||||
region: 'us-east-1',
|
||||
db_host: 'db.default.supabase.co',
|
||||
restUrl: 'https://default.supabase.co/rest/v1/',
|
||||
inserted_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
subscription_id: 'sub_123',
|
||||
is_branch_enabled: false,
|
||||
is_physical_backups_enabled: false,
|
||||
high_availability: false,
|
||||
integration_source: null,
|
||||
connectionString: 'postgresql://postgres@localhost:5432/postgres',
|
||||
is_hibernating: false,
|
||||
}
|
||||
|
||||
const ORG_MOCK = createMockOrganizationResponse({ slug: 'test-org', name: 'Test Org' })
|
||||
|
||||
const LEGACY_SQL = 'select event_message from edge_logs limit 10'
|
||||
|
||||
const renderBanner = ({
|
||||
sql = LEGACY_SQL,
|
||||
isLogsSource = true,
|
||||
isOtelLogsEnabled = true,
|
||||
hidden = false,
|
||||
onProposal = vi.fn(),
|
||||
}: {
|
||||
sql?: string
|
||||
isLogsSource?: boolean
|
||||
isOtelLogsEnabled?: boolean
|
||||
hidden?: boolean
|
||||
onProposal?: (proposal: { original: string; modified: string }) => void
|
||||
} = {}) => {
|
||||
const utils = customRender(
|
||||
<FeatureFlagContext.Provider
|
||||
value={{ configcat: { otelLegacyLogs: isOtelLogsEnabled }, posthog: {}, hasLoaded: true }}
|
||||
>
|
||||
<LegacyLogsRewriteBanner
|
||||
isLogsSource={isLogsSource}
|
||||
sql={sql}
|
||||
readSql={() => sql}
|
||||
onProposal={onProposal}
|
||||
hidden={hidden}
|
||||
/>
|
||||
</FeatureFlagContext.Provider>
|
||||
)
|
||||
return { ...utils, onProposal }
|
||||
}
|
||||
|
||||
describe('LegacyLogsRewriteBanner', () => {
|
||||
beforeEach(() => {
|
||||
addAPIMock({ method: 'get', path: '/platform/projects/:ref', response: PROJECT_MOCK })
|
||||
addAPIMock({ method: 'get', path: '/platform/organizations', response: [ORG_MOCK] })
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel',
|
||||
response: () =>
|
||||
HttpResponse.json<platformComponents['schemas']['AnalyticsResponse']>({ result: [] }),
|
||||
})
|
||||
})
|
||||
|
||||
it('offers a rewrite for legacy logs SQL when the source is logs and the flag is on', async () => {
|
||||
renderBanner()
|
||||
|
||||
expect(
|
||||
await screen.findByText('Logs now run on a ClickHouse-backed engine')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when the source is not logs', () => {
|
||||
renderBanner({ isLogsSource: false })
|
||||
|
||||
expect(screen.queryByText('Logs now run on a ClickHouse-backed engine')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when the SQL does not look legacy', () => {
|
||||
renderBanner({ sql: 'select event_message from logs limit 10' })
|
||||
|
||||
expect(screen.queryByText('Logs now run on a ClickHouse-backed engine')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when the ClickHouse logs flag is off', () => {
|
||||
renderBanner({ isOtelLogsEnabled: false })
|
||||
|
||||
expect(screen.queryByText('Logs now run on a ClickHouse-backed engine')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when a caller suppresses it via `hidden`, even for legacy SQL', () => {
|
||||
renderBanner({ hidden: true })
|
||||
|
||||
expect(screen.queryByText('Logs now run on a ClickHouse-backed engine')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hands an accepted rewrite to the caller via onProposal', async () => {
|
||||
const rewritten = "select event_message from logs where source = 'edge_logs' limit 10"
|
||||
mswServer.use(
|
||||
http.post(`${API_URL}/ai/code/complete`, async () => HttpResponse.json(rewritten))
|
||||
)
|
||||
|
||||
const { onProposal } = renderBanner()
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Rewrite with Assistant' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onProposal).toHaveBeenCalledWith({ original: LEGACY_SQL, modified: rewritten })
|
||||
)
|
||||
})
|
||||
|
||||
it('dismisses the offer', async () => {
|
||||
renderBanner()
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Dismiss' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('Logs now run on a ClickHouse-backed engine')
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
|
||||
it('reports when the assistant found nothing to rewrite', async () => {
|
||||
mswServer.use(
|
||||
http.post(`${API_URL}/ai/code/complete`, async () => HttpResponse.json(LEGACY_SQL))
|
||||
)
|
||||
|
||||
renderBanner()
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Rewrite with Assistant' }))
|
||||
|
||||
expect(await screen.findByText('No rewrite needed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { useFlag } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { LegacyLogsRewriteAdmonition } from './LegacyLogsRewriteAdmonition'
|
||||
import {
|
||||
LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS,
|
||||
shouldOfferLegacyLogsRewrite,
|
||||
} from '@/data/logs/logs-sql-rewrite'
|
||||
import {
|
||||
useLegacyLogsRewrite,
|
||||
type LegacyLogsRewriteProposal,
|
||||
} from '@/hooks/analytics/useLegacyLogsRewrite'
|
||||
|
||||
export type LegacyLogsRewriteBannerProps = {
|
||||
isLogsSource: boolean
|
||||
sql: string
|
||||
readSql: () => string
|
||||
onProposal: (proposal: LegacyLogsRewriteProposal) => void
|
||||
/** An extra hide condition layered on top of the offer's own visibility, for a
|
||||
* surface that has its own reason to suppress it (the SQL editor while a diff
|
||||
* is open). */
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers to rewrite a logs query still written in the old BigQuery dialect
|
||||
* (per-service `FROM` tables, `unnest(metadata)` joins) to ClickHouse SQL.
|
||||
* Shared by every surface that can run a logs query and wants to offer this —
|
||||
* each one decides how to source `sql`/`readSql` and what an accepted
|
||||
* `onProposal` does with the result (the SQL editor opens a diff, Explorer's
|
||||
* query editor swaps the buffer directly).
|
||||
*/
|
||||
export const LegacyLogsRewriteBanner = ({
|
||||
isLogsSource,
|
||||
sql,
|
||||
readSql,
|
||||
onProposal,
|
||||
hidden,
|
||||
}: LegacyLogsRewriteBannerProps) => {
|
||||
const isOtelLogsEnabled = useFlag('otelLegacyLogs')
|
||||
|
||||
const settledSql = useDebounce(sql, LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS)
|
||||
|
||||
const needsRewrite = useMemo(
|
||||
() =>
|
||||
isLogsSource &&
|
||||
shouldOfferLegacyLogsRewrite({ sql: settledSql, isClickhouseLogsEnabled: isOtelLogsEnabled }),
|
||||
[isLogsSource, settledSql, isOtelLogsEnabled]
|
||||
)
|
||||
|
||||
const { state, requestRewrite, dismiss } = useLegacyLogsRewrite({ readSql, onProposal })
|
||||
|
||||
const hasUnacknowledgedOutcome = state.status === 'failed' || state.status === 'noRewriteNeeded'
|
||||
const isEligibleSurface = isLogsSource && isOtelLogsEnabled
|
||||
|
||||
if (hidden || !isEligibleSurface || !(needsRewrite || hasUnacknowledgedOutcome)) return null
|
||||
|
||||
return (
|
||||
<LegacyLogsRewriteAdmonition state={state} onRewrite={requestRewrite} onDismiss={dismiss} />
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user