diff --git a/.github/workflows/docs-e2e.yml b/.github/workflows/docs-e2e.yml index 06c68d4c2f1..0cc745fad25 100644 --- a/.github/workflows/docs-e2e.yml +++ b/.github/workflows/docs-e2e.yml @@ -139,9 +139,12 @@ jobs: # Poll the "Vercel – docs" commit status instead — Vercel keeps posting # those — then resolve the deployment it points to via Vercel's own API # to get the actual preview URL. See scripts/waitForVercelDocsPreview.js. + # A Vercel failure or timeout is not the author's problem, and the required + # "Vercel – docs" check already reports it. Resolve no URL and skip below. - name: Wait for Vercel docs preview if: steps.scope.outputs.skip == 'false' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.changes.outputs.docs_app == 'true' id: deployment + continue-on-error: true run: node scripts/waitForVercelDocsPreview.js env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -156,30 +159,61 @@ jobs: EVENT_NAME: ${{ github.event_name }} BASE_URL_INPUT: ${{ inputs.base_url }} DEPLOYMENT_URL: ${{ steps.deployment.outputs.deployment-url }} - DOCS_APP_CHANGED: ${{ steps.changes.outputs.docs_app }} + PAGE_PATHS: ${{ steps.scope.outputs.paths }} run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then printf 'url=%s\n' "$BASE_URL_INPUT" >> "$GITHUB_OUTPUT" - echo "use_bypass=false" >> "$GITHUB_OUTPUT" - elif [ "$DOCS_APP_CHANGED" = "true" ] && [ -n "$DEPLOYMENT_URL" ]; then - printf 'url=%s\n' "$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT" - echo "use_bypass=true" >> "$GITHUB_OUTPUT" - else - # Harness-only PRs have no docs preview; test against production. - echo "url=https://supabase.com" >> "$GITHUB_OUTPUT" - echo "use_bypass=false" >> "$GITHUB_OUTPUT" + # Non-production targets are previews, which may need the bypass. + if [ "$BASE_URL_INPUT" = "https://supabase.com" ]; then + echo "use_bypass=false" >> "$GITHUB_OUTPUT" + else + echo "use_bypass=true" >> "$GITHUB_OUTPUT" + fi + echo "should_test=true" >> "$GITHUB_OUTPUT" + exit 0 fi + if [ -n "$DEPLOYMENT_URL" ]; then + printf 'url=%s\n' "$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT" + echo "use_bypass=true" >> "$GITHUB_OUTPUT" + echo "should_test=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Production is not a substitute: it lacks pages this pull request + # adds, so testing it fails a required check for a valid change. + echo "url=" >> "$GITHUB_OUTPUT" + echo "use_bypass=false" >> "$GITHUB_OUTPUT" + echo "should_test=false" >> "$GITHUB_OUTPUT" + echo "::warning::No Vercel docs preview URL for this pull request, so there is nothing serving its content to test. Skipping Playwright rather than testing production, which does not have pages this pull request adds." + { + echo "### Docs E2E skipped: no preview to test against" + echo + echo "Nothing is serving this pull request's content, and production is not a" + echo "substitute — pages it adds do not exist there yet." + echo + echo "Fork pull requests reach this path because they run without repository" + echo "secrets. A maintainer can run the suite against the preview manually:" + echo + echo '```' + echo "gh workflow run docs-e2e.yml \\" + echo " -f base_url= \\" + echo " -f page_paths=$PAGE_PATHS" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - name: Install dependencies - if: steps.scope.outputs.skip == 'false' + if: steps.base-url.outputs.should_test == 'true' run: pnpm install --frozen-lockfile --filter=e2e-docs... - name: Install Playwright Chromium - if: steps.scope.outputs.skip == 'false' + if: steps.base-url.outputs.should_test == 'true' run: pnpm -C e2e/docs exec playwright install chromium --with-deps --only-shell - name: Run docs E2E - if: steps.scope.outputs.skip == 'false' + if: steps.base-url.outputs.should_test == 'true' working-directory: e2e/docs run: pnpm run e2e:docs env: diff --git a/apps/docs/DEVELOPERS.md b/apps/docs/DEVELOPERS.md index 10ea54cdb32..37f48953ea1 100644 --- a/apps/docs/DEVELOPERS.md +++ b/apps/docs/DEVELOPERS.md @@ -35,6 +35,26 @@ This creates Markdown files for all routes under the `public/markdown/guides` di For production this setup runs as a `prebuild` task to allow Vercel to bundle these files with middleware and functions. +## Accessibility checks + +Docs pages are scanned for WCAG 2.1 A/AA issues with axe-core, as part of the +Playwright suite in `e2e/docs`. Pull requests scan the pages your change affects, +limited to the main article. + +To scan the pages your current branch changes: + +```bash +PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:a11y +``` + +That resolves which pages to scan from your branch, but reads them from +production, so it won't see your edits and will 404 on a page you just added. +Point `PLAYWRIGHT_BASE_URL` at your pull request's preview to scan your own +content. + +See [`e2e/docs/README.md`](https://github.com/supabase/supabase/blob/master/e2e/docs/README.md) +for coverage and skipped rules. + ## Contributing For repo organization and style guide, see the [contributing guide](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md). diff --git a/apps/docs/components/ContentListings/ContentListings.client.tsx b/apps/docs/components/ContentListings/ContentListings.client.tsx index 548bde14d50..2e699cbd2d6 100644 --- a/apps/docs/components/ContentListings/ContentListings.client.tsx +++ b/apps/docs/components/ContentListings/ContentListings.client.tsx @@ -10,6 +10,7 @@ import { import { useSendTelemetryEvent } from '~/lib/telemetry' import Link from 'next/link' import { useCallback, useMemo } from 'react' +import ReactMarkdown from 'react-markdown' import { Badge } from 'ui' import { GlassPanel } from 'ui-patterns/GlassPanel' import { Heading } from 'ui/src/components/CustomHTMLElements' @@ -66,7 +67,11 @@ function ContentListingsGroup({ group }: { group: ContentListingGroup }) {
- {group.description &&

{group.description}

} + {group.description && ( +
+ {group.description} +
+ )}
+

+ Manual saving is becoming the default for the SQL Editor. This preview can no longer be + turned off. To opt into manual saving before the change reaches your account, enable this + preview. +

) } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index da9c80cf2ba..3587e6b175b 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -14,6 +14,12 @@ export type FeaturePreview = { isDefaultOptIn: boolean /** Visibility in the feature preview modal (For feature flagging a feature preview) */ enabled: boolean + /** + * Forces the preview on for this user, whatever they previously chose — for a + * preview that has become the default behavior. Overrides both `isDefaultOptIn` + * and a stored opt-out, and takes away the ability to turn the preview back off. + */ + isForced?: boolean /** Optional category that the feature preview falls under, defaults to "Others" in the UI otherwise */ category?: 'observability' | 'database' /** @@ -28,10 +34,10 @@ export const useFeaturePreviews = (): FeaturePreview[] => { const isPlatformWebhooksEnabled = useFlag('platformWebhooks') const jitDbAccessEnabled = useFlag('jitDbAccess') const isMarketplaceEnabled = useFlag('marketplaceIntegrations') - const isSqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave') const isDatabaseConnectionsEnabled = useFlag('topForPostgres') const unifiedLogsDefaultOptIn = useFlag('unifiedLogsDefaultOptIn') + const isSqlEditorManualSaveForced = useFlag('sqlEditorManualSaveForced') return useMemo(() => { const previews: FeaturePreview[] = [ @@ -114,7 +120,11 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isNew: true, isPlatformOnly: true, isDefaultOptIn: false, - enabled: isSqlEditorManualSaveEnabled, + enabled: true, + // Manual saving is becoming the default for the SQL Editor. The preview + // stays listed so users who lose their local storage can opt back in + // before the rollout reaches them. + isForced: isSqlEditorManualSaveForced, }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS, @@ -133,10 +143,10 @@ export const useFeaturePreviews = (): FeaturePreview[] => { return previews.sort((a, b) => Number(b.isNew) - Number(a.isNew)) }, [ unifiedLogsDefaultOptIn, + isSqlEditorManualSaveForced, isPlatformWebhooksEnabled, jitDbAccessEnabled, isMarketplaceEnabled, - isSqlEditorManualSaveEnabled, isDatabaseConnectionsEnabled, ]) } diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.test.ts b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.test.ts new file mode 100644 index 00000000000..65aae4e19d1 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.test.ts @@ -0,0 +1,102 @@ +import type { PGPolicy } from '@supabase/pg-meta' +import { describe, expect, it } from 'vitest' + +import { generateUpdatePolicyPayload } from './PolicyEditorPanel.utils' + +type PolicyFixture = Pick + +const mockPolicy = (overrides: Partial = {}): PolicyFixture => ({ + name: 'my_policy', + roles: ['authenticated'], + command: 'DELETE', + definition: 'user_id = auth.uid()', + check: null, + ...overrides, +}) + +const baseForm = { + name: 'my_policy', + roles: ['authenticated'], + // The panel prefills editors with two leading spaces of indentation + using: ' user_id = auth.uid()', + check: undefined, +} + +describe('generateUpdatePolicyPayload', () => { + it('returns an empty payload when nothing changed', () => { + expect(generateUpdatePolicyPayload(mockPolicy(), baseForm)).toEqual({}) + }) + + it('includes the definition when a policy with a null definition gains a using expression', () => { + const payload = generateUpdatePolicyPayload(mockPolicy({ definition: null }), { + ...baseForm, + using: 'true', + }) + expect(payload).toEqual({ definition: 'true' }) + }) + + it('includes the definition when the using expression changed', () => { + const payload = generateUpdatePolicyPayload(mockPolicy(), { ...baseForm, using: 'true' }) + expect(payload).toEqual({ definition: 'true' }) + }) + + it('omits the definition when the using expression is empty', () => { + expect(generateUpdatePolicyPayload(mockPolicy(), { ...baseForm, using: '' })).toEqual({}) + expect(generateUpdatePolicyPayload(mockPolicy(), { ...baseForm, using: ' ' })).toEqual({}) + expect(generateUpdatePolicyPayload(mockPolicy(), { ...baseForm, using: undefined })).toEqual({}) + }) + + it('includes the check when a policy with a null check gains a check expression', () => { + const payload = generateUpdatePolicyPayload(mockPolicy({ command: 'UPDATE' }), { + ...baseForm, + check: 'is_admin()', + }) + expect(payload).toEqual({ check: 'is_admin()' }) + }) + + it('includes the check when the check expression changed', () => { + const payload = generateUpdatePolicyPayload( + mockPolicy({ command: 'UPDATE', check: 'is_admin()' }), + { ...baseForm, check: 'is_owner()' } + ) + expect(payload).toEqual({ check: 'is_owner()' }) + }) + + it('omits the check when the check expression is unchanged or empty', () => { + const withCheck = mockPolicy({ command: 'UPDATE', check: 'is_admin()' }) + expect(generateUpdatePolicyPayload(withCheck, { ...baseForm, check: ' is_admin()' })).toEqual( + {} + ) + expect(generateUpdatePolicyPayload(withCheck, { ...baseForm, check: '' })).toEqual({}) + }) + + it('maps the using editor to the check field for INSERT policies', () => { + const insertPolicy = mockPolicy({ command: 'INSERT', definition: null, check: null }) + const payload = generateUpdatePolicyPayload(insertPolicy, { ...baseForm, using: 'true' }) + expect(payload).toEqual({ check: 'true' }) + }) + + it('never includes a definition for INSERT policies', () => { + const insertPolicy = mockPolicy({ command: 'INSERT', definition: null, check: 'true' }) + const payload = generateUpdatePolicyPayload(insertPolicy, { + ...baseForm, + using: 'is_admin()', + check: 'ignored', + }) + expect(payload).toEqual({ check: 'is_admin()' }) + }) + + it('omits the check for INSERT policies when the expression is unchanged', () => { + const insertPolicy = mockPolicy({ command: 'INSERT', definition: null, check: 'true' }) + expect(generateUpdatePolicyPayload(insertPolicy, { ...baseForm, using: ' true' })).toEqual({}) + }) + + it('includes the name and roles when they changed', () => { + const payload = generateUpdatePolicyPayload(mockPolicy(), { + ...baseForm, + name: 'renamed_policy', + roles: ['anon', 'authenticated'], + }) + expect(payload).toEqual({ name: 'renamed_policy', roles: ['anon', 'authenticated'] }) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts index f0341e8a06b..6e483e8a36f 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts @@ -1,5 +1,12 @@ import type { PGPolicy } from '@supabase/pg-meta' -import { ident, keyword, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' +import { + ident, + keyword, + safeSql, + untrustedSql, + type SafeSqlFragment, + type UntrustedSqlFragment, +} from '@supabase/pg-meta/src/pg-format' import { isEqual } from 'lodash' // [Joshen] Not used but keeping this for now in case we do an inline editor @@ -77,6 +84,55 @@ export const generateCreatePolicyQuery = ({ return safeSql`${withUsing};` } +/** + * Diffs the editor form against the stored policy and returns only the fields + * that changed — an empty result means there is nothing to save. A stored + * `null` definition/check (policy created without that clause) counts as + * empty, so typing an expression into a previously empty editor is a change. + * Empty form values never produce a payload field, because ALTER POLICY can + * only replace an expression, not remove it. + * + * Expressions are returned as UntrustedSqlFragment — the caller promotes them + * with acceptUntrustedSql in the submit handler. + */ +export const generateUpdatePolicyPayload = ( + selectedPolicy: Pick, + policyForm: { + name: string + roles: string[] + using?: string + check?: string + } +): { + name?: string + roles?: string[] + definition?: UntrustedSqlFragment + check?: UntrustedSqlFragment +} => { + const payload: { + name?: string + roles?: string[] + definition?: UntrustedSqlFragment + check?: UntrustedSqlFragment + } = {} + const usingVal = policyForm.using?.trim() + const checkVal = policyForm.check?.trim() + + if (policyForm.name !== selectedPolicy.name) payload.name = policyForm.name + if (!isEqual(selectedPolicy.roles, policyForm.roles)) payload.roles = policyForm.roles + + if (selectedPolicy.command === 'INSERT') { + // For INSERT policies editor one holds the with check expression + if (!!usingVal && usingVal !== selectedPolicy.check) payload.check = untrustedSql(usingVal) + } else { + if (!!usingVal && usingVal !== selectedPolicy.definition) + payload.definition = untrustedSql(usingVal) + if (!!checkVal && checkVal !== selectedPolicy.check) payload.check = untrustedSql(checkVal) + } + + return payload +} + export const checkIfPolicyHasChanged = ( selectedPolicy: PGPolicy, policyForm: { diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx index e24db3b4374..a7688b1fdae 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx @@ -12,7 +12,6 @@ import { import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' -import { isEqual } from 'lodash' import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' @@ -35,7 +34,11 @@ import * as z from 'zod' import { LockedCreateQuerySection, LockedRenameQuerySection } from './LockedQuerySection' import { PolicyDetailsV2 } from './PolicyDetailsV2' -import { checkIfPolicyHasChanged, generateCreatePolicyQuery } from './PolicyEditorPanel.utils' +import { + checkIfPolicyHasChanged, + generateCreatePolicyQuery, + generateUpdatePolicyPayload, +} from './PolicyEditorPanel.utils' import { PolicyEditorPanelHeader } from './PolicyEditorPanelHeader' import { PolicyTemplates } from './PolicyTemplates' import { QueryError } from './QueryError' @@ -172,12 +175,15 @@ export const PolicyEditorPanel = memo(function ({ name, roles: roles.length === 0 ? ['public'] : roles.split(', '), definition: editorOneFormattedValue, - check: command === 'INSERT' ? editorOneFormattedValue : editorTwoFormattedValue, + check: + selectedPolicy.command === 'INSERT' + ? editorOneFormattedValue + : editorTwoFormattedValue, }) : false return policyCreateUnsaved || policyUpdateUnsaved - }, [command, name, roles, selectedPolicy]) + }, [name, roles, selectedPolicy]) const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: hasUnsavedChanges, @@ -192,15 +198,14 @@ export const PolicyEditorPanel = memo(function ({ const usingExpr = command !== 'insert' ? using : undefined const checkExpr = command === 'insert' ? using : check - if (command === 'insert' && !checkExpr?.trim()) { - return setFieldError('Please provide a SQL expression for the WITH CHECK statement') - } else if (command !== 'insert' && !usingExpr?.trim()) { - return setFieldError('Please provide a SQL expression for the USING statement') - } else { - setFieldError(undefined) - } - if (selectedPolicy === undefined) { + if (command === 'insert' && !checkExpr?.trim()) { + return setFieldError('Please provide a SQL expression for the WITH CHECK statement') + } else if (command !== 'insert' && !usingExpr?.trim()) { + return setFieldError('Please provide a SQL expression for the USING statement') + } + setFieldError(undefined) + const sql = generateCreatePolicyQuery({ name, schema, @@ -222,34 +227,37 @@ export const PolicyEditorPanel = memo(function ({ }, }) } else if (selectedProject !== undefined) { - const payload: { - name?: string - definition?: SafeSqlFragment - check?: SafeSqlFragment - roles?: Array - } = {} const updatedRoles = roles.length === 0 ? ['public'] : roles.split(', ') - // Trim for string comparison against the stored policy values. The Save click is the - // explicit user gesture that promotes editor content to executable SQL. - const usingVal = using?.trim() - const checkVal = check?.trim() - - if (name !== selectedPolicy.name) payload.name = name - if (!isEqual(selectedPolicy.roles, updatedRoles)) payload.roles = updatedRoles - if (selectedPolicy.definition !== null && selectedPolicy.definition !== usingVal) - payload.definition = - usingVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(usingVal)) + // A null definition/check is valid (the policy was created without that clause), so + // updates only require an expression where the policy already has one — ALTER POLICY + // can only replace an expression, not remove it. if (selectedPolicy.command === 'INSERT') { - // [Joshen] Cause editor one will be the check statement in this scenario - if (selectedPolicy.check !== usingVal) - payload.check = - usingVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(usingVal)) + if (selectedPolicy.check !== null && !using?.trim()) { + return setFieldError( + 'The WITH CHECK expression cannot be removed. Provide a new expression, or delete and recreate the policy without it.' + ) + } } else { - if (selectedPolicy.check !== checkVal) - payload.check = - checkVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(checkVal)) + if (selectedPolicy.definition !== null && !using?.trim()) { + return setFieldError( + 'The USING expression cannot be removed. Provide a new expression, or delete and recreate the policy without it.' + ) + } + if (selectedPolicy.check !== null && !check?.trim()) { + return setFieldError( + 'The WITH CHECK expression cannot be removed. Provide a new expression, or delete and recreate the policy without it.' + ) + } } + setFieldError(undefined) + + const payload = generateUpdatePolicyPayload(selectedPolicy, { + name, + roles: updatedRoles, + using, + check, + }) if (Object.keys(payload).length === 0) return onSelectCancel() @@ -257,7 +265,15 @@ export const PolicyEditorPanel = memo(function ({ projectRef: selectedProject.ref, connectionString: selectedProject?.connectionString, originalPolicy: selectedPolicy, - payload, + // The Save click is the explicit user gesture that promotes editor content + // to executable SQL. + payload: { + name: payload.name, + roles: payload.roles, + definition: + payload.definition === undefined ? undefined : acceptUntrustedSql(payload.definition), + check: payload.check === undefined ? undefined : acceptUntrustedSql(payload.check), + }, }) } } diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx index 8da3285a784..a670c455cf2 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx @@ -55,7 +55,7 @@ import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by- import { useReplicationPipelineReplicationStatusQuery } from '@/data/replication/pipeline-replication-status-query' import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query' import { useReplicationPipelineVersionQuery } from '@/data/replication/pipeline-version-query' -import { useRestartPipelineHelper } from '@/data/replication/restart-pipeline-helper' +import { useRestartPipelineMutation } from '@/data/replication/restart-pipeline-mutation' import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation' import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation' import { @@ -137,7 +137,7 @@ export const ReplicationPipelineStatus = () => { const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation() const { mutateAsync: stopPipeline, isPending: isStoppingPipeline } = useStopPipelineMutation() - const { restartPipeline } = useRestartPipelineHelper() + const { mutateAsync: restartPipeline } = useRestartPipelineMutation() const destinationName = pipeline?.destination_name const statusName = getStatusName(pipelineStatusData?.status) diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx index 4db6813bef1..51a5faa3a4e 100644 --- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -24,7 +24,7 @@ import { import { PipelineStatusName } from './Replication.constants' import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query' import { Pipeline } from '@/data/replication/pipelines-query' -import { useRestartPipelineHelper } from '@/data/replication/restart-pipeline-helper' +import { useRestartPipelineMutation } from '@/data/replication/restart-pipeline-mutation' import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation' import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation' import { @@ -66,7 +66,7 @@ export const RowMenu = ({ const { mutateAsync: startPipeline } = useStartPipelineMutation() const { mutateAsync: stopPipeline } = useStopPipelineMutation() - const { restartPipeline } = useRestartPipelineHelper() + const { mutateAsync: restartPipeline } = useRestartPipelineMutation() const { getRequestStatus, setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus() const requestStatus = pipeline?.id ? getRequestStatus(pipeline.id) diff --git a/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx index 01e58b27988..d3f551ce4d5 100644 --- a/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx +++ b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx @@ -66,6 +66,32 @@ describe('ErrorMatcher', () => { expect(screen.getByText('UNKNOWN ERROR')).toBeInTheDocument() }) + it('renders the caller-provided fallback when the error is unclassified', () => { + render( + Custom fallback} + /> + ) + expect(screen.getByText('Custom fallback')).toBeInTheDocument() + }) + + it('ignores the caller-provided fallback when the error is classified', () => { + const error = new ConnectionTimeoutError('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT') + render( + Custom fallback} + /> + ) + expect(screen.queryByText('Custom fallback')).not.toBeInTheDocument() + expect(screen.getByText('Try restarting your project')).toBeInTheDocument() + }) + it('accepts error as object with message property', () => { render( - {Troubleshooting && } + {Troubleshooting ? : fallback} ) } diff --git a/apps/studio/components/interfaces/ErrorHandling/RestartTroubleshootingFallback.tsx b/apps/studio/components/interfaces/ErrorHandling/RestartTroubleshootingFallback.tsx new file mode 100644 index 00000000000..6d715d98ff6 --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/RestartTroubleshootingFallback.tsx @@ -0,0 +1,15 @@ +import { TroubleshootingAccordion } from './TroubleshootingAccordion' +import { RestartDatabaseTroubleshootingSection } from './TroubleshootingSections' + +const ERROR_TYPE = 'unknown' + +export function RestartTroubleshootingFallback() { + return ( + + + + ) +} diff --git a/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx b/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx new file mode 100644 index 00000000000..434903793dd --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx @@ -0,0 +1,109 @@ +import { MessageCirclePlus, NotebookText, SquareCode } from 'lucide-react' +import { useState } from 'react' + +import { useCreateNotebook } from './hooks' +import { ActionCard } from '@/components/layouts/Tabs/ActionCard' +import { AssistantChatForm } from '@/components/ui/AIAssistantPanel/AssistantChatForm' +import { AssistantModel } from '@/state/ai-assistant-state' + +export const ExplorerHome = () => { + const { createNotebook } = useCreateNotebook() + + const [value, setValue] = useState('') + const [selectedModel, setSelectedModal] = useState('gpt-5.4-nano') + + const onCreateNotebook = () => {} + const onCreateChat = () => {} + + return ( +
+
+
+

Explore your project

+

+ Ask the Assistant about your data, or begin with a new resource. +

+
+ + setValue(e.target.value)} + selectedModel={selectedModel} + onSelectModel={setSelectedModal} + onSubmit={() => {}} + /> + +
+
+ } + title="Create a notebook" + description="Combine notes, queries, and results" + bgColor="bg-blue-500" + onClick={createNotebook} + /> + } + title="Run SQL" + description="Write and run an ad-hoc query" + bgColor="bg-blue-500" + onClick={onCreateNotebook} + /> +
+
+ +
+

Start with a template

+ +
+ } + title="Authentication health" + description="Notebook template" + bgColor="bg-blue-500" + onClick={onCreateNotebook} + /> + } + title="Signup funnel" + description="Notebook template" + bgColor="bg-blue-500" + onClick={onCreateNotebook} + /> + } + title="Incident review" + description="Notebook template" + bgColor="bg-blue-500" + onClick={onCreateNotebook} + /> + } + title="Investigate errors" + description="Chat template" + bgColor="bg-blue-500" + onClick={onCreateChat} + /> + } + title="Explore your schema" + description="Chat template" + bgColor="bg-blue-500" + onClick={onCreateChat} + /> + } + title="Optimize a query" + description="Chat template" + bgColor="bg-blue-500" + onClick={onCreateChat} + /> +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx new file mode 100644 index 00000000000..d0c58f861f7 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx @@ -0,0 +1,125 @@ +import { useParams } from 'common' +import { Edit, Notebook, NotebookText, Play, Save } from 'lucide-react' +import { useEffect, useEffectEvent, useState } from 'react' +import { AiIconAnimation, Button } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' +import { createTabId, useTabsStateSnapshot } from '@/state/tabs' + +export const NotebookEditor = () => { + const { id } = useParams() + const tabs = useTabsStateSnapshot() + const snap = useNotebooksStateSnapshot() + const stateNotebook = id ? snap.notebooks[id] : undefined + + const { name, content } = stateNotebook?.notebook ?? {} + + const [titleValue, setTitleValue] = useState(name ?? '') + const [isEditingTitle, setIsEditingTitle] = useState(false) + + const handleSaveTitle = () => { + const trimmedName = titleValue.trim() + if (id && trimmedName && trimmedName !== name) { + snap.renameNotebook({ id, name: trimmedName }) + tabs.updateTab(createTabId('notebook', { id }), { label: trimmedName }) + } + + setIsEditingTitle(false) + } + + const registerTab = useEffectEvent(() => { + if (!id) return + tabs.addTab({ + id: createTabId('notebook', { id }), + type: 'notebook', + label: name ?? 'New Notebook', + metadata: { notebookId: id }, + isPreview: false, + }) + }) + + useEffect(() => registerTab(), [id]) + + return ( +
+
+
+ + {isEditingTitle ? ( + setTitleValue(e.target.value)} + onBlur={() => { + if (isEditingTitle) handleSaveTitle() + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + setIsEditingTitle(false) + setTitleValue(name ?? '') + } else if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + handleSaveTitle() + } + }} + /> + ) : ( + + )} +
+
+ + } + className="px-1" + tooltip={{ content: { side: 'bottom', text: 'Run notebook' } }} + /> + } + className="px-1" + tooltip={{ content: { side: 'bottom', text: 'Save changes' } }} + /> +
+
+ +
+
+ {content?.cells.length === 0 && ( + } + title="This notebook is empty" + description="Add a query cell to run SQL against your database or logs." + contentClassName="[&>h3]:text-sm [&>p]:text-xs" + > +
+ + +
+
+ )} +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx new file mode 100644 index 00000000000..de62d78c980 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { NotebookEditor } from '../NotebookEditor' +import { notebooksState } from '@/state/notebooks/notebooks-state' +import type { Notebook } from '@/state/notebooks/types' +import { customRender } from '@/tests/lib/custom-render' + +const { mockUseParams, mockAddTab } = vi.hoisted(() => ({ + mockUseParams: vi.fn(), + mockAddTab: vi.fn(), +})) + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useParams: () => mockUseParams(), + } +}) + +vi.mock('@/state/tabs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTabsStateSnapshot: () => ({ addTab: mockAddTab }), + } +}) + +function makeNotebook(id: string, overrides: Partial = {}): Notebook { + return { + id, + type: 'notebook', + name: 'My Notebook', + description: '', + visibility: 'project', + favorite: false, + owner_id: 7, + project_id: 42, + content: { schema_version: '1.0', cells: [] }, + ...overrides, + } +} + +describe('NotebookEditor tab registration', () => { + beforeEach(() => { + mockAddTab.mockClear() + mockUseParams.mockReturnValue({ ref: 'default', id: 'notebook-1' }) + + // notebooksState is a module-level singleton, so reset the state these tests touch + for (const id of Object.keys(notebooksState.notebooks)) { + delete notebooksState.notebooks[id] + } + notebooksState.needsSaving.clear() + }) + + it('registers a tab with the notebook id, type, loaded name, and metadata', () => { + notebooksState.setNotebook({ + projectRef: 'default', + notebook: makeNotebook('notebook-1', { name: 'My Notebook' }), + }) + + customRender() + + expect(mockAddTab).toHaveBeenCalledTimes(1) + expect(mockAddTab).toHaveBeenCalledWith({ + id: 'notebook-notebook-1', + type: 'notebook', + label: 'My Notebook', + metadata: { notebookId: 'notebook-1' }, + isPreview: false, + }) + }) + + it('falls back to "New Notebook" as the label when the notebook has not loaded yet', () => { + customRender() + + expect(mockAddTab).toHaveBeenCalledWith({ + id: 'notebook-notebook-1', + type: 'notebook', + label: 'New Notebook', + metadata: { notebookId: 'notebook-1' }, + isPreview: false, + }) + }) + + it('does not register a tab when there is no id in the route', () => { + mockUseParams.mockReturnValue({ ref: 'default', id: undefined }) + + customRender() + + expect(mockAddTab).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/hooks.ts b/apps/studio/components/interfaces/Explorer/hooks.ts new file mode 100644 index 00000000000..5c23c31eee1 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/hooks.ts @@ -0,0 +1,43 @@ +import { useRouter } from 'next/router' + +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { generateUuid } from '@/lib/api/snippets.browser' +import { useProfile } from '@/lib/profile' +import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' +import { type Notebook } from '@/state/notebooks/types' + +export const useCreateNotebook = () => { + const router = useRouter() + const { profile } = useProfile() + const { data: project } = useSelectedProjectQuery() + const notebooksSnap = useNotebooksStateSnapshot() + + const createNotebook = ({ id: idOverride, name }: { id?: string; name?: string } = {}) => { + if (!profile) return console.error('Profile is required') + if (!project) return console.error('Project is required') + + const id = idOverride ?? generateUuid() + + const notebook: Notebook = { + id, + type: 'notebook', + name: name ?? 'New Notebook', + description: '', + visibility: 'project', + favorite: false, + content: { + schema_version: '1.0', + cells: [], + }, + owner_id: profile.id, + project_id: project.id, + } + + notebooksSnap.addNotebook({ projectRef: project.ref, notebook }) + notebooksSnap.addNeedsSaving(notebook.id) + + router.push(`/project/${project.ref}/explorer/notebook/${notebook.id}`) + } + + return { createNotebook } +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx index b1496a1dc3c..05cfc78cf37 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx @@ -214,7 +214,7 @@ export const QueueSettings = ({}: QueueSettingsProps) => { toast.success('Successfully updated permissions') setOpen(false) } catch (error: unknown) { - toast.error(`Failed to update permissions: ${getErrorMessage(error)}`) + toast.error(`Failed to update permissions: ${getErrorMessage(error, 'unknown error')}`) } finally { setIsSaving(false) } diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx index 2a491957170..8a4e66b84a5 100644 --- a/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx @@ -8,6 +8,7 @@ import { SupabaseProjectSelector, } from './ProjectLinkerComponents' import { Project, ProjectLinkerProps } from './VercelGithub.types' +import { InterstitialActionError } from '@/components/layouts/InterstitialLayout' import ShimmerLine from '@/components/ui/ShimmerLine' import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' @@ -31,6 +32,8 @@ export const ProjectLinker = ({ defaultForeignProjectId, mode, variant = 'default', + actionError, + onSelectionChange, }: ProjectLinkerProps) => { const [openProjectsDropdown, setOpenProjectsDropdown] = useState(false) const [openForeignProjectsComboBox, setOpenForeignProjectsComboBox] = useState(false) @@ -38,6 +41,7 @@ export const ProjectLinker = ({ defaultForeignProjectId ) const [selectedSupabaseProject, setSelectedSupabaseProject] = useState() + const [validationError, setValidationError] = useState() const { data: selectedOrganization } = useSelectedOrganizationQuery() const { data: orgProjects, isPending: loadingSupabaseProjects } = useOrgProjectsInfiniteQuery({ @@ -60,11 +64,15 @@ export const ProjectLinker = ({ const alreadyInstalled = flatInstalledConnectionsIds.has(foreignProjectId ?? '') if (alreadyInstalled) { - return toast.error( - `Unable to connect to ${selectedForeignProject.name}: Selected repository already has an installed connection to a project` - ) + const message = `Unable to connect to ${selectedForeignProject.name}: Selected repository already has an installed connection to a project` + if (variant === 'interstitial') { + setValidationError(message) + return + } + return toast.error(message) } + setValidationError(undefined) _onCreateConnections({ organizationIntegrationId: organizationIntegrationId!, connection: { @@ -95,6 +103,17 @@ export const ProjectLinker = ({ isLoading || !selectedSupabaseProject || !selectedForeignProject + const displayedActionError = actionError ?? validationError + const setForeignProjectSelection: typeof setForeignProjectId = (value) => { + setForeignProjectId(value) + setValidationError(undefined) + onSelectionChange?.() + } + const setSupabaseProjectSelection: typeof setSelectedSupabaseProject = (value) => { + setSelectedSupabaseProject(value) + setValidationError(undefined) + onSelectionChange?.() + } useEffect(() => { if (defaultSupabaseProject !== undefined && selectedSupabaseProject === undefined) @@ -134,7 +153,7 @@ export const ProjectLinker = ({ selectedSupabaseProject={selectedSupabaseProject} loadingSupabaseProjects={loadingSupabaseProjects} setOpen={setOpenProjectsDropdown} - setSelectedSupabaseProject={setSelectedSupabaseProject} + setSelectedSupabaseProject={setSupabaseProjectSelection} />
@@ -151,7 +170,7 @@ export const ProjectLinker = ({ loadingForeignProjects={loadingForeignProjects} foreignProjects={foreignProjects} integrationIcon={integrationIcon} - setForeignProjectId={setForeignProjectId} + setForeignProjectId={setForeignProjectSelection} onOpenChange={setOpenForeignProjectsComboBox} getForeignProjectIcon={getForeignProjectIcon} /> @@ -159,17 +178,20 @@ export const ProjectLinker = ({ )} - +
+ + +
) } @@ -216,7 +238,7 @@ export const ProjectLinker = ({ selectedSupabaseProject={selectedSupabaseProject} loadingSupabaseProjects={loadingSupabaseProjects} setOpen={setOpenProjectsDropdown} - setSelectedSupabaseProject={setSelectedSupabaseProject} + setSelectedSupabaseProject={setSupabaseProjectSelection} /> @@ -236,7 +258,7 @@ export const ProjectLinker = ({ loadingForeignProjects={loadingForeignProjects} foreignProjects={foreignProjects} integrationIcon={integrationIcon} - setForeignProjectId={setForeignProjectId} + setForeignProjectId={setForeignProjectSelection} onOpenChange={setOpenForeignProjectsComboBox} getForeignProjectIcon={getForeignProjectIcon} /> diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/VercelGithub.types.ts b/apps/studio/components/interfaces/Integrations/VercelGithub/VercelGithub.types.ts index aa745b4d43b..ced750b9887 100644 --- a/apps/studio/components/interfaces/Integrations/VercelGithub/VercelGithub.types.ts +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/VercelGithub.types.ts @@ -33,4 +33,6 @@ export interface ProjectLinkerProps { defaultForeignProjectId?: string mode: 'Vercel' | 'GitHub' variant?: 'default' | 'interstitial' + actionError?: string + onSelectionChange?: () => void } diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx index 42fababc170..fcd26bfb2e1 100644 --- a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx @@ -77,7 +77,6 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } onSuccess: (_, variables) => { setLinkedOrgSlug(variables.slug) }, - onError: () => undefined, }) const linkError = linkOrganizationError ? `Failed to link organization: ${linkOrganizationError.message}` diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx index 774ee1f63d7..d765fa85c95 100644 --- a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx @@ -16,6 +16,7 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { BASE_PATH } from '@/lib/constants' import { EMPTY_ARR } from '@/lib/void' import { useSidePanelsStateSnapshot } from '@/state/side-panels' +import type { ResponseError } from '@/types' const VERCEL_ICON = ( @@ -82,6 +83,9 @@ export const SidePanelVercelProjectLinker = () => { sidePanelStateSnapshot.setVercelConnectionsOpen(false) }, + onError(error: ResponseError) { + toast.error(`Failed to create connection: ${error.message}`) + }, }) const onCreateConnections = useCallback( diff --git a/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx b/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx index 7d129542db9..c19aa0349c8 100644 --- a/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx +++ b/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx @@ -34,7 +34,7 @@ export const ProjectClaimConfirm = ({ const { invalidateProjectsQuery } = useInvalidateProjectsInfiniteQuery() const { mutateAsync: approveRequest, isPending: isApproving } = - useApiAuthorizationApproveMutation({ onError: () => {} }) + useApiAuthorizationApproveMutation() const { mutateAsync: claimProject, isPending: isClaiming } = useOrganizationProjectClaimMutation() diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index bdd1c39c301..a5a92456a55 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -78,8 +78,6 @@ export const OrganizationInvite = () => { onSuccess: () => { router.push('/organizations') }, - // [Joshen] Silence the default toast handler - onError: () => {}, }) async function handleJoinOrganization() { diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts index cb820643b13..ec08c1218ef 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts @@ -24,15 +24,18 @@ describe('High Availability project creation constraints', () => { }) it.each([ - ['local', 'eu-central-1'], + ['local', undefined], ['staging', 'us-east-1'], ['prod', undefined], - ])('limits %s projects to the required region', (environment, expectedRegion) => { - const regions = [{ code: 'us-east-1' }, { code: 'eu-central-1' }] + ])( + 'applies the %s region restriction to high availability projects', + (environment, expectedRegion) => { + const regions = [{ code: 'us-east-1' }, { code: 'eu-central-1' }, { code: 'ap-southeast-1' }] - expect(filterHighAvailabilityRegions(regions, true, environment)).toEqual( - expectedRegion === undefined ? regions : [{ code: expectedRegion }] - ) - expect(filterHighAvailabilityRegions(regions, false, environment)).toEqual(regions) - }) + expect(filterHighAvailabilityRegions(regions, true, environment)).toEqual( + expectedRegion === undefined ? regions : [{ code: expectedRegion }] + ) + expect(filterHighAvailabilityRegions(regions, false, environment)).toEqual(regions) + } + ) }) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts index a316856a645..a99780d99c7 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts @@ -47,8 +47,9 @@ export const instanceLabel = (instance: string | undefined): string => { export const getHighAvailabilityRegionCode = ( environment = process.env.NEXT_PUBLIC_ENVIRONMENT ) => { - if (environment === 'local') return 'eu-central-1' + // Local dev stacks can run in any of the supported regions, so they're left unrestricted if (environment === 'staging') return 'us-east-1' + if (environment === 'local') return 'eu-central-1' return undefined } @@ -57,8 +58,9 @@ export const filterHighAvailabilityRegions = ( highAvailability: boolean, environment = process.env.NEXT_PUBLIC_ENVIRONMENT ) => { + const isLocal = environment === 'local' const regionCode = getHighAvailabilityRegionCode(environment) - return highAvailability && regionCode !== undefined + return highAvailability && !isLocal && regionCode !== undefined ? regions.filter((region) => region.code === regionCode) : regions } diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index 4aee33481c3..5e3ad93b6db 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -150,6 +150,7 @@ export const ProjectCreationForm = ({ const [allProjects, setAllProjects] = useState(undefined) const [isComputeCostsConfirmationModalVisible, setIsComputeCostsConfirmationModalVisible] = useState(false) + const [projectCreationError, setProjectCreationError] = useState() const form = useForm>({ resolver: zodResolver(FormSchema), @@ -327,6 +328,7 @@ export const ProjectCreationForm = ({ isSuccess: isSuccessNewProject, } = useProjectCreateMutation({ onSuccess: (res) => { + setProjectCreationError(undefined) track( 'project_creation_simple_version_submitted', { @@ -349,6 +351,11 @@ export const ProjectCreationForm = ({ if (surface === 'main') router.push(`/project/${res.ref}`) }, onError: (error) => { + if (isVercelIntegrationFlow) { + setProjectCreationError(`Failed to create new project: ${error.message}`) + trackFunnelError('project_creation', classifyApiError('project_creation', error), 'form') + return + } const toastId = toast.error(`Failed to create new project: ${error.message}`) trackFunnelError( 'project_creation', @@ -376,6 +383,7 @@ export const ProjectCreationForm = ({ const onSubmit = async (values: z.infer) => { if (!currentOrg) return console.error('Unable to retrieve current organization') + setProjectCreationError(undefined) const { cloudProvider, @@ -401,13 +409,27 @@ export const ProjectCreationForm = ({ const customPostgresVersion = highAvailability ? undefined : postgresVersion if (customPostgresVersion && !customPostgresVersion.match(/1[2-9]\..*/)) { - return toast.error( - `Invalid Postgres version, should start with a number between 12-19, a dot and additional characters, i.e. 15.2 or 15.2.0-3` - ) + const message = + 'Invalid Postgres version, should start with a number between 12-19, a dot and additional characters, i.e. 15.2 or 15.2.0-3' + if (isVercelIntegrationFlow) { + setProjectCreationError(message) + return + } + return toast.error(message) } if (useOrioleDb && !availableOrioleVersion) { - const toastId = toast.error('No available OrioleDB image found, only Postgres is available') + const message = 'No available OrioleDB image found, only Postgres is available' + if (isVercelIntegrationFlow) { + setProjectCreationError(message) + trackFunnelError( + 'project_creation', + { errorCategory: 'validation', errorReason: 'oriole_unavailable' }, + 'form' + ) + return + } + const toastId = toast.error(message) trackFunnelError( 'project_creation', { errorCategory: 'validation', errorReason: 'oriole_unavailable' }, @@ -754,6 +776,13 @@ export const ProjectCreationForm = ({ ) : null} )} + {projectCreationError && ( + +

+ {projectCreationError} +

+ + )} diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 42119e81ffa..55f5898d54c 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -69,6 +69,8 @@ const getDisplayNameForSmartRegion = (name: string): string => { return name } +const isLocal = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' + export const RegionSelector = ({ form, instanceSize, @@ -104,7 +106,7 @@ export const RegionSelector = ({ const allSmartRegions = availableRegionsData?.all.smartGroup ?? [] const allRegions = availableRegionsData?.all.specific ?? [] const restrictHighAvailabilityRegion = - highAvailability && highAvailabilityRegionCode !== undefined + highAvailability && !isLocal && highAvailabilityRegionCode !== undefined const smartRegions = highAvailability ? [] : allSmartRegions const recommendedSmartRegions = new Set( @@ -131,9 +133,8 @@ export const RegionSelector = ({ ) const isLoading = smartRegionEnabled ? isLoadingAvailableRegions : isLoadingDefaultRegion - const showNonProdFields = - process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || - process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + const isLocalEnvironment = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' + const showNonProdFields = isLocalEnvironment || process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' const allSelectableRegions = [...smartRegions, ...regionOptions] @@ -220,6 +221,11 @@ export const RegionSelector = ({
  • Central EU (Frankfurt)
  • Southeast Asia (Singapore)
  • + {isLocalEnvironment && ( +

    + Use Central EU (Frankfurt) unless you're on a personal dev stack. +

    + )} ) )} diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/LogsSnippetReportBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/LogsSnippetReportBlock.tsx new file mode 100644 index 00000000000..c6114a47613 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/ReportBlock/LogsSnippetReportBlock.tsx @@ -0,0 +1,37 @@ +import { ScrollText } from 'lucide-react' +import { ReactNode } from 'react' + +import { ReportBlockContainer } from './ReportBlockContainer' + +interface LogsSnippetReportBlockProps { + label: string + actions?: ReactNode +} + +/** + * Stands in for a snippet that queries the logs backend. Reports only run SQL + * against the user's database, so a `log_sql` snippet reference renders this + * instead of executing — see ReportBlock, which never issues a query for one. + */ +export const LogsSnippetReportBlock = ({ label, actions }: LogsSnippetReportBlockProps) => { + return ( + } + label={label} + actions={actions} + > +
    +

    + Logs snippets aren't supported in reports yet +

    +

    + Reports can't run queries against the logs backend yet. Open this snippet in the SQL + editor to view results, or remove it from this report. +

    +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx index ba68a57a127..2fea6cc8265 100644 --- a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx +++ b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx @@ -9,6 +9,7 @@ import { applyAutoLimit } from '../../SQLEditor/SQLEditor.utils' import { BURSTABLE_IO_METRIC_KEYS, DEPRECATED_REPORTS } from '../Reports.constants' import { ChartBlock } from './ChartBlock' import { DeprecatedChartBlock } from './DeprecatedChartBlock' +import { LogsSnippetReportBlock } from './LogsSnippetReportBlock' import { UnavailableChartBlock } from './UnavailableChartBlock' import { hasBurstableIO } from '@/components/interfaces/DiskManagement/DiskManagement.utils' import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' @@ -79,8 +80,11 @@ export const ReportBlock = ({ } ) + const isLogsSnippet = data?.type === 'log_sql' + const autoLimit = 100 - const sql = isSnippet ? (data?.content as SqlSnippets.Content)?.unchecked_sql : undefined + const sql = + isSnippet && !isLogsSnippet ? (data?.content as SqlSnippets.Content)?.unchecked_sql : undefined // acceptUntrustedSql is usually not allowed outside a user-action event // handler, but it's explicitly fine here: adding this block to a report is // itself the user action that approves running its SQL. @@ -125,7 +129,7 @@ export const ReportBlock = ({ sql: formattedSql, }) }, - enabled: !isLoadingContent && contentError == null, + enabled: !isLoadingContent && contentError == null && !isLogsSnippet, refetchOnWindowFocus: false, }) @@ -151,6 +155,25 @@ export const ReportBlock = ({ } }, [isRefreshing, refetch]) + if (isLogsSnippet) { + return ( + } + className="w-7 h-7" + onClick={() => onRemoveChart({ metric: { key: item.attribute } })} + tooltip={{ content: { side: 'bottom', text: 'Remove chart' } }} + /> + ) : null + } + /> + ) + } + return ( <> {isSnippet ? ( diff --git a/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx b/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx new file mode 100644 index 00000000000..9bb41fe5430 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx @@ -0,0 +1,77 @@ +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 { + 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. + * + * 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 + * that opening a diff doesn't unmount it and throw away a dismissal. + */ +export const LegacyLogsRewriteBanner = () => { + const { id } = useSqlEditorSnippet() + const { runSource } = useSqlEditorRun() + const { + 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.type === 'logs' && + shouldOfferLegacyLogsRewrite({ sql: settledSql, isClickhouseLogsEnabled: isOtelLogsEnabled }), + [runSource.type, 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 + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index 982ef10bb16..d981a2da927 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -29,6 +29,7 @@ import { resolveConnectionString, resolveDiffKeyAction, shouldAutoGenerateTitle, + sqlSourceToDialect, trimTrailingSemicolons, } from './SQLEditor.utils' import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' @@ -392,13 +393,26 @@ describe('SQLEditor.utils.ts:buildDebugChatArgs', () => { test('builds the newChat payload from the snippet sql and error message', () => { const snippet = buildDebugSnippet('select 1;') const result = { error: { message: 'relation does not exist' } } - expect(buildDebugChatArgs(snippet, result)).toEqual({ + expect(buildDebugChatArgs(snippet, result, 'database')).toEqual({ name: 'Debug SQL snippet', - sqlSnippets: ['select 1;'], + sqlSnippets: [{ label: 'Current Query', content: 'select 1;', source: 'database' }], initialInput: 'Help me to debug the attached sql snippet which gives the following error: \n\nrelation does not exist', }) }) + + // The attachment is what puts sqlSource on the message the user then submits, so + // the debug flow has to attach a sourced snippet, not a bare string. + test('attaches the query with its source and names the dialect', () => { + const snippet = buildDebugSnippet('select count(*) from logs;') + const result = { error: { message: 'Unknown expression identifier' } } + expect(buildDebugChatArgs(snippet, result, 'logs').sqlSnippets).toEqual([ + { label: 'Current Query', content: 'select count(*) from logs;', source: 'logs' }, + ]) + expect(buildDebugChatArgs(snippet, result, 'logs').initialInput).toEqual( + 'Help me to debug the attached sql snippet which gives the following error: \n\nUnknown expression identifier\n\nThis query runs against the Supabase logs table on a ClickHouse-backed engine, not Postgres.' + ) + }) }) describe('SQLEditor.utils.ts:buildCompletionRequestBody', () => { @@ -432,6 +446,31 @@ describe('SQLEditor.utils.ts:buildCompletionRequestBody', () => { completionMetadata: { prompt: 'add a where clause' }, }) }) + test('omits dialect when not provided, so the route keeps its Postgres default', () => { + const body = buildCompletionRequestBody({ + projectRef: 'default', + connectionString: null, + orgSlug: 'acme', + }) + expect(body).not.toHaveProperty('dialect') + }) + test('includes the dialect when provided', () => { + expect( + buildCompletionRequestBody({ + projectRef: 'default', + connectionString: null, + orgSlug: 'acme', + dialect: 'clickhouse', + }).dialect + ).toBe('clickhouse') + }) +}) + +describe('SQLEditor.utils.ts:sqlSourceToDialect', () => { + test('logs snippets get ClickHouse, database snippets get Postgres', () => { + expect(sqlSourceToDialect('logs')).toBe('clickhouse') + expect(sqlSourceToDialect('database')).toBe('postgres') + }) }) describe('SQLEditor.utils.ts:createSqlSnippetSkeletonV2', () => { @@ -1438,9 +1477,21 @@ describe('SQLEditor.utils:assembleCompletionDiff', () => { describe('SQLEditor.utils:buildDebugPromptText', () => { it('builds the debug prompt with the error message and SQL block', () => { - const result = buildDebugPromptText('select 1;', 'relation does not exist') + const result = buildDebugPromptText('select 1;', 'relation does not exist', 'database') expect(result).toContain('relation does not exist') expect(result).toContain('```sql\nselect 1;\n```') + expect(result).not.toContain('ClickHouse') + }) + + // This text is copyable and gets pasted into external models, so it has to name + // the dialect itself rather than relying on the message metadata. + it('names the dialect for a logs snippet', () => { + const sql = "select count() from logs where source = 'edge_logs'" + const result = buildDebugPromptText(sql, 'Unknown expression identifier', 'logs') + expect(result).toContain('Unknown expression identifier') + expect(result).toContain('ClickHouse') + expect(result).toContain('not Postgres') + expect(result).toContain('```clickhouse\n' + sql + '\n```') }) }) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index aa1ba912080..188c8c0fdce 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -7,7 +7,7 @@ import { } from '@supabase/pg-meta' import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants' -import type { SqlSnippetSource } from './querySource' +import { isLogsSource, sqlSourceToFenceLanguage, type SqlSnippetSource } from './querySource' import { alterDatabasePreventConnectionStatements, destructiveSqlRegex, @@ -497,25 +497,46 @@ export function assembleCompletionDiff( } /** - * Builds the request body sent to the AI completion endpoint. `options` is - * the caller-provided extra fields (e.g. `completionMetadata`), merged in - * last so it can override the defaults if it ever needs to. + * The SQL dialect the AI writes. Mirrors the `dialect` enum the completion API + * route accepts — a snippet's dialect follows its source and never flips, so a + * logs snippet always gets ClickHouse SQL and a database snippet Postgres. + */ +export type SqlDialect = 'postgres' | 'clickhouse' + +/** + * Maps a snippet's query source to the dialect the AI should write in. Logs + * snippets run against the ClickHouse-backed analytics endpoint; everything + * else runs against the user's Postgres database. + */ +export function sqlSourceToDialect(source: SqlSnippetSource): SqlDialect { + return isLogsSource(source) ? 'clickhouse' : 'postgres' +} + +/** + * Builds the request body sent to the AI completion endpoint. `dialect` is + * omitted when undefined so callers that don't care keep the route's Postgres + * default. `options` is the caller-provided extra fields (e.g. + * `completionMetadata`), merged in last so it can override the defaults if it + * ever needs to. */ export function buildCompletionRequestBody({ projectRef, connectionString, orgSlug, + dialect, options, }: { projectRef: string | undefined connectionString: string | undefined | null orgSlug: string | undefined + dialect?: SqlDialect options?: { completionMetadata?: unknown } }): { projectRef: string | undefined connectionString: string | undefined | null language: 'sql' orgSlug: string | undefined + dialect?: SqlDialect completionMetadata?: unknown } { return { @@ -523,16 +544,37 @@ export function buildCompletionRequestBody({ connectionString, language: 'sql', orgSlug, + ...(dialect !== undefined && { dialect }), ...(options ?? {}), } } /** - * Builds the prompt text used to ask the assistant to debug a failing snippet. + * Names the dialect for a logs snippet, whose SQL is ClickHouse against the `logs` + * table. The in-app assistant also learns this from the message's `sqlSource` + * metadata, but the same text is offered as "Copy prompt" and pasted into external + * models, so it has to stand on its own — otherwise a logs error gets Postgres advice. */ -export function buildDebugPromptText(sql: string, errorMessage: string): string { - const prompt = `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}` - return `${prompt}\n\nSQL Query:\n\`\`\`sql\n${sql}\n\`\`\`` +const CLICKHOUSE_LOGS_DEBUG_HINT = + 'This query runs against the Supabase logs table on a ClickHouse-backed engine, not Postgres.' + +/** The shared ask + error + dialect preamble behind both debug entry points. */ +function buildDebugRequestText(errorMessage: string, source: SqlSnippetSource): string { + const ask = `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}` + return isLogsSource(source) ? `${ask}\n\n${CLICKHOUSE_LOGS_DEBUG_HINT}` : ask +} + +/** + * Builds the prompt text used to ask the assistant to debug a failing snippet, and + * offered verbatim as the dropdown's copyable prompt. + */ +export function buildDebugPromptText( + sql: string, + errorMessage: string, + source: SqlSnippetSource +): string { + const fence = sqlSourceToFenceLanguage(source) + return `${buildDebugRequestText(errorMessage, source)}\n\nSQL Query:\n\`\`\`${fence}\n${sql}\n\`\`\`` } // Accepts either brand: the debug flow only reads the SQL as text (it's stripped @@ -564,13 +606,18 @@ export function extractDebugContext( */ export function buildDebugChatArgs( snippet: DebugSnippet, - result: DebugResult -): { name: string; sqlSnippets: string[]; initialInput: string } { + result: DebugResult, + source: SqlSnippetSource +): { + name: string + sqlSnippets: Array<{ label: string; content: string; source: SqlSnippetSource }> + initialInput: string +} { const { sql, errorMessage } = extractDebugContext(snippet, result) return { name: 'Debug SQL snippet', - sqlSnippets: [sql], - initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}`, + sqlSnippets: [{ label: 'Current Query', content: sql, source }], + initialInput: buildDebugRequestText(errorMessage, source), } } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx index 0aa70a05313..f4738c230a9 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx @@ -191,7 +191,7 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => const isExecuting = isExecutingDb || isExecutingLogs - const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt }) + const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt, sqlSource: runSource.type }) const { acceptAiHandler, discardAiHandler } = ai useSqlEditorShortcuts({ diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx index 149a713f4e5..6215f6580d6 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx @@ -4,6 +4,7 @@ import dynamic from 'next/dynamic' import { useCallback } from 'react' import { cn } from 'ui' +import { LegacyLogsRewriteBanner } from './LegacyLogsRewriteBanner' import { useSQLEditorContext } from './SQLEditorContext' import { useSqlEditorAssistant, @@ -171,23 +172,32 @@ const SQLEditorMainView = () => { ) } -/** The top (editor) resizable panel: loading state, diff view, and main editor. */ -export const SQLEditorEditorPanel = () => { - const { isLoading } = useSqlEditorSnippet() - const { diff } = useSqlEditorAssistant() - +function LoadingSpinner() { return ( -
    - {isLoading ? ( -
    - -
    - ) : ( - <> - {diff.isDiffOpen && } - - - )} +
    +
    + +
    +
    + ) +} + +/** The top (editor) resizable panel: loading state, diff view, and main editor. */ +export const SQLEditorEditorPanel = () => { + const { id, isLoading } = useSqlEditorSnippet() + const { diff } = useSqlEditorAssistant() + + if (isLoading) { + return + } + + return ( +
    + +
    + {diff.isDiffOpen && } + +
    ) } diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.tsx new file mode 100644 index 00000000000..fbbf96cff05 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.tsx @@ -0,0 +1,85 @@ +import { LOCAL_STORAGE_KEYS, safeLocalStorage, useFlag } from 'common' +import { useEffect } from 'react' +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + KeyboardShortcut, +} from 'ui' + +import { shouldShowManualSaveNotice } from './SqlEditorManualSaveNoticeDialog.utils' +import { useFeaturePreviewContext } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { IS_PLATFORM } from '@/lib/constants' + +/** + * One-time notice for users the manual saving rollout switches over, shown the + * first time they land on a SQL Editor route. Users who opted into the preview + * themselves already know the behavior and never see it. + */ +export const SqlEditorManualSaveNoticeDialog = () => { + const { isInitialized } = useFeaturePreviewContext() + const isForced = useFlag('sqlEditorManualSaveForced') + + const [isNoticeDismissed, setIsNoticeDismissed, { isSuccess: isDismissalLoaded }] = + useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED, false) + + const hasOptedIntoPreview = + safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE) === 'true' + + // Record the dismissal up front for users who opted in themselves. The notice + // outlives the feature preview — once the preview is retired there's no stored + // opt-in left to recognize them by, and without this they'd be shown a notice + // about behavior they chose. + useEffect(() => { + if (isDismissalLoaded && hasOptedIntoPreview && !isNoticeDismissed) { + setIsNoticeDismissed(true) + } + }, [isDismissalLoaded, hasOptedIntoPreview, isNoticeDismissed, setIsNoticeDismissed]) + + const isOpen = + isDismissalLoaded && + shouldShowManualSaveNotice({ + isPlatform: IS_PLATFORM, + isFeaturePreviewInitialized: isInitialized, + isForced, + hasOptedIntoPreview, + isNoticeDismissed, + }) + + return ( + !open && setIsNoticeDismissed(true)}> + + + Snippets no longer save automatically + + The SQL Editor now saves your snippets only when you save them. + + + + + + +
      +
    • + Save with the Save button next to Run, or with{' '} + +
    • +
    • Tabs with unsaved edits show a dot
    • +
    • Closing a tab with unsaved edits discards them
    • +
    +
    + + + + +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts new file mode 100644 index 00000000000..3b68fef5667 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { + shouldShowManualSaveNotice, + type ShouldShowManualSaveNoticeParams, +} from './SqlEditorManualSaveNoticeDialog.utils' + +const eligible: ShouldShowManualSaveNoticeParams = { + isPlatform: true, + isFeaturePreviewInitialized: true, + isForced: true, + hasOptedIntoPreview: false, + isNoticeDismissed: false, +} + +describe('shouldShowManualSaveNotice', () => { + it('shows the notice to a user the rollout switches over', () => { + expect(shouldShowManualSaveNotice(eligible)).toBe(true) + }) + + it('does not show the notice to a user who opted into the preview themselves', () => { + expect(shouldShowManualSaveNotice({ ...eligible, hasOptedIntoPreview: true })).toBe(false) + }) + + it('does not show the notice once dismissed', () => { + expect(shouldShowManualSaveNotice({ ...eligible, isNoticeDismissed: true })).toBe(false) + }) + + it('does not show the notice when the rollout flag is off', () => { + expect(shouldShowManualSaveNotice({ ...eligible, isForced: false })).toBe(false) + }) + + it('does not show the notice outside of the hosted platform', () => { + expect(shouldShowManualSaveNotice({ ...eligible, isPlatform: false })).toBe(false) + }) + + it('does not show the notice before the feature previews have initialized', () => { + expect(shouldShowManualSaveNotice({ ...eligible, isFeaturePreviewInitialized: false })).toBe( + false + ) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts new file mode 100644 index 00000000000..86556c08bba --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts @@ -0,0 +1,36 @@ +export interface ShouldShowManualSaveNoticeParams { + /** Manual saving is a platform-only feature preview */ + isPlatform: boolean + /** Whether the feature preview flags reflect loaded values rather than pre-load defaults */ + isFeaturePreviewInitialized: boolean + /** Whether the rollout flag forces manual saving on for this user */ + isForced: boolean + /** Whether the user explicitly enabled the manual save preview themselves */ + hasOptedIntoPreview: boolean + isNoticeDismissed: boolean +} + +/** + * Whether to show the one-time notice explaining that snippets no longer + * autosave. + * + * Everyone the rollout switches over needs telling — including users who + * explicitly opted *out* of the preview, since the rollout overrides that + * choice. Only users who turned manual saving on themselves already know what + * the behavior is. + */ +export function shouldShowManualSaveNotice({ + isPlatform, + isFeaturePreviewInitialized, + isForced, + hasOptedIntoPreview, + isNoticeDismissed, +}: ShouldShowManualSaveNoticeParams): boolean { + return ( + isPlatform && + isFeaturePreviewInitialized && + isForced && + !hasOptedIntoPreview && + !isNoticeDismissed + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx index abff6c14b7a..2391bdc4400 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, useFlag } from 'common' +import { LOCAL_STORAGE_KEYS } from 'common' import { PowerOff } from 'lucide-react' import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' @@ -19,13 +19,7 @@ export const AutosaveStatus = ({ id }: AutosaveStatusProps) => { const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled() const { selectFeaturePreview } = useFeaturePreviewModal() - // Whether the manual-save preview can actually be opted into. Mirrors the - // feature preview modal's own filter (platform-only + ConfigCat flag), so we - // don't offer to "disable autosave" when there's no preview to switch to. - // `isManualSaveEnabled` also being false for self-hosted / flag-off users is - // why it can't gate this affordance. - const sqlEditorManualSaveFlag = useFlag('sqlEditorManualSave') - const canEnableManualSave = IS_PLATFORM && sqlEditorManualSaveFlag + const canEnableManualSave = IS_PLATFORM if (isManualSaveEnabled) { const snippet = snapV2.snippets[id] diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx index 027ae76bd2c..9e08a904d50 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx @@ -116,7 +116,13 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo - switchSource('database')}> + { + e.preventDefault() + switchSource('database') + }} + > Database @@ -124,7 +130,13 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo {!isLogs && } {(canCreateLogsSnippet || isLogs) && ( - switchSource('logs')}> + { + e.preventDefault() + switchSource('logs') + }} + > Logs diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx index 39517f8c7b6..f633d4a8a58 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx @@ -1,3 +1,4 @@ +import dayjs from 'dayjs' import { Check, Lock } from 'lucide-react' import { DropdownMenuItem, @@ -50,7 +51,9 @@ export const TimeRangeSubMenu = ({
    Time range - {isCustomRange ? 'Custom range' : (selectedPreset?.helper.text ?? 'Custom range')} + {isCustomRange + ? `${dayjs(range.from).format('DD MMM, HH:mm')} - ${dayjs(range.to).format('DD MMM, HH:mm')}` + : (selectedPreset?.helper.text ?? 'Custom range')}
    diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx index 49f6883ea8e..393f243f8e0 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx @@ -11,7 +11,11 @@ import { import { CellDetailPanel } from './CellDetailPanel' import { ResultCell } from './ResultCell' -import { formatClipboardValue } from './Results.utils' +import { + calculateResultColumnWidth, + formatClipboardValue, + RESULT_COLUMN_MIN_WIDTH, +} from './Results.utils' import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils' export const Results = ({ rows }: { rows: readonly any[] }) => { @@ -41,22 +45,9 @@ export const Results = ({ rows }: { rows: readonly any[] }) => { return
    {name}
    } - const EST_CHAR_WIDTH = 8.25 - const MIN_COLUMN_WIDTH = 100 - const MAX_COLUMN_WIDTH = 500 - const columns: CalculatedColumn[] = useMemo( () => Object.keys(rows?.[0] ?? []).map((key, idx) => { - const maxColumnValueLength = rows - .map((row) => String(row[key]).length) - .reduce((a, b) => Math.max(a, b), 0) - - const columnWidth = Math.max( - Math.min(maxColumnValueLength * EST_CHAR_WIDTH, MAX_COLUMN_WIDTH), - MIN_COLUMN_WIDTH - ) - return { idx, key, @@ -64,8 +55,8 @@ export const Results = ({ rows }: { rows: readonly any[] }) => { resizable: true, parent: undefined, level: 0, - width: columnWidth, - minWidth: MIN_COLUMN_WIDTH, + width: calculateResultColumnWidth(key, rows), + minWidth: RESULT_COLUMN_MIN_WIDTH, maxWidth: undefined, draggable: false, frozen: false, diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts index 6006cde9feb..88224d90764 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + calculateResultColumnWidth, convertResultsToCSV, convertResultsToJSON, convertResultsToMarkdown, @@ -12,6 +13,46 @@ import { } from './Results.utils' describe('Results.utils', () => { + describe('calculateResultColumnWidth', () => { + it('uses the minimum width when the column name and values are short', () => { + expect(calculateResultColumnWidth('id', [{ id: 1 }])).toBe(100) + }) + + it('accounts for a column name that is longer than its values', () => { + expect(calculateResultColumnWidth('source_campaign_id', [{ source_campaign_id: null }])).toBe( + 148.5 + ) + }) + + it('accounts for a value that is longer than the column name', () => { + expect(calculateResultColumnWidth('name', [{ name: 'a'.repeat(20) }])).toBe(165) + }) + + it('accounts for the formatted JSON representation of an object value', () => { + expect( + calculateResultColumnWidth('metadata', [{ metadata: { campaign: 'a'.repeat(20) } }]) + ).toBe(288.75) + }) + + it('accounts for the formatted JSON representation of an array value', () => { + expect(calculateResultColumnWidth('tags', [{ tags: ['a'.repeat(10), 'b'.repeat(10)] }])).toBe( + 222.75 + ) + }) + + it('caps the width when the column name exceeds the maximum', () => { + expect(calculateResultColumnWidth('a'.repeat(100), [])).toBe(500) + }) + + it('caps the width when a value exceeds the maximum', () => { + expect(calculateResultColumnWidth('value', [{ value: 'a'.repeat(100) }])).toBe(500) + }) + + it('uses the minimum width when there are no rows', () => { + expect(calculateResultColumnWidth('id', [])).toBe(100) + }) + }) + describe('formatClipboardValue', () => { it('returns empty string for null', () => { expect(formatClipboardValue(null)).toBe('') diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts index ca19a5763ec..fb03dc9faf2 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts @@ -3,6 +3,22 @@ import Papa from 'papaparse' type ResultRow = Record +const ESTIMATED_CHARACTER_WIDTH = 8.25 +export const RESULT_COLUMN_MIN_WIDTH = 100 +const MAX_COLUMN_WIDTH = 500 + +export function calculateResultColumnWidth(columnName: string, rows: readonly ResultRow[]) { + const maxContentLength = rows.reduce( + (maxLength, row) => Math.max(maxLength, (formatCellValue(row[columnName]) ?? '').length), + columnName.length + ) + + return Math.min( + Math.max(maxContentLength * ESTIMATED_CHARACTER_WIDTH, RESULT_COLUMN_MIN_WIDTH), + MAX_COLUMN_WIDTH + ) +} + export function formatClipboardValue(value: unknown) { if (value === null) return '' if (typeof value == 'object' || Array.isArray(value)) { diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index a5c69be3d40..1803004efee 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -27,7 +27,9 @@ import { SqlSaveButton } from './SaveButton' import SavingIndicator from './SavingIndicator' import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DatabaseSelector } from '@/components/ui/DatabaseSelector' +import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { IS_PLATFORM } from '@/lib/constants' import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' @@ -153,13 +155,23 @@ export const UtilityActions = ({
    )} - + Prettify SQL {formatKeys && } - +
    @@ -215,24 +227,28 @@ export const UtilityActions = ({ )} - - -
    diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts index 5b79d3b7768..6c68f56e2db 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts @@ -5,10 +5,12 @@ import { datePickerValueToLogDateRange, DEFAULT_LOG_DATE_RANGE, getSnippetSource, - isoDateTimeString, + isLogsSource, logDateRangesEqual, logDateRangeToDatePickerValue, resolveLogRunRange, + resolveSnippetSource, + sqlSourceToFenceLanguage, type LogDateRange, } from './querySource' import { @@ -18,6 +20,7 @@ import { import { generateHelpersFromInput } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import type { DatetimeHelper } from '@/components/interfaces/Settings/Logs/Logs.types' +import { isoDateTimeString } from '@/lib/iso-datetime' /** Build the `DatePickerValue` the Logs picker submits when a helper is selected. */ const valueFromHelper = (helper: DatetimeHelper): DatePickerValue => ({ @@ -50,19 +53,42 @@ describe('querySource.ts:getSnippetSource', () => { }) }) -describe('querySource.ts:isoDateTimeString', () => { - it('accepts a valid ISO datetime', () => { - const raw = '2025-01-01T12:00:00.000Z' - expect(isoDateTimeString(raw)).toBe(raw) +describe('querySource.ts:isLogsSource', () => { + it('is true only for the logs source', () => { + expect(isLogsSource('logs')).toBe(true) + expect(isLogsSource('database')).toBe(false) }) - it('rejects an empty string', () => { - expect(isoDateTimeString('')).toBeNull() + it('is false for an absent source', () => { + expect(isLogsSource(undefined)).toBe(false) + }) +}) + +describe('querySource.ts:sqlSourceToFenceLanguage', () => { + it('labels a logs query as clickhouse and everything else as sql', () => { + expect(sqlSourceToFenceLanguage('logs')).toBe('clickhouse') + expect(sqlSourceToFenceLanguage('database')).toBe('sql') }) - it('rejects junk', () => { - expect(isoDateTimeString('not-a-date')).toBeNull() - expect(isoDateTimeString('2025-13-45T99:99:99Z')).toBeNull() + // Attachments can carry no source; those are Postgres SQL. + it('treats an absent source as sql', () => { + expect(sqlSourceToFenceLanguage(undefined)).toBe('sql') + }) +}) + +describe('querySource.ts:resolveSnippetSource', () => { + it('prefers the snippet type over the URL param', () => { + expect(resolveSnippetSource({ type: 'log_sql' }, undefined)).toBe('logs') + // A stale/mismatched param must not override a snippet that already exists. + expect(resolveSnippetSource({ type: 'sql' }, 'logs')).toBe('database') + }) + + // A fresh `/sql/new` tab has no snippet until the first keystroke, so the param is + // the only signal that it is a logs tab. + it('falls back to the URL param before the snippet exists', () => { + expect(resolveSnippetSource(undefined, 'logs')).toBe('logs') + expect(resolveSnippetSource(undefined, undefined)).toBe('database') + expect(resolveSnippetSource(undefined, 'nonsense')).toBe('database') }) }) diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.ts b/apps/studio/components/interfaces/SQLEditor/querySource.ts index 1ba1c4fd1ea..4a1174f8375 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.ts @@ -1,10 +1,11 @@ import dayjs from 'dayjs' -import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' import type { Unit } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange' import type { Snippet } from '@/data/content/sql-folders-query' +import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime' /** * Domain view of where a snippet's query runs. Derived from the content TYPE: @@ -25,6 +26,19 @@ export function getSnippetSource(snippet: Pick): SqlSnippetSour return snippet.type === 'log_sql' ? 'logs' : 'database' } +export function isLogsSource(source: SqlSnippetSource | undefined): boolean { + return source === 'logs' +} + +/** + * The markdown fence language a source's SQL is written into a prompt with, so the model + * can tell a ClickHouse logs query from Postgres SQL. */ +export function sqlSourceToFenceLanguage( + source: SqlSnippetSource | undefined +): 'sql' | 'clickhouse' { + return isLogsSource(source) ? 'clickhouse' : 'sql' +} + /** * Parse a raw `source` value (e.g. the `?source=` query param a creation entry * threads through `/sql/new`) into a `SqlSnippetSource`. Only the explicit @@ -36,19 +50,15 @@ export function parseSqlSnippetSource(raw: string | undefined): SqlSnippetSource } /** - * An ISO-8601 datetime proven valid at construction via a dayjs parse. Absolute - * log ranges carry these instead of raw strings so an unvalidated datetime can - * never reach execution. + * Resolve where an open snippet's query runs, falling back to the `?source=` URL param + * when the snippet isn't in the store yet — a fresh `/sql/new` tab is materialized + * lazily on the first keystroke, and until then the param is the only signal. */ -export type IsoDateTimeString = string & { readonly __isoDateTimeBrand: unique symbol } - -/** - * Validate a raw string as an ISO datetime, returning the branded value or null. - * The sole construction site for `IsoDateTimeString` outside `now`. - */ -export function isoDateTimeString(raw: string): IsoDateTimeString | null { - if (!raw) return null - return dayjs(raw).isValid() ? (raw as IsoDateTimeString) : null +export function resolveSnippetSource( + snippet: Pick | undefined, + sourceParam: string | undefined +): SqlSnippetSource { + return snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam) } /** `now` as a branded ISO datetime — `toISOString()` is always valid ISO-8601. */ diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts index aa7cbf05b6f..95c83d782a5 100644 --- a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts @@ -3,10 +3,9 @@ import { useMemo } from 'react' import { DEFAULT_LOG_DATE_RANGE, - getSnippetSource, - parseSqlSnippetSource, + isLogsSource, + resolveSnippetSource, type QuerySource, - type SqlSnippetSource, } from './querySource' import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' @@ -29,12 +28,11 @@ export function useRunSource(id: string): QuerySource { const sessionSnap = useSqlEditorSessionSnapshot() const snippet = snapV2.snippets[id]?.snippet - const source: SqlSnippetSource = - snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam) + const source = resolveSnippetSource(snippet, sourceParam) const logRange = sessionSnap.logRange[id] return useMemo(() => { - if (source === 'logs') { + if (isLogsSource(source)) { return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_DATE_RANGE } } return { type: 'database' } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx index 244f4fc4431..b62fb4f45ec 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx @@ -1,13 +1,17 @@ import { act, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' +import type { SqlSnippetSource } from './querySource' import { DiffType } from './SQLEditor.types' import { useSqlEditorAi } from './useSqlEditorAi' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { API_URL } from '@/lib/constants' import { sidebarManagerState } from '@/state/sidebar-manager-state' import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request' import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { mswServer } from '@/tests/lib/msw' import { createInMemoryEditor, renderSqlEditorHook, @@ -18,15 +22,17 @@ import { const SNIPPET_ID = 'ai-snippet' +type HarnessProps = { editorMountCount?: number; sqlSource?: SqlSnippetSource } + /** * Composes the diff + prompt state hooks the AI hook depends on (production * wires these together in `SQLEditorControllers`), so tests drive the real * accept/discard/drain flows end to end. */ -function useAiHarness({ editorMountCount = 1 }: { editorMountCount?: number } = {}) { +function useAiHarness({ editorMountCount = 1, sqlSource = 'database' }: HarnessProps = {}) { const diff = useSqlEditorDiff() const prompt = useSqlEditorPrompt() - const ai = useSqlEditorAi({ id: SNIPPET_ID, editorMountCount, diff, prompt }) + const ai = useSqlEditorAi({ id: SNIPPET_ID, editorMountCount, diff, prompt, sqlSource }) return { ai, diff, prompt } } @@ -119,6 +125,110 @@ describe('useSqlEditorAi — accept / discard diff', () => { }) }) +describe('useSqlEditorAi — completion dialect', () => { + type CompletionRequestBody = { + dialect?: string + intent?: string + completionMetadata: { + prompt: string + selection: string + textBeforeCursor: string + textAfterCursor: string + availableKeys?: string[] + } + } + + /** Replaces the default completion mock so we can read what was posted. */ + function captureCompletionRequests(response: string) { + const bodies: CompletionRequestBody[] = [] + mswServer.use( + http.post(`${API_URL}/ai/code/complete`, async ({ request }) => { + bodies.push((await request.json()) as CompletionRequestBody) + return HttpResponse.json(response) + }) + ) + return bodies + } + + const context = { + beforeSelection: "select timestamp from logs where source = 'edge_logs'\n", + selection: 'limit 5', + afterSelection: '', + } + + it('posts the clickhouse dialect with the raw instruction and cursor context', async () => { + const bodies = captureCompletionRequests('limit 10') + const { result } = renderSqlEditorHook(useAiHarness, { + initialProps: { sqlSource: 'logs' }, + }) + + await act(async () => { + await result.current.ai.handlePrompt('only keep 5xx responses', context) + }) + + expect(bodies).toHaveLength(1) + expect(bodies[0].dialect).toBe('clickhouse') + // The route assembles the schema section and the selection-wrapped code around + // the instruction, so the client posts the instruction verbatim — the same + // shape as the Postgres path — and never hand-builds prompt text. + expect(bodies[0].completionMetadata.prompt).toBe('only keep 5xx responses') + expect(bodies[0].completionMetadata.selection).toBe('limit 5') + expect(bodies[0].completionMetadata.textBeforeCursor).toBe(context.beforeSelection) + // An inline edit is not a rewrite. + expect(bodies[0].intent).toBeUndefined() + }) + + it('posts the postgres dialect with the raw instruction for a database snippet', async () => { + const bodies = captureCompletionRequests('limit 10') + const { result } = renderSqlEditorHook(useAiHarness, { + initialProps: { sqlSource: 'database' }, + }) + + await act(async () => { + await result.current.ai.handlePrompt('bump the limit', context) + }) + + expect(bodies).toHaveLength(1) + expect(bodies[0].dialect).toBe('postgres') + expect(bodies[0].completionMetadata.prompt).toBe('bump the limit') + }) + + it('strips code fences and leaves clickhouse output unformatted', async () => { + captureCompletionRequests('```sql\nlimit 10\n```') + const { result } = renderSqlEditorHook(useAiHarness, { + initialProps: { sqlSource: 'logs' }, + }) + + await act(async () => { + await result.current.ai.handlePrompt('bump the limit', context) + }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(true)) + // sql-formatter is Postgres-only, so the ClickHouse diff is the reassembled + // query verbatim — fences stripped, nothing else touched. + expect(result.current.diff.sourceSqlDiff).toEqual({ + original: `${context.beforeSelection}limit 5`, + modified: `${context.beforeSelection}limit 10`, + }) + }) + + it('still formats database output through sql-formatter', async () => { + captureCompletionRequests('limit 10') + const { result } = renderSqlEditorHook(useAiHarness, { + initialProps: { sqlSource: 'database' }, + }) + + await act(async () => { + await result.current.ai.handlePrompt('bump the limit', context) + }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(true)) + expect(result.current.diff.sourceSqlDiff?.modified).not.toBe( + `${context.beforeSelection}limit 10` + ) + }) +}) + describe('useSqlEditorAi — debug', () => { it('onDebug opens the assistant sidebar and starts a debug chat from the failing snippet', async () => { seedSnippet({ id: SNIPPET_ID, name: 'Broken query', sql: 'selct 1;' }) @@ -135,7 +245,11 @@ describe('useSqlEditorAi — debug', () => { const activeChat = aiAssistantState.chats[aiAssistantState.activeChatId ?? ''] expect(activeChat?.name).toBe('Debug SQL snippet') - expect(aiAssistantState.sqlSnippets).toEqual(['selct 1;']) + // Attached with its source, which is what carries sqlSource onto the message the + // user submits from the prefilled composer. + expect(aiAssistantState.sqlSnippets).toEqual([ + { label: 'Current Query', content: 'selct 1;', source: 'database' }, + ]) expect(aiAssistantState.initialInput).toContain('syntax error at or near "selct"') }) diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts index 97c5e589d6b..36359ffe49a 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react import { toast } from 'sonner' import type { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' +import type { SqlSnippetSource } from './querySource' import { DiffType, type IStandaloneDiffEditor } from './SQLEditor.types' import { assembleCompletionDiff, @@ -13,12 +14,15 @@ import { createSqlSnippetSkeletonV2, extractDebugContext, planDiffRequestApplication, + sqlSourceToDialect, } from './SQLEditor.utils' import { useSQLEditorContext } from './SQLEditorContext' import { useSnippetTitleGenerator } from './useSnippetTitleGenerator' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { constructHeaders } from '@/data/fetchers' +import { stripSqlCodeFences } from '@/data/logs/logs-sql-rewrite' import { isError } from '@/data/utils/error-check' +import { useLogsAttributeKeys } from '@/hooks/analytics/useLogsAttributeKeys' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { BASE_PATH } from '@/lib/constants' @@ -37,6 +41,11 @@ type UseSqlEditorAiArgs = { editorMountCount: number diff: ReturnType prompt: ReturnType + /** + * Where the snippet runs. Selects the dialect the AI writes in — logs snippets + * get ClickHouse SQL for the `logs` table, database snippets get Postgres. + */ + sqlSource: SqlSnippetSource } /** @@ -45,7 +54,13 @@ type UseSqlEditorAiArgs = { * lifecycle effects (one-shot diff-request drain, diff-editor value sync, and the * ask-AI widget visibility). */ -export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEditorAiArgs) { +export function useSqlEditorAi({ + id, + editorMountCount, + diff, + prompt, + sqlSource, +}: UseSqlEditorAiArgs) { const { sourceSqlDiff, setSourceSqlDiff, @@ -77,6 +92,14 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false) const [showWidget, setShowWidget] = useState(false) + const dialect = sqlSourceToDialect(sqlSource) + const isClickhouse = dialect === 'clickhouse' + + // Grounds ClickHouse edits in the source's real log_attributes keys, the same way + // the whole-query rewrite does — otherwise inline edits invent dotted paths. + // Looked up when the user submits, not while they type. + const { fetchAttributeKeys } = useLogsAttributeKeys() + const handleNewQuery = useCallback( async (sql: string, name: string) => { if (!ref) return console.error('Project ref is required') @@ -105,15 +128,15 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const result = sessionSnap.results[id]?.[0] const { sql, errorMessage } = extractDebugContext(snippet, result) - return buildDebugPromptText(sql, errorMessage) - }, [id, sessionSnap.results, snapV2.snippets]) + return buildDebugPromptText(sql, errorMessage, sqlSource) + }, [id, sessionSnap.results, snapV2.snippets, sqlSource]) const onDebug = useCallback(async () => { try { const snippet = snapV2.snippets[id] const result = sessionSnap.results[id]?.[0] openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) - aiSnap.newChat(buildDebugChatArgs(snippet, result)) + aiSnap.newChat(buildDebugChatArgs(snippet, result, sqlSource)) } catch (error: unknown) { // [Joshen] There's a tendency for the SQL debug to chuck a lengthy error message // that's not relevant for the user - so we prettify it here by avoiding to return the @@ -124,7 +147,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi ) } } - }, [id, sessionSnap.results, snapV2.snippets, aiSnap, openSidebar]) + }, [id, sessionSnap.results, snapV2.snippets, aiSnap, openSidebar, sqlSource]) const acceptAiHandler = useCallback(async () => { try { @@ -196,6 +219,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi projectRef: project?.ref, connectionString: project?.connectionString, orgSlug: org?.slug, + dialect, options: options?.body, }) ), @@ -210,9 +234,16 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi const text: string = await response.json() const meta = options?.body?.completionMetadata ?? {} - const { original, modified } = assembleCompletionDiff(meta, text) + // The clickhouse system prompt forbids fences, but strip them defensively + // so a chatty model can't leak backticks into the snippet. + const { original, modified } = assembleCompletionDiff( + meta, + isClickhouse ? stripSqlCodeFences(text) : text + ) - const formattedModified = formatSql(modified) + // sql-formatter is Postgres-only — it mangles ClickHouse backticks and + // map lookups — so ClickHouse output goes into the diff unformatted. + const formattedModified = isClickhouse ? modified : formatSql(modified) setSourceSqlDiff({ original, modified: formattedModified }) setSelectedDiffType(DiffType.Modification) setPromptState((prev) => ({ ...prev, isLoading: false })) @@ -224,6 +255,8 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi } }, [ + dialect, + isClickhouse, org?.slug, project?.connectionString, project?.ref, @@ -249,10 +282,23 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi beforeSelection: context.beforeSelection, afterSelection: context.afterSelection, })) - const headerData = await constructHeaders() + // ClickHouse only: there's no server-side schema to fetch for the logs + // table, so the real log_attributes keys travel with the request. Detected + // from the whole document, which is what the three context fields spell. + const [headerData, availableKeys] = await Promise.all([ + constructHeaders(), + isClickhouse + ? fetchAttributeKeys( + context.beforeSelection + context.selection + context.afterSelection + ) + : undefined, + ]) const authorizationHeader = headerData.get('Authorization') + // The instruction goes over as-is for both dialects — the route assembles + // the schema section and the cursor context around it, so there's exactly + // one place that knows how a completion prompt is built. await complete(prompt, { ...(authorizationHeader ? { headers: { Authorization: authorizationHeader } } @@ -264,6 +310,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi language: 'pgsql', prompt, selection: context.selection, + ...(availableKeys ? { availableKeys } : {}), }, }, }) @@ -271,7 +318,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi setPromptState((prev) => ({ ...prev, isLoading: false })) } }, - [complete, setPromptState] + [complete, fetchAttributeKeys, isClickhouse, setPromptState] ) const handleDiffEditorMount = useCallback( diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx index bfd7d6a522e..f6dd58a2907 100644 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx @@ -42,6 +42,7 @@ import { import { JitDbAccessDeleteDialog } from './JitDbAccessDeleteDialog' import { JitDbAccessRuleSheet } from './JitDbAccessRuleSheet' import { JitDbAccessRulesTable } from './JitDbAccessRulesTable' +import { SSLEnforcementConfirmDialog } from '@/components/interfaces/Settings/Database/SSLEnforcementConfirmDialog' import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils' import { SupportLink } from '@/components/interfaces/Support/SupportLink' import { AlertError } from '@/components/ui/AlertError' @@ -55,6 +56,7 @@ import { useJitDbAccessRevokeMutation } from '@/data/jit-db-access/jit-db-access import { useJitDbAccessUpdateMutation } from '@/data/jit-db-access/jit-db-access-update-mutation' import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query' import { useProjectMembersQuery } from '@/data/projects/project-members-query' +import { useSSLEnforcementUpdateMutation } from '@/data/ssl-enforcement/ssl-enforcement-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -126,6 +128,21 @@ export const JitDbAccessConfiguration = () => { onError: () => {}, }) + const { mutateAsync: enableSSLEnforcement, isPending: isEnablingSSLEnforcement } = + useSSLEnforcementUpdateMutation({ + onSuccess: () => { + toast.success('Successfully enabled SSL enforcement') + }, + onError: (error) => { + toast.error(`Failed to enable SSL enforcement: ${error.message}`) + }, + }) + + const handleEnableSSLEnforcement = async () => { + if (!ref) return console.error('Project ref is required') + await enableSSLEnforcement({ projectRef: ref, requestedConfig: { database: true } }) + } + const { mutateAsync: revokeUserAccess, isPending: isRevokingAccess } = useJitDbAccessRevokeMutation({ onSuccess: (_, variables) => { @@ -383,11 +400,15 @@ export const JitDbAccessConfiguration = () => { Upgrade Postgres ) : unavailableReason === 'ssl_enforcement_required' && ref ? ( - + + ) : ( + +
    + } + /> + ) + } + + // The dialect check is a heuristic, so an unchanged rewrite is the Assistant + // disagreeing with it. Say so and let the user close it, rather than proposing + // an empty diff or silently giving up. + if (state.status === 'noRewriteNeeded') { + return ( + + Dismiss + + } + /> + ) + } + + const isRewriting = state.status === 'rewriting' + + return ( + + + {/* Disabled mid-rewrite so the offer can't be dismissed out from under + an in-flight request — the two states stay mutually exclusive. */} + + Dismiss + + + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx deleted file mode 100644 index 4f9bac9cd34..00000000000 --- a/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button } from 'ui' -import { Admonition } from 'ui-patterns/Admonition' - -interface LogsExplorerOtelBannerProps { - isRewriting: boolean - onRewrite: () => void - onDismiss: () => void -} - -export const LogsExplorerOtelBanner = ({ - isRewriting, - onRewrite, - onDismiss, -}: LogsExplorerOtelBannerProps) => { - return ( - - - - - } - /> - ) -} diff --git a/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx b/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx index fededa3cd64..7d717067421 100644 --- a/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { Button, + Card, Dialog, DialogContent, DialogFooter, @@ -8,6 +9,12 @@ import { DialogSection, DialogSectionSeparator, DialogTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from 'ui' import { TIER_QUERY_LIMITS } from './Logs.constants' @@ -47,30 +54,34 @@ const UpgradePrompt: React.FC = ({

    {description}

    -
    -
    -

    Plan

    -

    Retention duration

    -
    -
    -
    -

    Free

    -

    {TIER_QUERY_LIMITS.FREE.text}

    -
    -
    -

    Pro

    -

    {TIER_QUERY_LIMITS.PRO.text}

    -
    -
    -

    Team

    -

    {TIER_QUERY_LIMITS.TEAM.text}

    -
    -
    -

    Enterprise

    -

    {TIER_QUERY_LIMITS.ENTERPRISE.text}

    -
    -
    -
    + + + + + Plan + Retention duration + + + + + Free + {TIER_QUERY_LIMITS.FREE.text} + + + Pro + {TIER_QUERY_LIMITS.PRO.text} + + + Team + {TIER_QUERY_LIMITS.TEAM.text} + + + Enterprise + {TIER_QUERY_LIMITS.ENTERPRISE.text} + + +
    +
    diff --git a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx index 3e9d040ae21..92bc0850ef8 100644 --- a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx @@ -26,6 +26,11 @@ const schema = z.object({ const formId = 'sign-in-mfa-form' +function getFactorDisplayName(factor: Pick | null | undefined): string { + const name = factor?.friendly_name?.trim() + return name && name.length > 0 ? name : 'your authenticator app' +} + interface SignInMfaFormProps { context?: 'forgot-password' | 'sign-in' } @@ -142,9 +147,14 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { + Code generated by{' '} + + {getFactorDisplayName(selectedFactor)} + + + ) : null } > @@ -208,9 +218,13 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { onClick={() => setSelectedFactor(factors.totp.find((f) => f.id !== selectedFactor?.id)!) } - >{`Authenticate using ${ - factors.totp.find((f) => f.id !== selectedFactor?.id)?.friendly_name - }?`}
    + > + Authenticate using{' '} + + {getFactorDisplayName(factors.totp.find((f) => f.id !== selectedFactor?.id))} + + ? + )}
  • diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 4fa7f81fc3d..751364b10fc 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -1228,9 +1228,12 @@ describe('SupportFormPage', () => { expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 2') }) - await waitFor(() => { - expect(getProjectSelector(screen)).toHaveTextContent('Project 2') - }) + await waitFor( + () => { + expect(getProjectSelector(screen)).toHaveTextContent('Project 2') + }, + { timeout: 5_000 } + ) }) test('AI Assistant suggestion displays when valid project and organization are selected', async () => { @@ -1758,10 +1761,13 @@ describe('SupportFormPage', () => { const renderResult = renderSupportFormPage() unmount = renderResult.unmount - await waitFor(() => { - expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') - expect(getProjectSelector(screen)).toHaveTextContent('Project 1') - }) + await waitFor( + () => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }, + { timeout: 5_000 } + ) await selectCategoryOption(screen, 'Database unresponsive') await waitFor(() => { diff --git a/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx b/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx index 8590e9c817a..b920d79eaf6 100644 --- a/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx +++ b/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx @@ -1,39 +1,14 @@ -import { useFlag } from 'common' -import Link from 'next/link' -import { useRouter } from 'next/router' import { PropsWithChildren } from 'react' -import { NavMenu, NavMenuItem } from 'ui' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, PageHeaderDescription, PageHeaderMeta, - PageHeaderNavigationTabs, PageHeaderSummary, PageHeaderTitle, } from 'ui-patterns/PageHeader' export const AccessTokensLayout = ({ children }: PropsWithChildren) => { - const router = useRouter() - const scopedTokensEnabled = useFlag('scopedPAT') - - const navigationItems = [ - { - label: 'Classic Tokens', - href: `/account/tokens`, - id: 'classic-tokens', - }, - ...(scopedTokensEnabled - ? [ - { - label: 'Scoped Tokens', - href: `/account/tokens/scoped`, - id: 'scoped-tokens', - }, - ] - : []), - ] - const title = 'Access Tokens' const description = 'Create and manage access tokens for API authentication.' @@ -46,20 +21,6 @@ export const AccessTokensLayout = ({ children }: PropsWithChildren) => { {description} - {navigationItems.length > 0 && ( - - - {navigationItems.map((item) => { - const isActive = router.asPath.split('?')[0] === item.href - return ( - - {item.label} - - ) - })} - - - )} {children} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx new file mode 100644 index 00000000000..fc195984fc1 --- /dev/null +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx @@ -0,0 +1,103 @@ +import { motion } from 'framer-motion' +import { ChevronLeft, MessageSquare, NotebookText, Plus } from 'lucide-react' +import { type ComponentType, type PropsWithChildren } from 'react' +import { Button, cn } from 'ui' +import { InnerSideBarFilters, InnerSideBarFilterSearchInput } from 'ui-patterns/InnerSideMenu' + +import { useCreateNotebook } from '@/components/interfaces/Explorer/hooks' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' + +export type ExplorerResourceType = 'notebook' | 'chat' + +export const LEVEL_OFFSET = 8 +export const LEVEL_TRANSITION = { duration: 0.09, ease: 'easeOut' } as const + +export const EXPLORER_SECTIONS: Array<{ + type: ExplorerResourceType + label: string + icon: ComponentType<{ size?: number; className?: string }> + searchPlaceholder: string +}> = [ + { + type: 'notebook', + label: 'Notebooks', + icon: NotebookText, + searchPlaceholder: 'Search notebooks', + }, + { type: 'chat', label: 'Chats', icon: MessageSquare, searchPlaceholder: 'Search chats' }, +] + +export const rowClassName = (isActive: boolean) => + cn( + 'group relative flex h-7 w-full items-center gap-2 rounded-md pl-3 pr-2 text-sm', + isActive + ? 'bg-selection text-foreground' + : 'text-foreground-light hover:bg-surface-200 hover:text-foreground' + ) + +export const ExplorerNavResourceWrapper = ({ + type, + label, + className, + children, + search, + setSearch, + onBack, +}: PropsWithChildren<{ + type: ExplorerResourceType + label?: string + className?: string + search?: string + setSearch: (value: string) => void + onBack: () => void +}>) => { + const { createNotebook } = useCreateNotebook() + const searchPlaceholder = EXPLORER_SECTIONS.find((x) => x.type === type)?.searchPlaceholder + + return ( + +
    +
    + {children} +
    + ) +} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx new file mode 100644 index 00000000000..0c1edef2d3c --- /dev/null +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -0,0 +1,120 @@ +import { useParams } from 'common' +import { AnimatePresence, motion } from 'framer-motion' +import { Home, MessageCirclePlus, NotebookText, Plus } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { ComponentProps, ReactNode, useState } from 'react' +import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from 'ui' + +import { ProjectLayoutWithAuth } from '../ProjectLayout' +import { EditorTabs } from '../Tabs/Tabs' +import { type ExplorerResourceType } from './ExplorerLayout.constants' +import { ExplorerNavChats } from './ExplorerNavChats' +import { ExplorerNavHome } from './ExplorerNavHome' +import { ExplorerNavNotebooks } from './ExplorerNavNotebooks' +import { useCreateNotebook } from '@/components/interfaces/Explorer/hooks' + +export interface ExplorerLayoutProps extends ComponentProps { + children: ReactNode + title?: string +} + +export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayoutProps) => { + const [section, setSection] = useState() + + // [Joshen] Temporary, to hook up with tabs store + const activeTabLabel = 'Active Tab Label' + + const mergedBrowserTitle = { + ...browserTitle, + section: title ?? browserTitle?.section, + entity: browserTitle?.entity ?? activeTabLabel, + } + + return ( + + + {section === undefined && } + {section === 'notebook' && ( + setSection(undefined)} /> + )} + {section === 'chat' && ( + setSection(undefined)} /> + )} + + + } + > +
    +
    + } + newTabButton={} + /> +
    +
    {children}
    +
    +
    + ) +} + +const TabClassName = + 'flex items-center justify-center min-w-(--header-height) min-h-(--header-height) hover:bg-surface-100 shrink-0 border-b' + +const HomeTabButton = () => { + const router = useRouter() + const { ref } = useParams() + const isActive = router.pathname.endsWith('/explorer') + + return ( + + + Open Explorer home + + ) +} + +const NewTabButton = () => { + const { createNotebook } = useCreateNotebook() + + return ( + + + {}} + initial={{ opacity: 0, scale: 0.8, x: -10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + transition={{ duration: 0.2 }} + > + + + + + createNotebook()}> + + New notebook + + + + New chat + + + + ) +} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx new file mode 100644 index 00000000000..7bef8979324 --- /dev/null +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx @@ -0,0 +1,17 @@ +import { useState } from 'react' + +import { ExplorerNavResourceWrapper } from './ExplorerLayout.constants' + +export const ExplorerNavChats = ({ onBack }: { onBack: () => void }) => { + const [search, setSearch] = useState('') + + // [Joshen] Eventually will have data fetching for notebooks via useAiAssistantState + + return ( + +
    +

    No chats created yet

    +
    +
    + ) +} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx new file mode 100644 index 00000000000..da7ce2a25a3 --- /dev/null +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx @@ -0,0 +1,55 @@ +import { motion } from 'framer-motion' +import { ChevronRight } from 'lucide-react' + +import { + EXPLORER_SECTIONS, + ExplorerResourceType, + LEVEL_OFFSET, + LEVEL_TRANSITION, + rowClassName, +} from './ExplorerLayout.constants' + +export const ExplorerNavHome = ({ + onSelectSection, +}: { + onSelectSection: (section: ExplorerResourceType) => void +}) => { + return ( + + + +
    +

    + Recent +

    +

    Nothing edited yet

    +
    +
    + ) +} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx new file mode 100644 index 00000000000..d465190d848 --- /dev/null +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx @@ -0,0 +1,22 @@ +import { useState } from 'react' + +import { ExplorerNavResourceWrapper } from './ExplorerLayout.constants' + +export const ExplorerNavNotebooks = ({ onBack }: { onBack: () => void }) => { + const [search, setSearch] = useState('') + + // [Joshen] Eventually will have data fetching for notebooks via RQ + + return ( + +
    +

    No notebooks created yet

    +
    +
    + ) +} diff --git a/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx b/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx index 668052b3978..f36c6d8c4d9 100644 --- a/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx +++ b/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx @@ -1,12 +1,23 @@ +import { useFlag, useParams } from 'common' +import Link from 'next/link' import { PropsWithChildren } from 'react' -import { cn } from 'ui' +import { Button, cn } from 'ui' interface ProductMenuBarProps { title: string className?: string } -const ProductMenuBar = ({ title, children, className }: PropsWithChildren) => { +export const ProductMenuBar = ({ + title, + children, + className, +}: PropsWithChildren) => { + // [Joshen] Temporary entry point into explorer + const { ref } = useParams() + const isExplorerEnabled = useFlag('explorer') + const showExplorerCTA = isExplorerEnabled && title === 'SQL Editor' + return (
    -
    +

    {title}

    + {showExplorerCTA && ( + + )}
    {children}
    ) } - -export default ProductMenuBar diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx index 626b8f0c725..c9b9650ece0 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx @@ -103,7 +103,7 @@ export const ObservabilityMenu = () => {
    ) : ( -
    +
    ({ @@ -115,7 +115,7 @@ export const ObservabilityMenu = () => { {IS_PLATFORM && ( <>
    -
    +
    ({ ProjectPausedState: () => n vi.mock('./PauseFailedState', () => ({ PauseFailedState: () => null })) vi.mock('./PausingState', () => ({ PausingState: () => null })) vi.mock('./ProductMenuBar', () => ({ - default: ({ children }: { children: ReactNode }) => <>{children}, + ProductMenuBar: ({ children }: { children: ReactNode }) => <>{children}, })) vi.mock('./ResizingState', () => ({ ResizingState: () => null })) vi.mock('./RestartingState', () => ({ default: () => null })) diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 32680848eca..015764d49a0 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -27,7 +27,7 @@ import { import { useEditorType } from '../editors/EditorsLayout.hooks' import { useMainScrollContainer, useSetMainScrollContainer } from '../MainScrollContainerContext' import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext' -import ProductMenuBar from '../Navigation/ProductMenuBar' +import { ProductMenuBar } from '../Navigation/ProductMenuBar' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent' diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx index 6ce9d4ddad2..f97145a1a25 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react' import { OngoingQueriesPanel } from '@/components/interfaces/SQLEditor/OngoingQueriesPanel' +import { SqlEditorManualSaveNoticeDialog } from '@/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog' import { withAuth } from '@/hooks/misc/withAuth' export interface SQLEditorLayoutProps { @@ -12,6 +13,7 @@ const SQLEditorLayout = ({ children }: SQLEditorLayoutProps) => { <> {children} + ) } diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx new file mode 100644 index 00000000000..1caf2bff1f9 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/LogsSnippetsSection.tsx @@ -0,0 +1,136 @@ +import { keepPreviousData } from '@tanstack/react-query' +import { useParams } from 'common' +import { useEffect, useMemo } from 'react' +import { + InnerSideBarEmptyPanel, + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleContent, + InnerSideMenuCollapsibleTrigger, +} from 'ui-patterns/InnerSideMenu' + +import { SQLEditorLoadingSnippets } from './SQLEditorLoadingSnippets' +import { + formatFolderResponseForTreeView, + getLastItemIds, + ROOT_NODE, + withActiveSnippet, +} from './SQLEditorNav.utils' +import { SqlSnippetTree } from './SqlSnippetTree' +import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' +import { useContentCountQuery } from '@/data/content/content-count-query' +import { Snippet } from '@/data/content/sql-folders-query' +import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query' +import { useLatest } from '@/hooks/misc/useLatest' + +interface LogsSnippetsSectionProps { + open: boolean + onOpenChange: (open: boolean) => void + sort: 'inserted_at' | 'name' + /** The currently open snippet, surfaced in the list before the query fetches it. */ + activeSnippet?: Snippet + selectedSnippetIds: string[] + /** Bubbles loaded logs snippets up for tab cleanup, mirroring onFolderContentsChange. */ + onSnippetsLoaded: (info: { + snippets: Snippet[] + /** + * Whether `snippets` is the complete set of logs snippets. Only true once every + * page has been fetched — cleanup prunes tabs whose snippet is absent from the + * list, and a snippet on an unfetched page would otherwise look deleted. + */ + isComplete: boolean + isSettled: boolean + }) => void + onSelectDelete: (snippet: Snippet) => void + onSelectRename: (snippet: Snippet) => void +} + +/** + * A separate single-type `log_sql` query — logs snippets are a distinct backend and + * don't participate in folders, so they get their own flat section rather than merging + * with the cursor-paginated `sql` sections. + */ +export const LogsSnippetsSection = ({ + open, + onOpenChange, + sort, + activeSnippet, + selectedSnippetIds, + onSnippetsLoaded, + onSelectDelete, + onSelectRename, +}: LogsSnippetsSectionProps) => { + const { ref: projectRef } = useParams() + + const { data, isLoading, isSuccess, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = + useSqlSnippetsQuery( + { projectRef, type: 'log_sql', sort }, + { placeholderData: keepPreviousData } + ) + + const { data: countData } = useContentCountQuery({ projectRef, type: 'log_sql' }) + const numSnippets = (countData?.private ?? 0) + (countData?.shared ?? 0) + + const snippets = useMemo(() => { + const pageSnippets = data?.pages.flatMap((page) => page.contents ?? []) ?? [] + + return withActiveSnippet( + pageSnippets, + activeSnippet, + (s) => getSnippetSource(s) === 'logs' + ) + .map((x) => ({ ...x, folder_id: null })) + .sort((a, b) => { + if (sort === 'name') return a.name.localeCompare(b.name) + return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() + }) + }, [data?.pages, activeSnippet, sort]) + + const treeState = useMemo( + () => + snippets.length === 0 + ? [ROOT_NODE] + : formatFolderResponseForTreeView({ contents: snippets, folders: [] }), + [snippets] + ) + const lastItemIds = useMemo(() => getLastItemIds(treeState), [treeState]) + + const onSnippetsLoadedRef = useLatest(onSnippetsLoaded) + useEffect(() => { + onSnippetsLoadedRef.current({ + snippets, + isComplete: isSuccess && !hasNextPage, + isSettled: isSuccess || isError, + }) + }, [snippets, isSuccess, isError, hasNextPage]) + + return ( + + 0 ? ` (${numSnippets})` : ''}`} + /> + + {isLoading && } + {!isLoading && snippets.length === 0 && ( + + )} + {!isLoading && snippets.length > 0 && ( + + )} + + + ) +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts index 72f80c44725..623bc4119c4 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.constants.ts @@ -2,6 +2,7 @@ export type SectionState = { shared: boolean favorite: boolean private: boolean + logs: boolean community: boolean } @@ -9,5 +10,6 @@ export const DEFAULT_SECTION_STATE: SectionState = { shared: false, favorite: false, private: true, + logs: false, community: true, } diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 9a536480ccb..57ef4715aea 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -1,5 +1,5 @@ import { keepPreviousData } from '@tanstack/react-query' -import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import { Heart } from 'lucide-react' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' @@ -15,15 +15,22 @@ import { } from 'ui-patterns/InnerSideMenu' import { DeleteSnippetsModal } from './DeleteSnippetsModal' +import { LogsSnippetsSection } from './LogsSnippetsSection' import { ReferenceSnippetsSection } from './ReferenceSnippetsSection' import { ShareSnippetModal } from './ShareSnippetModal' import { SQLEditorLoadingSnippets } from './SQLEditorLoadingSnippets' import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants' -import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils' +import { + formatFolderResponseForTreeView, + getLastItemIds, + ROOT_NODE, + withActiveSnippet, +} from './SQLEditorNav.utils' import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' import { UnshareSnippetModal } from './UnshareSnippetModal' import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal' import { MoveQueryModal } from '@/components/interfaces/SQLEditor/MoveQueryModal' +import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal' import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils' @@ -65,8 +72,16 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { shared: showSharedSnippets, favorite: showFavoriteSnippets, private: showPrivateSnippets, + logs: showLogsSnippets, } = sectionVisibility + // Both flags gate the entry point: `sqlEditorLogsSource` enables the feature and + // `otelLegacyLogs` confirms the org's logs live in the ClickHouse backend a logs + // snippet queries. + const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + const canShowLogsSection = isLogsSourceEnabled && isOtelLogsEnabled + const [showMoveModal, setShowMoveModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false) @@ -131,10 +146,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { } ) - if (snippet && snippet.visibility === 'user' && !snippetInfo.snippetIds.has(snippet.id)) { - snippetInfo.snippetIds.add(snippet.id) - snippetInfo.snippets = [...snippetInfo.snippets, snippet] - } + snippetInfo.snippets = withActiveSnippet( + snippetInfo.snippets, + snippet, + (s) => s.visibility === 'user' && getSnippetSource(s) !== 'logs' + ) return snippetInfo }, [privateSnippetsPages?.pages, subResults, isLoading, isPlaceholderData, isFetching, snippet]) @@ -204,11 +220,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { ) const favoriteSnippets = useMemo(() => { - let snippets = favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [] - - if (snippet && snippet.favorite && !snippets.find((x) => x.id === snippet.id)) { - snippets.push(snippet) - } + const snippets = withActiveSnippet( + favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [], + snippet, + (s) => !!s.favorite && getSnippetSource(s) !== 'logs' + ) return ( snippets @@ -255,11 +271,11 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { ) const sharedSnippets = useMemo(() => { - let snippets = sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [] - - if (snippet && snippet.visibility === 'project' && !snippets.find((x) => x.id === snippet.id)) { - snippets.push(snippet) - } + const snippets = withActiveSnippet( + sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? [], + snippet, + (s) => s.visibility === 'project' && getSnippetSource(s) !== 'logs' + ) return ( snippets.sort((a, b) => { @@ -284,12 +300,21 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { [projectSnippetsTreeState] ) + // The Logs section owns its own query and bubbles loaded snippets here so tab + // cleanup treats them as live (and prunes stale ones). + const [logsSnippetsInView, setLogsSnippetsInView] = useState<{ + snippets: Snippet[] + isComplete: boolean + isSettled: boolean + }>({ snippets: [], isComplete: false, isSettled: false }) + const allSnippetsInView = useMemo( () => [ - ...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []), - ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []), + ...(privateSnippetsPages?.pages.flatMap((x) => x.contents ?? []) ?? []), + ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents ?? []) ?? []), + ...logsSnippetsInView.snippets, ], - [privateSnippetsPages, sharedSqlSnippetsData] + [privateSnippetsPages, sharedSqlSnippetsData, logsSnippetsInView.snippets] ) // ========================== @@ -404,7 +429,12 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { useEffect(() => { if (snippet !== undefined && isSuccess) { - if (snippet.visibility === 'project') { + // Source is checked before visibility: a logs snippet lives in the Logs section + // whatever its visibility, so branching on visibility first would open Private + // (or Shared) and leave the section the snippet is actually in collapsed. + if (getSnippetSource(snippet) === 'logs') { + setSectionVisibility({ ...sectionVisibility, logs: true }) + } else if (snippet.visibility === 'project') { setSectionVisibility({ ...sectionVisibility, shared: true }) } else if (snippet.visibility === 'user') { setSectionVisibility({ ...sectionVisibility, private: true }) @@ -457,10 +487,24 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const sqlEditorTabsCleanup = useSqlEditorTabsCleanup() useEffect(() => { - if (isSuccess) { - sqlEditorTabsCleanup({ snippets: allSnippetsInView as any }) + // Wait for the logs query to settle (when enabled) so a logs failure doesn't + // freeze database-tab cleanup. Logs tabs are only prunable once every logs page + // has been fetched — until then the list is partial and a tab whose snippet sits + // on an unfetched page would be pruned as stale, so they're preserved instead. + if (isSuccess && (!canShowLogsSection || logsSnippetsInView.isSettled)) { + sqlEditorTabsCleanup({ + snippets: allSnippetsInView, + canPruneLogsTabs: canShowLogsSection && logsSnippetsInView.isComplete, + }) } - }, [allSnippetsInView, isSuccess, sqlEditorTabsCleanup]) + }, [ + allSnippetsInView, + isSuccess, + canShowLogsSection, + logsSnippetsInView.isComplete, + logsSnippetsInView.isSettled, + sqlEditorTabsCleanup, + ]) return ( <> @@ -757,6 +801,31 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { + {canShowLogsSection && ( + <> + + setSectionVisibility({ ...(sectionVisibility ?? DEFAULT_SECTION_STATE), logs: value }) + } + sort={sort} + activeSnippet={snippet} + selectedSnippetIds={selectedSnippets.map((x) => x.id)} + onSnippetsLoaded={setLogsSnippetsInView} + onSelectDelete={(snippet) => { + setShowDeleteModal(true) + setSelectedSnippets([snippet]) + }} + onSelectRename={(snippet) => { + setShowRenameModal(true) + setSelectedSnippetToRename(snippet) + }} + /> + + + + )} + diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts index 65f8dbd2861..f0f12761928 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.utils.ts @@ -53,6 +53,27 @@ export const formatFolderResponseForTreeView = ( return [root, ...formattedFolders, ...formattedContents] } +/** + * Append the active snippet to a section's list when it belongs there (per the + * section's predicate) and isn't already present. Each nav section lists + * server-filtered pages, so a just-opened or just-created snippet may not appear + * until a refetch — this surfaces it immediately, in the one section it belongs to. + */ +export function withActiveSnippet( + snippets: T[], + activeSnippet: T | undefined, + belongsInSection: (snippet: T) => boolean +): T[] { + if ( + activeSnippet !== undefined && + belongsInSection(activeSnippet) && + !snippets.some((snippet) => snippet.id === activeSnippet.id) + ) { + return [...snippets, activeSnippet] + } + return snippets +} + export function getLastItemIds(items: TreeViewItemProps[]) { let lastItemIds = new Set() diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 21ae2a5e129..615eecc6aad 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -30,6 +30,7 @@ import { import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils' +import { LogsSnippetIcon } from '@/components/ui/EntityTypeIcon' import { getContentById, getSqlSnippetById } from '@/data/content/content-id-query' import { useSQLSnippetFolderContentsQuery } from '@/data/content/sql-folder-contents-query' import { Snippet } from '@/data/content/sql-folders-query' @@ -111,6 +112,8 @@ export const SQLEditorTreeViewItem = ({ const isSharedSnippet = element.metadata.visibility === 'project' const isFavorite = element.metadata.favorite + const isLogsSnippet = getSnippetSource(element.metadata) === 'logs' + const isEditing = isFolderEditing(status) const isSaving = isFolderSaving(status) @@ -247,6 +250,14 @@ export const SQLEditorTreeViewItem = ({ isPreview={props.isPreview} isEditing={isEditing} isLoading={(isEnabled && isLoading) || isSaving} + icon={ + isLogsSnippet ? ( + + ) : undefined + } onEditSubmit={(value) => { if (onEditSave !== undefined) onEditSave(value) }} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx index 9d6a349b730..a9a9c89f336 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx @@ -1,29 +1,45 @@ import { keepPreviousData } from '@tanstack/react-query' -import { useParams } from 'common' +import { useFlag, useParams } from 'common' import { Loader2 } from 'lucide-react' import { useMemo, useState } from 'react' -import { TreeView } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { DeleteSnippetsModal } from './DeleteSnippetsModal' import { ShareSnippetModal } from './ShareSnippetModal' import { formatFolderResponseForTreeView, getLastItemIds } from './SQLEditorNav.utils' -import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' +import { SqlSnippetTree } from './SqlSnippetTree' import { UnshareSnippetModal } from './UnshareSnippetModal' import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal' import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal' import { useContentCountQuery } from '@/data/content/content-count-query' -import { useContentInfiniteQuery } from '@/data/content/content-infinite-query' -import { Snippet, SNIPPET_PAGE_LIMIT } from '@/data/content/sql-folders-query' -import { createTabId, useTabsStateSnapshot } from '@/state/tabs' +import { Snippet } from '@/data/content/sql-folders-query' +import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query' interface SearchListProps { search: string } +/** Uppercase section heading shown above the Database and Logs result groups. */ +const SearchGroupHeading = ({ children }: { children: string }) => ( +

    {children}

    +) + +/** + * Flatten paginated snippet results into the flat (folderless) tree the search view + * renders. Keyed on `pages` (a stable reference from React Query) so the flatMap and + * tree build run once per data change rather than on every render. + */ +function useSnippetSearchTree(pages: { contents: Snippet[] }[] | undefined) { + return useMemo(() => { + const flat = (pages ?? []) + .flatMap((page) => page.contents ?? []) + .map((snippet) => ({ ...snippet, folder_id: null })) + const treeState = formatFolderResponseForTreeView({ folders: [], contents: flat }) + return { treeState, lastItemIds: getLastItemIds(treeState), count: flat.length } + }, [pages]) +} + export const SearchList = ({ search }: SearchListProps) => { - const { id } = useParams() - const tabs = useTabsStateSnapshot() const { ref: projectRef } = useParams() const [selectedSnippetToShare, setSelectedSnippetToShare] = useState() @@ -32,120 +48,121 @@ export const SearchList = ({ search }: SearchListProps) => { const [selectedSnippetToRename, setSelectedSnippetToRename] = useState() const [selectedSnippetToDelete, setSelectedSnippetToDelete] = useState() + // Logs snippets are a separate backend and don't share the `sql` cursor, so the + // search runs a second single-type query and renders them under their own group. + // Gated by both flags, mirroring the nav's Logs section. + const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + const canShowLogsSection = isLogsSourceEnabled && isOtelLogsEnabled + + const searchName = search.length === 0 ? undefined : search + const { data, isPending: isLoading, hasNextPage, fetchNextPage, isFetchingNextPage, - } = useContentInfiniteQuery( - { - projectRef, - type: 'sql', - limit: SNIPPET_PAGE_LIMIT, - name: search.length === 0 ? undefined : search, - }, + } = useSqlSnippetsQuery( + { projectRef, type: 'sql', name: searchName }, { placeholderData: keepPreviousData } ) const { data: count, isPending: isLoadingCount } = useContentCountQuery( - { - projectRef, - type: 'sql', - name: search, - }, + { projectRef, type: 'sql', name: search }, { placeholderData: keepPreviousData } ) - const totalNumber = count ? count.private + count.shared : 0 - const snippets = useMemo( - // [Joshen] Set folder_id to null to ensure flat list - () => data?.pages.flatMap((page) => page.content.map((x) => ({ ...x, folder_id: null }))), - [data?.pages] + const { data: logsCount, isPending: isLoadingLogsCount } = useContentCountQuery( + { projectRef, type: 'log_sql', name: search }, + { enabled: canShowLogsSection, placeholderData: keepPreviousData } ) - const treeState = formatFolderResponseForTreeView({ folders: [], contents: snippets as any }) - const snippetsLastItemIds = useMemo(() => getLastItemIds(treeState), [treeState]) + const databaseCount = count ? count.private + count.shared : 0 + const logsResultCount = canShowLogsSection && logsCount ? logsCount.private + logsCount.shared : 0 + const totalNumber = databaseCount + logsResultCount + const isLoadingCounts = isLoadingCount || (canShowLogsSection && isLoadingLogsCount) + const hasCounts = count !== undefined || (canShowLogsSection && logsCount !== undefined) + + const { + data: logsData, + isPending: isLoadingLogs, + hasNextPage: hasNextLogsPage, + fetchNextPage: fetchNextLogsPage, + isFetchingNextPage: isFetchingNextLogsPage, + } = useSqlSnippetsQuery( + { projectRef, type: 'log_sql', name: searchName }, + { enabled: canShowLogsSection, placeholderData: keepPreviousData } + ) + + const databaseTree = useSnippetSearchTree(data?.pages) + const logsTree = useSnippetSearchTree(logsData?.pages) + const hasDatabaseResults = databaseTree.count > 0 + const hasLogsResults = logsTree.count > 0 + // Label the groups only when both are present; a single group needs no heading. + const showGroupHeadings = hasDatabaseResults && hasLogsResults + // The results body is loading until both source queries (when enabled) settle. + const isSearchLoading = isLoading || (canShowLogsSection && isLoadingLogs) return ( <>
    - {isLoadingCount ? ( + {isLoadingCounts && (
    - ) : !!count ? ( + )} + {!isLoadingCounts && hasCounts && (

    - {totalNumber} result{totalNumber > 1 ? 's' : ''} found + {totalNumber} result{totalNumber === 1 ? '' : 's'} found

    - ) : null} - {isLoading ? ( + )} + {isSearchLoading && (
    - ) : ( - { - const isOpened = Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) - const tabId = createTabId('sql', { - id: element?.metadata?.id as unknown as Snippet['id'], - }) - const isPreview = tabs.previewTabId === tabId - const isActive = !isPreview && element.metadata?.id === id - const visibility = - element.metadata?.visibility === 'user' - ? 'Private' - : element.metadata?.visibility === 'project' - ? 'Shared' - : undefined - - return ( - - {element.name} - {!!visibility && ( - {visibility} - )} - - ), - }} - nameForTitle={element.name} - isBranch={false} - isOpened={isOpened && !isPreview} - isSelected={isActive} - isPreview={isPreview} - isLastItem={snippetsLastItemIds.has(element.id as string)} - status="idle" - className="items-start h-[40px] [&>svg]:translate-y-0.5" - onSelectDelete={() => setSelectedSnippetToDelete(element.metadata as Snippet)} - onSelectRename={() => setSelectedSnippetToRename(element.metadata as Snippet)} - onSelectDownload={() => setSelectedSnippetToDownload(element.metadata as Snippet)} - onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)} - onSelectUnshare={() => setSelectedSnippetToUnshare(element.metadata as Snippet)} - hasNextPage={hasNextPage} - fetchNextPage={fetchNextPage} - isFetchingNextPage={isFetchingNextPage} - onDoubleClick={(e) => { - e.preventDefault() - tabs.makeTabPermanent(tabId) - }} - /> - ) - }} - /> + )} + {!isSearchLoading && !hasDatabaseResults && !hasLogsResults && ( +

    No queries found

    + )} + {!isSearchLoading && hasDatabaseResults && ( + <> + {showGroupHeadings && Database} + + + )} + {!isSearchLoading && canShowLogsSection && hasLogsResults && ( + <> + {showGroupHeadings && Logs} + + )}
    diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx new file mode 100644 index 00000000000..4ee68e623c2 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SqlSnippetTree.tsx @@ -0,0 +1,122 @@ +import { useParams } from 'common' +import { TreeView } from 'ui' + +import type { TreeViewItemProps } from './SQLEditorNav.utils' +import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' +import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' +import { Snippet } from '@/data/content/sql-folders-query' +import { createTabId, useTabsStateSnapshot } from '@/state/tabs' + +interface SqlSnippetTreeProps { + ariaLabel: string + data: TreeViewItemProps[] + lastItemIds: Set + /** Additional (multi-)selected snippet ids to highlight beyond the active one. */ + selectedSnippetIds?: string[] + /** Search results show a source/visibility sublabel under each snippet name. */ + showVisibility?: boolean + itemClassName?: string + hasNextPage?: boolean + fetchNextPage?: () => void + isFetchingNextPage?: boolean + onSelectDelete?: (snippet: Snippet) => void + onSelectRename?: (snippet: Snippet) => void + onSelectDownload?: (snippet: Snippet) => void + onSelectShare?: (snippet: Snippet) => void + onSelectUnshare?: (snippet: Snippet) => void +} + +/** + * The sublabel under a snippet's name. Logs snippets are labeled by source rather + * than visibility: they have no share action, so "Private" says nothing that + * distinguishes them from the database query sitting right above them in the + * same result list, while "Logs" does. + */ +const snippetSublabel = (snippet: Snippet) => { + if (getSnippetSource(snippet) === 'logs') return 'Logs' + if (snippet.visibility === 'user') return 'Private' + if (snippet.visibility === 'project') return 'Shared' + return undefined +} + +export const SqlSnippetTree = ({ + ariaLabel, + data, + lastItemIds, + selectedSnippetIds, + showVisibility = false, + itemClassName, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + onSelectDelete, + onSelectRename, + onSelectDownload, + onSelectShare, + onSelectUnshare, +}: SqlSnippetTreeProps) => { + const { id } = useParams() + const tabs = useTabsStateSnapshot() + + return ( + { + const snippet = element.metadata as Snippet + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === snippet.id + ) + const tabId = createTabId('sql', { id: snippet.id }) + const isPreview = tabs.previewTabId === tabId + const isActive = !isPreview && snippet.id === id + const isSelected = isActive || (selectedSnippetIds?.includes(snippet.id) ?? false) + const sublabel = showVisibility ? snippetSublabel(snippet) : undefined + + return ( + + {element.name} + {!!sublabel && ( + {sublabel} + )} + + ), + } + : element + } + nameForTitle={showVisibility ? (element.name as string) : undefined} + isBranch={false} + isOpened={isOpened && !isPreview} + isSelected={isSelected} + isPreview={isPreview} + isLastItem={lastItemIds.has(element.id as string)} + status="idle" + className={itemClassName} + onSelectDelete={onSelectDelete ? () => onSelectDelete(snippet) : undefined} + onSelectRename={onSelectRename ? () => onSelectRename(snippet) : undefined} + onSelectDownload={onSelectDownload ? () => onSelectDownload(snippet) : undefined} + onSelectShare={onSelectShare ? () => onSelectShare(snippet) : undefined} + onSelectUnshare={onSelectUnshare ? () => onSelectUnshare(snippet) : undefined} + hasNextPage={hasNextPage} + fetchNextPage={fetchNextPage} + isFetchingNextPage={isFetchingNextPage} + onDoubleClick={(e) => { + e.preventDefault() + tabs.makeTabPermanent(tabId) + }} + /> + ) + }} + /> + ) +} diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 80710c92136..7dcf8969319 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -21,6 +21,7 @@ import { parseSupaTable } from '@/components/grid/SupabaseGrid.utils' import { SupaTable } from '@/components/grid/types' import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning' import { ErrorMatcher } from '@/components/interfaces/ErrorHandling/ErrorMatcher' +import { RestartTroubleshootingFallback } from '@/components/interfaces/ErrorHandling/RestartTroubleshootingFallback' import { EditorMenuListSkeleton } from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList' @@ -305,6 +306,7 @@ export const TableEditorMenu = () => { error={error ?? 'Failed to load tables'} supportFormParams={{ projectRef: project?.ref }} className="mx-4 mt-3" + fallback={} /> )} diff --git a/apps/studio/components/layouts/Tabs/RecentItems.tsx b/apps/studio/components/layouts/Tabs/RecentItems.tsx index 165dd480436..2bae3f19acb 100644 --- a/apps/studio/components/layouts/Tabs/RecentItems.tsx +++ b/apps/studio/components/layouts/Tabs/RecentItems.tsx @@ -71,7 +71,7 @@ export function RecentItems() { className="flex items-center gap-4 rounded-lg bg-surface-100 py-2 transition-colors hover:bg-surface-200" >
    - +
    diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index c66a3a003a1..8c3eccdd38c 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -104,7 +104,7 @@ export const SortableTab = ({ )} {...listeners} > - +
    {shouldShowSchema && ( diff --git a/apps/studio/components/layouts/Tabs/TabPreview.tsx b/apps/studio/components/layouts/Tabs/TabPreview.tsx index f2fa1073a0c..ef5825f49ba 100644 --- a/apps/studio/components/layouts/Tabs/TabPreview.tsx +++ b/apps/studio/components/layouts/Tabs/TabPreview.tsx @@ -17,7 +17,7 @@ export const TabPreview = ({ tab }: { tab: string }) => { animate={{ opacity: 0.7 }} className="flex relative items-center gap-2 px-3 text-xs bg-dash-sidebar dark:bg-surface-100 shadow-lg rounded-xs h-10" > - + {tabData.label || 'Untitled'}
    diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 9ee549892b3..73b0dd1c43a 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -11,7 +11,7 @@ import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { Plus, X } from 'lucide-react' import { useRouter } from 'next/router' -import { useState } from 'react' +import { ReactNode, useState } from 'react' import { cn, ContextMenu, @@ -37,7 +37,19 @@ import { type TabCloseConfirmation, } from '@/state/tabs' -export const EditorTabs = () => { +interface EditorTabsProps { + customTabs?: ReactNode + newTabButton?: ReactNode + isCollapseButtonHidden?: boolean +} + +// [Joshen] Will be adjusting this component to support Explorer +// Will require quite a bit of cleaning up once Explorer supercedes SQL Editor +export const EditorTabs = ({ + customTabs, + newTabButton, + isCollapseButtonHidden, +}: EditorTabsProps) => { const { ref, id } = useParams() const router = useRouter() const { setLastVisitedSnippet, setLastVisitedTable } = useDashboardHistory() @@ -175,7 +187,10 @@ export const EditorTabs = () => { value={hasNewTab ? 'new' : (tabs.activeTab ?? undefined)} onValueChange={handleTabChange} > - + {!isCollapseButtonHidden && } + + {customTabs} + { )} - {!hasNewTab && ( - - router.push( - `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` - ) - } - initial={{ opacity: 0, scale: 0.8, x: -10 }} - animate={{ opacity: 1, scale: 1, x: 0 }} - transition={{ duration: 0.2 }} - > - - - )} + {!hasNewTab && + (newTabButton ?? ( + + router.push( + `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` + ) + } + initial={{ opacity: 0, scale: 0.8, x: -10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + transition={{ duration: 0.2 }} + > + + + ))}
    diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx b/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx new file mode 100644 index 00000000000..8bb69423320 --- /dev/null +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.test.tsx @@ -0,0 +1,91 @@ +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' + +import { useSqlEditorTabsCleanup } from './Tabs.utils' +import { createTabsState, TabsStateContext, type Tab } from '@/state/tabs' + +const dbTab = (id: string): Tab => ({ + id: `sql-${id}`, + type: 'sql', + label: id, + isPreview: false, + metadata: { sqlId: id, sqlSource: 'database' }, +}) + +const logsTab = (id: string): Tab => ({ + id: `sql-${id}`, + type: 'sql', + label: id, + isPreview: false, + metadata: { sqlId: id, sqlSource: 'logs' }, +}) + +function renderCleanup(store: ReturnType) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return renderHook(() => useSqlEditorTabsCleanup(), { wrapper }).result.current +} + +describe('useSqlEditorTabsCleanup', () => { + it('prunes tabs for deleted snippets (database and logs) while keeping live ones', () => { + const store = createTabsState('default') + store.addTab(dbTab('db-stale')) + store.addTab(logsTab('logs-stale')) + store.addTab(logsTab('logs-live')) + + const cleanup = renderCleanup(store) + + // The caller passes both `sql` and `log_sql` snippets it knows to be live; only + // `logs-live` is present, so the two stale tabs are pruned and it is kept. + act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] })) + + expect(store.openTabs).toEqual(['sql-logs-live']) + expect(store.tabsMap['sql-db-stale']).toBeUndefined() + expect(store.tabsMap['sql-logs-stale']).toBeUndefined() + expect(store.tabsMap['sql-logs-live']).toBeDefined() + }) + + it('keeps a logs tab that still exists in the snippet list', () => { + const store = createTabsState('default') + store.addTab(logsTab('logs')) + + const cleanup = renderCleanup(store) + act(() => cleanup({ snippets: [{ id: 'logs', type: 'log_sql', name: 'logs' }] })) + + expect(store.openTabs).toEqual(['sql-logs']) + }) + + it('preserves logs tabs when logs snippets are not authoritative (canPruneLogsTabs false)', () => { + const store = createTabsState('default') + store.addTab(dbTab('db-stale')) + store.addTab(logsTab('logs')) + + const cleanup = renderCleanup(store) + // Logs section disabled / query errored: no logs snippets passed, and logs tabs + // must not be pruned — but stale database tabs still are. + act(() => cleanup({ snippets: [], canPruneLogsTabs: false })) + + expect(store.openTabs).toEqual(['sql-logs']) + expect(store.tabsMap['sql-db-stale']).toBeUndefined() + + // Recent items carry their own logs-source condition, so assert them separately. + const recentIds = store.recentItems.map((item) => item.id) + expect(recentIds).toContain('sql-logs') + expect(recentIds).not.toContain('sql-db-stale') + }) + + it('prunes recent items for deleted logs snippets while keeping live ones', () => { + const store = createTabsState('default') + store.addTab(logsTab('logs-stale')) + store.addTab(logsTab('logs-live')) + + const cleanup = renderCleanup(store) + act(() => cleanup({ snippets: [{ id: 'logs-live', type: 'log_sql', name: 'logs-live' }] })) + + const recentIds = store.recentItems.map((item) => item.id) + expect(recentIds).toContain('sql-logs-live') + expect(recentIds).not.toContain('sql-logs-stale') + }) +}) diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts index eae4578697f..ad95bf40784 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts @@ -65,46 +65,69 @@ export function useSqlEditorTabsCleanup() { const tabMapRef = useLatest(tabs.tabsMap) const openTabsRef = useLatest(tabs.openTabs) - return useCallback(({ snippets }: { snippets: { id: string; type: string; name: string }[] }) => { - // these are tabs that are static content - // these canot be removed from localstorage based on this query request - const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts'] + return useCallback( + ({ + snippets, + canPruneLogsTabs = true, + }: { + snippets: { id: string; type: string; name: string }[] + // Whether `log_sql` snippets in `snippets` are authoritative. When false (the + // logs section is disabled or its query errored) we can't know which logs + // snippets exist, so logs tabs are preserved rather than pruned as stale. + canPruneLogsTabs?: boolean + }) => { + // these are tabs that are static content + // these canot be removed from localstorage based on this query request + const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts'] - // Identify all SQL snippets / content by their tab ids - const currentContentIds = [ - ...snippets - .filter((content) => content.type === 'sql') - .map((content) => createTabId('sql', { id: content.id })), - // append ignored tab IDs - ...IGNORED_TAB_IDS, - ] + // Identify all SQL snippets / content by their tab ids. Both database (`sql`) and + // logs (`log_sql`) snippets live in the `sql-` tab id space, so both are counted as + // live. Anything not in this set is treated as removed outside the session and pruned. + const currentContentIds = [ + ...snippets + .filter((content) => content.type === 'sql' || content.type === 'log_sql') + .map((content) => createTabId('sql', { id: content.id })), + // append ignored tab IDs + ...IGNORED_TAB_IDS, + ] - // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session) - const snippetTabsToBeCleaned = openTabsRef.current.filter( - (id: string) => id.startsWith('sql') && !currentContentIds.includes(id) - ) - tabs.removeTabs(snippetTabsToBeCleaned) + const isPrunable = (id: string) => + id.startsWith('sql') && + !currentContentIds.includes(id) && + (canPruneLogsTabs || tabMapRef.current[id]?.metadata?.sqlSource !== 'logs') - // Remove any recent items that might no longer be existing (removed outside of the dashboard session) - const recentItems = tabs.getRecentItemsByType('sql') - tabs.removeRecentItems( - recentItems - ? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id) - : [] - ) + // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session) + const snippetTabsToBeCleaned = openTabsRef.current.filter(isPrunable) + tabs.removeTabs(snippetTabsToBeCleaned) - // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not - // As the snippets name could've been updated outside of the SQL Editor session - // e.g for a shared snippet, the owner could've updated the name of the snippet - const openSqlTabs = openTabsRef.current - .map((id) => tabMapRef.current[id]) - .filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type)) + // Remove any recent items that might no longer be existing (removed outside of the dashboard session) + const recentItems = tabs.getRecentItemsByType('sql') + tabs.removeRecentItems( + recentItems + ? recentItems + .filter( + (item) => + !currentContentIds.includes(item.id) && + (canPruneLogsTabs || item.metadata?.sqlSource !== 'logs') + ) + .map((item) => item.id) + : [] + ) - openSqlTabs.forEach((tab) => { - const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id) - if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name }) - }) - }, []) + // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not + // As the snippets name could've been updated outside of the SQL Editor session + // e.g for a shared snippet, the owner could've updated the name of the snippet + const openSqlTabs = openTabsRef.current + .map((id) => tabMapRef.current[id]) + .filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type)) + + openSqlTabs.forEach((tab) => { + const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id) + if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name }) + }) + }, + [] + ) } interface UseTabsScrollOptions { diff --git a/apps/studio/components/layouts/editors/EditorsLayout.hooks.ts b/apps/studio/components/layouts/editors/EditorsLayout.hooks.ts index 1495cc2b374..3b0f0cdb705 100644 --- a/apps/studio/components/layouts/editors/EditorsLayout.hooks.ts +++ b/apps/studio/components/layouts/editors/EditorsLayout.hooks.ts @@ -1,13 +1,15 @@ import { useParams } from 'common' import { usePathname } from 'next/navigation' -export function useEditorType(): 'table' | 'sql' | undefined { +export type EditorType = 'table' | 'sql' | 'explorer' | undefined + +export function useEditorType(): EditorType { const pathname = usePathname() const { ref } = useParams() - return pathname?.includes(`/project/${ref}/editor`) - ? 'table' - : pathname?.includes(`/project/${ref}/sql`) - ? 'sql' - : undefined + if (pathname?.includes(`/project/${ref}/editor`)) return 'table' + if (pathname?.includes(`/project/${ref}/sql`)) return 'sql' + if (pathname?.includes(`/project/${ref}/explorer`)) return 'explorer' + + return undefined } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index ca13118c5bc..02c2844a139 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -15,8 +15,8 @@ import { ButtonTooltip } from '../ButtonTooltip' import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary' import { InlineLinkClassName } from '../InlineLink' import { ASSISTANT_ERRORS } from './AiAssistant.constants' -import type { SqlSnippet } from './AIAssistant.types' import { + containsLogsSnippets, hasPendingToolApproval, onErrorChat, resolvePendingToolApprovalsAsDenied, @@ -31,6 +31,7 @@ import { } from './elements/Conversation' import { Message } from './Message' import { Markdown } from '@/components/interfaces/Markdown' +import { resolveSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useCheckOpenAIKeyQuery } from '@/data/ai/check-api-key-query' import { useRateMessageMutation } from '@/data/ai/rate-message-mutation' @@ -40,6 +41,7 @@ import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import type { AssistantMessageMetadata } from '@/lib/ai/assistant-message-metadata' import { getParallelApprovalIdsToReject } from '@/lib/ai/message-utils' import { DEFAULT_ASSISTANT_BASE_MODEL_ID, @@ -50,7 +52,7 @@ import { import { IS_PLATFORM } from '@/lib/constants' import { uuidv4 } from '@/lib/helpers' import { useTrack } from '@/lib/telemetry/track' -import type { AssistantModel } from '@/state/ai-assistant-state' +import type { AssistantModel, SqlSnippet } from '@/state/ai-assistant-state' import { useAiAssistantState, useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' @@ -64,7 +66,7 @@ interface AIAssistantProps { export const AIAssistant = ({ className }: AIAssistantProps) => { const router = useRouter() - const { id: entityId } = useParams() + const { id: entityId, source: sourceParam } = useParams() const { data: project } = useSelectedProjectQuery() const searchParams = useSearchParamsShallow() @@ -108,6 +110,10 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const inputRef = useRef(null) const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel() + // Whether attached queries are sent at all. One definition, shared by the chat form + // (which folds them into the message text) and the message metadata (which states + // whether any of them was a logs query), so the two can't disagree. + const includeSnippetsInMessage = aiOptInLevel !== 'disabled' const showMetadataWarning = IS_PLATFORM && !!selectedOrganization && @@ -136,6 +142,10 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const snippet = snippets[entityId ?? ''] const snippetContent = snippet?.snippet?.content?.unchecked_sql + const openSnippetSource = isInSQLEditor + ? resolveSnippetSource(snippet?.snippet, sourceParam) + : undefined + const { data: tables } = useTablesQuery( { projectRef: project?.ref, @@ -355,11 +365,23 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { setEditingMessageId(null) } + // Read off the attachments this message actually carries, so detaching the + // "Current Query" chip also drops the claim. Gated on the same condition that + // decides whether attachments make it into the text at all: with AI opt-in + // disabled the chip is shown but no query is sent, and claiming otherwise would + // have the server prepend ClickHouse context for a message holding no query. + // Rides on the message rather than the request, so a Retry reproduces the context + // the message was asked in. + const metadata: AssistantMessageMetadata = { + containsLogsSnippets: includeSnippetsInMessage && containsLogsSnippets(snap.sqlSnippets), + } + const payload = { role: 'user', createdAt: new Date(), parts: [{ type: 'text', text: finalContent }], id: uuidv4(), + metadata, } as MessageType snap.clearSqlSnippets() @@ -421,10 +443,12 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { useEffect(() => { const isOpen = activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT if (isOpen && isInSQLEditor && !!snippetContent) { - snap.setSqlSnippets([{ label: 'Current Query', content: snippetContent }]) + snap.setSqlSnippets([ + { label: 'Current Query', content: snippetContent, source: openSnippetSource }, + ]) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeSidebar?.id, isInSQLEditor, snippetContent]) + }, [activeSidebar?.id, isInSQLEditor, snippetContent, openSnippetSource]) return ( { newSnippets.splice(index, 1) snap.setSqlSnippets(newSnippets) }} - includeSnippetsInMessage={aiOptInLevel !== 'disabled'} + includeSnippetsInMessage={includeSnippetsInMessage} selectedModel={selectedModel} onSelectModel={(model) => snap.setModel(model)} /> diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts index b87d413d76a..ea3611c4133 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts @@ -10,5 +10,3 @@ export interface AssistantSnippetProps { yAxis?: string name?: string } - -export type SqlSnippet = string | { label: string; content: string } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts index 49648982aaa..2f28dda6bdb 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.test.ts @@ -2,6 +2,8 @@ import type { UIMessage } from 'ai' import { describe, expect, test } from 'vitest' import { + containsLogsSnippets, + formatAttachedSnippets, hasPendingToolApproval, isReadOnlySelect, resolvePendingToolApprovalsAsDenied, @@ -157,3 +159,72 @@ describe('AIAssistant.utils.ts:resolvePendingToolApprovalsAsDenied', () => { expect(resolvePendingToolApprovalsAsDenied(messages)).toEqual(messages) }) }) + +describe('containsLogsSnippets', () => { + test('is true when an attached query is a logs query', () => { + expect( + containsLogsSnippets([{ label: 'Current Query', content: 'select 1', source: 'logs' }]) + ).toBe(true) + }) + + test('is false for a database query', () => { + expect( + containsLogsSnippets([{ label: 'Current Query', content: 'select 1', source: 'database' }]) + ).toBe(false) + }) + + test('is false once nothing is attached', () => { + expect(containsLogsSnippets([])).toBe(false) + expect(containsLogsSnippets(undefined)).toBe(false) + }) + + test('ignores plain string attachments, which carry no source', () => { + expect(containsLogsSnippets(['select 1'])).toBe(false) + }) + + test('is true when only some of several attachments are logs queries', () => { + expect( + containsLogsSnippets([ + 'select 1', + { label: 'Current Query', content: 'select 2', source: 'database' }, + { label: 'Other', content: 'select 3', source: 'logs' }, + ]) + ).toBe(true) + }) +}) + +describe('formatAttachedSnippets', () => { + test('fences a database query as sql', () => { + expect( + formatAttachedSnippets([{ label: 'Current Query', content: 'select 1', source: 'database' }]) + ).toBe('```sql\nselect 1\n```') + }) + + // The fence is how the model tells which attachment is ClickHouse. It also keeps a + // logs query out of MessageMarkdown's `sql` branch, which offers to run the block + // against Postgres and brands it with untrustedSql. + test('fences a logs query as clickhouse', () => { + expect( + formatAttachedSnippets([ + { + label: 'Current Query', + content: "select count() from logs where source = 'edge_logs'", + source: 'logs', + }, + ]) + ).toBe("```clickhouse\nselect count() from logs where source = 'edge_logs'\n```") + }) + + test('labels each attachment with its own dialect', () => { + expect( + formatAttachedSnippets([ + { label: 'A', content: 'select 1', source: 'database' }, + { label: 'B', content: 'select 2', source: 'logs' }, + ]) + ).toBe('```sql\nselect 1\n```\n```clickhouse\nselect 2\n```') + }) + + test('falls back to sql for a plain string attachment', () => { + expect(formatAttachedSnippets(['select 1'])).toBe('```sql\nselect 1\n```') + }) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts index 801f35fb939..92d4b1d694e 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -2,6 +2,10 @@ import { isToolUIPart, type UIMessage } from 'ai' import { toast } from 'sonner' import { SAFE_FUNCTIONS } from './AiAssistant.constants' +import { + isLogsSource, + sqlSourceToFenceLanguage, +} from '@/components/interfaces/SQLEditor/querySource' import { authKeys } from '@/data/auth/keys' import { databaseExtensionsKeys } from '@/data/database-extensions/keys' import { databaseIndexesKeys } from '@/data/database-indexes/keys' @@ -12,6 +16,7 @@ import { enumeratedTypesKeys } from '@/data/enumerated-types/keys' import { handleError } from '@/data/fetchers' import { tableKeys } from '@/data/tables/keys' import { tryParseJson } from '@/lib/helpers' +import type { SqlSnippet } from '@/state/ai-assistant-state' import { ResponseError } from '@/types' export type MutationCategory = 'functions' | 'rls-policies' @@ -159,3 +164,41 @@ export const onErrorChat = (error: Error) => { } } } + +export function containsLogsSnippets(snippets: readonly SqlSnippet[] | undefined): boolean { + return (snippets ?? []).some( + (snippet) => typeof snippet !== 'string' && isLogsSource(snippet.source) + ) +} + +export const getSnippetLabel = (snippet: SqlSnippet, index: number): string => + typeof snippet === 'string' ? `Snippet ${index + 1}` : snippet.label + +export const getSnippetContent = (snippet: SqlSnippet): string => + typeof snippet === 'string' ? snippet : snippet.content + +/** + * The fence language an attached query is written into the message with. A logs query + * is fenced as `clickhouse` so the model can tell which attachment is ClickHouse + * against the `logs` table — a single message can carry both dialects. + * + * It also keeps the two apart in the rendered message: MessageMarkdown treats a `sql` + * fence as runnable Postgres (`DisplayBlockRenderer`, branded with `untrustedSql`), + * which a ClickHouse query must never be offered as. + */ +function getSnippetFenceLanguage(snippet: SqlSnippet): 'sql' | 'clickhouse' { + return sqlSourceToFenceLanguage(typeof snippet === 'string' ? undefined : snippet.source) +} + +/** + * Renders attached queries as the fenced code blocks appended to the message text, + * each labelled with its own dialect. + */ +export function formatAttachedSnippets(snippets: readonly SqlSnippet[]): string { + return snippets + .map( + (snippet) => + '```' + getSnippetFenceLanguage(snippet) + '\n' + getSnippetContent(snippet) + '\n```' + ) + .join('\n') +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx index 928312db5d3..83df5deb1a1 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx @@ -4,10 +4,10 @@ import { BarChart, FileText, Shield } from 'lucide-react' import { AiIconAnimation, Button, Skeleton } from 'ui' import { codeSnippetPrompts, defaultPrompts } from './AIAssistant.prompts' -import type { SqlSnippet } from './AIAssistant.types' import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants' import { createLintSummaryPrompt } from '@/components/interfaces/Linter/Linter.utils' import { useProjectLintsQuery, type Lint } from '@/data/lint/lint-query' +import type { SqlSnippet } from '@/state/ai-assistant-state' interface AIOnboardingProps { sqlSnippets?: SqlSnippet[] diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx index 41bec723181..9db5c0488e5 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx @@ -5,10 +5,11 @@ import { ExpandingTextArea } from 'ui' import { cn } from 'ui/src/lib/utils' import { ButtonTooltip } from '../ButtonTooltip' -import { type SqlSnippet } from './AIAssistant.types' +import { formatAttachedSnippets } from './AIAssistant.utils' import { ModelSelector } from './ModelSelector' -import { getSnippetContent, SnippetRow } from './SnippetRow' +import { SnippetRow } from './SnippetRow' import type { AssistantModelId } from '@/lib/ai/model.utils' +import { type SqlSnippet } from '@/state/ai-assistant-state' export interface FormProps { /* The ref for the textarea, optional. Exposed for the CommandsPopover to attach events. */ @@ -83,10 +84,7 @@ const AssistantChatFormComponent = forwardRef( let finalMessage = value if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) { - const sqlSnippetsString = sqlSnippets - .map((snippet: SqlSnippet) => '```sql\n' + getSnippetContent(snippet) + '\n```') - .join('\n') - finalMessage = [value, sqlSnippetsString].filter(Boolean).join('\n\n') + finalMessage = [value, formatAttachedSnippets(sqlSnippets)].filter(Boolean).join('\n\n') } onSubmit(finalMessage) @@ -156,6 +154,7 @@ const AssistantChatFormComponent = forwardRef( ) : ( } disabled={!canSubmit} diff --git a/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx b/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx index 581b7fa92b6..2f68ab7cf02 100644 --- a/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/SnippetRow.tsx @@ -3,21 +3,8 @@ import React from 'react' import { Button, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' -import { type SqlSnippet } from './AIAssistant.types' - -export const getSnippetLabel = (snippet: SqlSnippet, index: number): string => { - if (typeof snippet === 'string') { - return `Snippet ${index + 1}` - } - return snippet.label -} - -export const getSnippetContent = (snippet: SqlSnippet): string => { - if (typeof snippet === 'string') { - return snippet - } - return snippet.content -} +import { getSnippetContent, getSnippetLabel } from './AIAssistant.utils' +import { type SqlSnippet } from '@/state/ai-assistant-state' interface SnippetRowProps { snippets: SqlSnippet[] diff --git a/apps/studio/components/ui/DatePicker/index.tsx b/apps/studio/components/ui/DatePicker/index.tsx index bcc24d54698..df04c8977dd 100644 --- a/apps/studio/components/ui/DatePicker/index.tsx +++ b/apps/studio/components/ui/DatePicker/index.tsx @@ -82,6 +82,9 @@ export function DatePicker({ const disabledDays = useMemo(() => calculateDisabledDays(minDate, maxDate), [minDate, maxDate]) + const startMonth = minDate ? dayjs(minDate).startOf('month').toDate() : undefined + const endMonth = maxDate ? dayjs(maxDate).endOf('month').toDate() : undefined + const clampDateToRange = useCallback( (date: Date | null) => { if (!date) return date @@ -286,6 +289,8 @@ export function DatePicker({ {selectsRange ? ( { @@ -295,6 +300,8 @@ export function DatePicker({ ) : ( { diff --git a/apps/studio/components/ui/DocsButton.tsx b/apps/studio/components/ui/DocsButton.tsx index 9a3de4e8e9e..45b5588c20e 100644 --- a/apps/studio/components/ui/DocsButton.tsx +++ b/apps/studio/components/ui/DocsButton.tsx @@ -6,9 +6,11 @@ interface DocsButtonProps { abbrev?: boolean className?: string topic?: string + /** Custom button text, e.g. to distinguish multiple docs buttons side by side. */ + label?: string } -export const DocsButton = ({ href, abbrev = true, className, topic }: DocsButtonProps) => { +export const DocsButton = ({ href, abbrev = true, className, topic, label }: DocsButtonProps) => { return ( ) diff --git a/apps/studio/components/ui/EntityTypeIcon.tsx b/apps/studio/components/ui/EntityTypeIcon.tsx index f62f616ba10..f238dcab366 100644 --- a/apps/studio/components/ui/EntityTypeIcon.tsx +++ b/apps/studio/components/ui/EntityTypeIcon.tsx @@ -1,13 +1,36 @@ -import { Eye, GitBranch, Table2 } from 'lucide-react' +import { Eye, GitBranch, NotebookText, ScrollText, Table2 } from 'lucide-react' import { cn, SQL_ICON } from 'ui' +import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' +/** + * The single icon representing a logs (`log_sql`) snippet — reused by the tabs + * (via EntityTypeIcon) and the nav tree so the two can't drift. Callers pass the + * context-appropriate size/className; the icon and stroke stay the same. + */ +export const LogsSnippetIcon = ({ + size = 15, + strokeWidth = 1.5, + className, +}: { + size?: number + strokeWidth?: number + className?: string +}) => ( + +) + interface EntityTypeIconProps { - type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' + type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' | 'notebook' size?: number strokeWidth?: number isActive?: boolean + sqlSource?: SqlSnippetSource } export const EntityTypeIcon = ({ @@ -15,7 +38,23 @@ export const EntityTypeIcon = ({ size = 15, strokeWidth = 1.5, isActive, + sqlSource, }: EntityTypeIconProps) => { + if (type === 'sql' && sqlSource === 'logs') { + return ( + + ) + } + if (type === 'sql') { return ( + } + return (
    {
    {idx !== menu.length - 1 && ( -
    +
    )}
    ))} diff --git a/apps/studio/components/ui/SchemaSelector.test.tsx b/apps/studio/components/ui/SchemaSelector.test.tsx index 0cff282ba8b..20500671f37 100644 --- a/apps/studio/components/ui/SchemaSelector.test.tsx +++ b/apps/studio/components/ui/SchemaSelector.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { SchemaSelector } from './SchemaSelector' import { customRender } from '@/tests/lib/custom-render' -import { addAPIMock } from '@/tests/lib/msw' +import { addAPIMock, APIErrorBody } from '@/tests/lib/msw' mockAnimationsApi() @@ -41,6 +41,30 @@ const mockProjectAndSchemas = ({ highAvailability }: { highAvailability: boolean }) } +const mockProjectAndFailingSchemas = () => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + // @ts-expect-error partial project response + response: { + cloud_provider: 'localhost', + id: 1, + inserted_at: '2021-08-02T06:40:40.646Z', + name: 'Default Project', + organization_id: 1, + ref: 'default', + region: 'local', + status: 'ACTIVE_HEALTHY', + }, + }) + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => + HttpResponse.json({ message: 'Service unavailable' }, { status: 503 }), + }) +} + const renderAndOpenSelector = async () => { customRender() @@ -66,4 +90,16 @@ describe('SchemaSelector', () => { expect(screen.getByRole('option', { name: 'multigres' })).toBeInTheDocument() }) + + it('offers to restart the database when schemas fail to load', async () => { + mockProjectAndFailingSchemas() + + customRender() + + await userEvent.click(await screen.findByRole('button', { name: 'Restart database' })) + + expect( + await screen.findByText(/are you sure you want to restart your database/i) + ).toBeInTheDocument() + }) }) diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx index a051ce4c6f4..d7cd9d1a0c3 100644 --- a/apps/studio/components/ui/SchemaSelector.tsx +++ b/apps/studio/components/ui/SchemaSelector.tsx @@ -20,6 +20,7 @@ import { Skeleton, } from 'ui' +import { RestartProjectDialog } from '@/components/interfaces/ErrorHandling/RestartProjectDialog' import { useSchemasQuery } from '@/data/database/schemas-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSchemasFilteredForHighAvailability } from '@/hooks/misc/useHighAvailability' @@ -65,6 +66,7 @@ export const SchemaSelector = forwardRef( ref ) => { const [internalOpen, setInternalOpen] = useState(false) + const [isRestartDialogVisible, setIsRestartDialogVisible] = useState(false) const isControlled = openProp !== undefined const open = isControlled ? openProp : internalOpen const setOpen = (next: boolean) => { @@ -119,9 +121,19 @@ export const SchemaSelector = forwardRef( Error: {(schemasError as any)?.message} - +
    + + +
    + setIsRestartDialogVisible(false)} + restartType="database" + /> )} diff --git a/apps/studio/data/api-authorization/api-authorization-approve-mutation.ts b/apps/studio/data/api-authorization/api-authorization-approve-mutation.ts index d4011184bb9..968b75ef7b2 100644 --- a/apps/studio/data/api-authorization/api-authorization-approve-mutation.ts +++ b/apps/studio/data/api-authorization/api-authorization-approve-mutation.ts @@ -1,5 +1,4 @@ import { useMutation } from '@tanstack/react-query' -import { toast } from 'sonner' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -29,26 +28,18 @@ export async function approveApiAuthorization({ id, slug }: ApiAuthorizationAppr type ApiAuthorizationApproveData = Awaited> -export const useApiAuthorizationApproveMutation = ({ - onError, - ...options -}: Omit< - UseCustomMutationOptions< - ApiAuthorizationApproveData, - ResponseError, - ApiAuthorizationApproveVariables - >, - 'mutationFn' -> = {}) => { +export const useApiAuthorizationApproveMutation = ( + options: Omit< + UseCustomMutationOptions< + ApiAuthorizationApproveData, + ResponseError, + ApiAuthorizationApproveVariables + >, + 'mutationFn' + > = {} +) => { return useMutation({ mutationFn: (vars) => approveApiAuthorization(vars), - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to approve authorization request: ${data.message}`) - } else { - onError(data, variables, context) - } - }, ...options, }) } diff --git a/apps/studio/data/api-authorization/api-authorization-decline-mutation.ts b/apps/studio/data/api-authorization/api-authorization-decline-mutation.ts index 8cc02eb7a4a..9450af30677 100644 --- a/apps/studio/data/api-authorization/api-authorization-decline-mutation.ts +++ b/apps/studio/data/api-authorization/api-authorization-decline-mutation.ts @@ -1,5 +1,4 @@ import { useMutation } from '@tanstack/react-query' -import { toast } from 'sonner' import { del, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -28,26 +27,18 @@ export async function declineApiAuthorization({ id, slug }: ApiAuthorizationDecl type ApiAuthorizationDeclineData = Awaited> -export const useApiAuthorizationDeclineMutation = ({ - onError, - ...options -}: Omit< - UseCustomMutationOptions< - ApiAuthorizationDeclineData, - ResponseError, - ApiAuthorizationDeclineVariables - >, - 'mutationFn' -> = {}) => { +export const useApiAuthorizationDeclineMutation = ( + options: Omit< + UseCustomMutationOptions< + ApiAuthorizationDeclineData, + ResponseError, + ApiAuthorizationDeclineVariables + >, + 'mutationFn' + > = {} +) => { return useMutation({ mutationFn: (vars) => declineApiAuthorization(vars), - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to decline authorization request: ${data.message}`) - } else { - onError(data, variables, context) - } - }, ...options, }) } diff --git a/apps/studio/data/content/content-query.ts b/apps/studio/data/content/content-query.ts index e65c862acbd..65ad09dc4ba 100644 --- a/apps/studio/data/content/content-query.ts +++ b/apps/studio/data/content/content-query.ts @@ -6,7 +6,12 @@ import { contentKeys } from './keys' import { get, handleError } from '@/data/fetchers' import type { Dashboards, LogSqlSnippets, SqlSnippets, UseCustomQueryOptions } from '@/types' -export type ContentBase = components['schemas']['GetUserContentResponse']['data'][number] +// TODO — Charis 2026-08-06 +// Temporary widening until we have API support for notebooks +export type ContentBase = Omit< + components['schemas']['GetUserContentResponse']['data'][number], + 'type' +> & { type: components['schemas']['GetUserContentResponse']['data'][number]['type'] | 'notebook' } export type Content = Omit & ( diff --git a/apps/studio/data/content/keys.ts b/apps/studio/data/content/keys.ts index 6c14043edb2..1c851522545 100644 --- a/apps/studio/data/content/keys.ts +++ b/apps/studio/data/content/keys.ts @@ -16,6 +16,7 @@ export const contentKeys = { sqlSnippets: ( projectRef: string | undefined, options?: { + type?: 'sql' | 'log_sql' sort?: 'inserted_at' | 'name' name?: string visibility?: string diff --git a/apps/studio/data/content/notebooks/notebook-schema.test.ts b/apps/studio/data/content/notebooks/notebook-schema.test.ts new file mode 100644 index 00000000000..8f81fff51cf --- /dev/null +++ b/apps/studio/data/content/notebooks/notebook-schema.test.ts @@ -0,0 +1,161 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { describe, expect, it } from 'vitest' + +import { agentNotebookSchema, notebookDomainSchema, notebookSchema } from './notebook-schema' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' + +const FULL_NOTEBOOK = { + schema_version: 1 as const, + cells: [ + { + _tag: 'markdown_cell' as const, + id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + text: '# Signup funnel', + }, + { + _tag: 'database_cell' as const, + id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + sql: 'select * from auth.users limit 100', + row_limit: 100, + }, + { + _tag: 'log_cell' as const, + id: 'c2001199-1e2b-4ef8-bb6d-6bb9bd380a33', + sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10", + time_range: { + _tag: 'relative_time_range' as const, + unit: 'hour' as const, + amount: 1, + }, + }, + ], +} + +describe('notebookSchema', () => { + it('accepts a full three-cell notebook', () => { + expect(notebookSchema.safeParse(FULL_NOTEBOOK).success).toBe(true) + }) + + it('rejects an unknown cell _tag', () => { + const result = notebookSchema.safeParse({ + schema_version: 1, + cells: [{ _tag: 'chart_cell', id: '1', text: 'hi' }], + }) + + expect(result.success).toBe(false) + }) + + it('rejects a database_cell missing row_limit', () => { + const result = notebookSchema.safeParse({ + schema_version: 1, + cells: [{ _tag: 'database_cell', id: '1', sql: 'select 1' }], + }) + + expect(result.success).toBe(false) + }) + + it('rejects a non-ISO absolute_time_range bound', () => { + const result = notebookSchema.safeParse({ + schema_version: 1, + cells: [ + { + _tag: 'log_cell', + id: '1', + sql: 'select 1', + time_range: { + _tag: 'absolute_time_range', + start: 'not-a-real-date', + end: '2024-01-02T00:00:00.000Z', + }, + }, + ], + }) + + expect(result.success).toBe(false) + }) + + it('accepts an absolute_time_range with ISO8601 bounds', () => { + const result = notebookSchema.safeParse({ + schema_version: 1, + cells: [ + { + _tag: 'log_cell', + id: '1', + sql: 'select 1', + time_range: { + _tag: 'absolute_time_range', + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-02T00:00:00.000Z', + }, + }, + ], + }) + + expect(result.success).toBe(true) + }) + + it('rejects an invalid relative_time_range unit', () => { + const result = notebookSchema.safeParse({ + schema_version: 1, + cells: [ + { + _tag: 'log_cell', + id: '1', + sql: 'select 1', + time_range: { _tag: 'relative_time_range', unit: 'fortnight', amount: 1 }, + }, + ], + }) + + expect(result.success).toBe(false) + }) +}) + +describe('agentNotebookSchema', () => { + it('accepts cells without ids', () => { + const result = agentNotebookSchema.safeParse({ + schema_version: 1, + cells: [ + { _tag: 'markdown_cell', text: 'hello' }, + { _tag: 'database_cell', sql: 'select 1', row_limit: 100 }, + ], + }) + + expect(result.success).toBe(true) + }) + + it('rejects cells that carry an agent-supplied id', () => { + const result = agentNotebookSchema.safeParse({ + schema_version: 1, + cells: [{ _tag: 'markdown_cell', id: 'should-not-be-here', text: 'hello' }], + }) + + expect(result.success).toBe(false) + }) +}) + +describe('notebookDomainSchema', () => { + it('brands database_cell and log_cell sql as unchecked_sql, leaving markdown_cell untouched', () => { + const result = notebookDomainSchema.safeParse(FULL_NOTEBOOK) + + expect(result.success).toBe(true) + if (!result.success) return + + const [markdownCell, databaseCell, logCell] = result.data.cells + expect(markdownCell).toEqual(FULL_NOTEBOOK.cells[0]) + expect(databaseCell).toEqual({ + _tag: 'database_cell', + id: FULL_NOTEBOOK.cells[1].id, + row_limit: 100, + unchecked_sql: untrustedSql('select * from auth.users limit 100'), + }) + expect(databaseCell).not.toHaveProperty('sql') + expect(logCell).toMatchObject({ + _tag: 'log_cell', + unchecked_sql: untrustedLogSql( + "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10" + ), + }) + expect(logCell).not.toHaveProperty('sql') + }) +}) diff --git a/apps/studio/data/content/notebooks/notebook-schema.ts b/apps/studio/data/content/notebooks/notebook-schema.ts new file mode 100644 index 00000000000..e5ca0c796da --- /dev/null +++ b/apps/studio/data/content/notebooks/notebook-schema.ts @@ -0,0 +1,121 @@ +import { untrustedSql } from '@supabase/pg-meta' +import * as z from 'zod' + +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { isoDateTimeString } from '@/lib/iso-datetime' + +const isoDateTimeSchema = z.string().transform((raw, ctx) => { + const parsed = isoDateTimeString(raw) + if (parsed === null) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be a valid ISO-8601 datetime' }) + return z.NEVER + } + return parsed +}) + +const chartConfigSchema = z.object({ + x_column: z.string(), + y_column: z.string(), + cumulative: z.boolean(), +}) + +const absoluteTimeRangeSchema = z.object({ + _tag: z.literal('absolute_time_range'), + start: isoDateTimeSchema, + end: isoDateTimeSchema, +}) + +const relativeTimeRangeSchema = z.object({ + _tag: z.literal('relative_time_range'), + unit: z.enum(['minute', 'hour', 'day', 'week', 'month', 'year']), + amount: z.number(), +}) + +const timeRangeSchema = z.discriminatedUnion('_tag', [ + absoluteTimeRangeSchema, + relativeTimeRangeSchema, +]) + +const markdownCellSchema = z.object({ + _tag: z.literal('markdown_cell'), + id: z.string(), + text: z.string(), +}) + +const databaseCellSchema = z.object({ + _tag: z.literal('database_cell'), + id: z.string(), + sql: z.string(), + row_limit: z.number(), + chart: chartConfigSchema.optional(), +}) + +const logCellSchema = z.object({ + _tag: z.literal('log_cell'), + id: z.string(), + sql: z.string(), + time_range: timeRangeSchema, + chart: chartConfigSchema.optional(), +}) + +const cellSchema = z.discriminatedUnion('_tag', [ + markdownCellSchema, + databaseCellSchema, + logCellSchema, +]) + +// The wire shape: every notebook fetched from the API has this shape, with backend- +// generated cell `id`s and plaintext `sql`. +export const notebookSchema = z.object({ + schema_version: z.literal(1), + cells: z.array(cellSchema), +}) + +export type NotebookWire = z.infer +export type CellWire = z.infer + +// Agents have restrictions on writing IDs to preserve guarantees about ID +// uniqueness. +export const agentCellSchema = z.discriminatedUnion('_tag', [ + markdownCellSchema.omit({ id: true }).strict(), + databaseCellSchema.omit({ id: true }).strict(), + logCellSchema.omit({ id: true }).strict(), +]) + +export const agentNotebookSchema = z.object({ + schema_version: z.literal(1), + cells: z.array(agentCellSchema), +}) + +export type AgentNotebook = z.infer +export type AgentCell = z.infer + +// The domain shape: parses the same wire cell (`cellSchema`) and transforms `sql` into a +// branded `unchecked_sql`. +const cellDomainSchema = cellSchema.transform((cell) => { + switch (cell._tag) { + case 'markdown_cell': + return cell + case 'database_cell': { + const { sql, ...rest } = cell + return { ...rest, unchecked_sql: untrustedSql(sql) } + } + case 'log_cell': { + const { sql, ...rest } = cell + return { ...rest, unchecked_sql: untrustedLogSql(sql) } + } + } +}) + +export const notebookDomainSchema = z.object({ + schema_version: z.literal(1), + cells: z.array(cellDomainSchema), +}) + +export type NotebookContent = z.infer +export type Cell = z.infer +export type MarkdownCell = Extract +export type DatabaseCell = Extract +export type LogCell = Extract +export type TimeRange = z.infer +export type ChartConfig = z.infer diff --git a/apps/studio/data/content/sql-snippets-query.ts b/apps/studio/data/content/sql-snippets-query.ts index ef4aac6673c..ec554a92b0c 100644 --- a/apps/studio/data/content/sql-snippets-query.ts +++ b/apps/studio/data/content/sql-snippets-query.ts @@ -9,9 +9,14 @@ import type { UseCustomInfiniteQueryOptions } from '@/types' export type SqlSnippet = Extract +/** The snippet content types this query can list. Defaults to `'sql'`; the nav's + * Logs section passes `'log_sql'` to list logs snippets through the same shape. */ +type SqlSnippetType = 'sql' | 'log_sql' + interface GetSqlSnippetsVariables { projectRef?: string cursor?: string + type?: SqlSnippetType visibility?: SqlSnippet['visibility'] favorite?: boolean name?: string @@ -19,7 +24,7 @@ interface GetSqlSnippetsVariables { } export async function getSqlSnippets( - { projectRef, cursor, visibility, favorite, name, sort }: GetSqlSnippetsVariables, + { projectRef, cursor, type = 'sql', visibility, favorite, name, sort }: GetSqlSnippetsVariables, signal?: AbortSignal ) { if (typeof projectRef === 'undefined') { @@ -32,7 +37,7 @@ export async function getSqlSnippets( params: { path: { ref: projectRef }, query: { - type: 'sql', + type, cursor, visibility, favorite, @@ -60,7 +65,14 @@ export type SqlSnippetsData = Awaited> export type SqlSnippetsError = unknown export const useSqlSnippetsQuery = ( - { projectRef, sort, name, visibility, favorite }: Omit, + { + projectRef, + type = 'sql', + sort, + name, + visibility, + favorite, + }: Omit, { enabled = true, ...options @@ -73,9 +85,9 @@ export const useSqlSnippetsQuery = ( > = {} ) => useInfiniteQuery({ - queryKey: contentKeys.sqlSnippets(projectRef, { sort, name, visibility, favorite }), + queryKey: contentKeys.sqlSnippets(projectRef, { type, sort, name, visibility, favorite }), queryFn: ({ signal, pageParam: cursor }) => - getSqlSnippets({ projectRef, cursor, sort, name, visibility, favorite }, signal), + getSqlSnippets({ projectRef, cursor, type, sort, name, visibility, favorite }, signal), enabled: enabled && typeof projectRef !== 'undefined', initialPageParam: undefined, getNextPageParam(lastPage) { diff --git a/apps/studio/data/integrations/integrations-vercel-connections-create-mutation.ts b/apps/studio/data/integrations/integrations-vercel-connections-create-mutation.ts index 51238c1daae..88cb907238a 100644 --- a/apps/studio/data/integrations/integrations-vercel-connections-create-mutation.ts +++ b/apps/studio/data/integrations/integrations-vercel-connections-create-mutation.ts @@ -1,5 +1,4 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' import type { IntegrationConnectionsCreateVariables } from './integrations.types' import { integrationKeys } from './keys' @@ -67,11 +66,7 @@ export const useIntegrationVercelConnectionsCreateMutation = ({ }, async onError(data, variables, context) { snapshot.setLoading(false) - if (onError === undefined) { - toast.error(`Failed to create connection: ${data.message}`) - } else { - onError(data, variables, context) - } + await onError?.(data, variables, context) }, ...options, }) diff --git a/apps/studio/data/integrations/vercel-integration-create-mutation.ts b/apps/studio/data/integrations/vercel-integration-create-mutation.ts index 35603b759b5..dc60e11bdc6 100644 --- a/apps/studio/data/integrations/vercel-integration-create-mutation.ts +++ b/apps/studio/data/integrations/vercel-integration-create-mutation.ts @@ -1,5 +1,4 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' import { integrationKeys } from './keys' import { handleError, post } from '@/data/fetchers' @@ -69,11 +68,7 @@ export const useVercelIntegrationCreateMutation = ({ await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to create Vercel integration: ${data.message}`) - } else { - onError(data, variables, context) - } + await onError?.(data, variables, context) }, ...options, }) diff --git a/apps/studio/data/logs/keys.ts b/apps/studio/data/logs/keys.ts index 9aff8c9c58b..f2f8de82ddb 100644 --- a/apps/studio/data/logs/keys.ts +++ b/apps/studio/data/logs/keys.ts @@ -1,6 +1,8 @@ import { QuerySearchParamsType } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.types' export const logsKeys = { + otelLogKeys: (projectRef: string | undefined, source: string | undefined) => + ['projects', projectRef, 'otel-log-keys', source] as const, unifiedLogsInfinite: ( projectRef: string | undefined, searchParams: QuerySearchParamsType | undefined diff --git a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts b/apps/studio/data/logs/logs-sql-rewrite.test.ts similarity index 63% rename from apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts rename to apps/studio/data/logs/logs-sql-rewrite.test.ts index 1b4f0646410..6e7a80c94ad 100644 --- a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts +++ b/apps/studio/data/logs/logs-sql-rewrite.test.ts @@ -1,45 +1,36 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - buildClickhouseRewritePrompt, detectLogSource, looksLikeLegacyLogsQuery, rewriteLogsSqlWithAI, + shouldOfferLegacyLogsRewrite, 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') +describe('shouldOfferLegacyLogsRewrite', () => { + const legacySql = 'select 1 from edge_logs cross join unnest(metadata) as m' + + it('offers the rewrite for BigQuery-dialect SQL once logs run on ClickHouse', () => { + expect(shouldOfferLegacyLogsRewrite({ sql: legacySql, isClickhouseLogsEnabled: true })).toBe( + true + ) }) - 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('never offers it on a non-migrated org, where the BigQuery SQL is still correct', () => { + expect(shouldOfferLegacyLogsRewrite({ sql: legacySql, isClickhouseLogsEnabled: false })).toBe( + false + ) }) - 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') + it('does not offer it for SQL that is already ClickHouse, or for empty SQL', () => { + expect( + shouldOfferLegacyLogsRewrite({ + sql: "select timestamp from logs where source = 'edge_logs' limit 5", + isClickhouseLogsEnabled: true, + }) + ).toBe(false) + expect(shouldOfferLegacyLogsRewrite({ sql: '', isClickhouseLogsEnabled: true })).toBe(false) }) }) @@ -66,6 +57,21 @@ describe('detectLogSource', () => { it('returns undefined when nothing matches', () => { expect(detectLogSource('select 1')).toBeUndefined() }) + + it('ignores a column that merely ends in "source"', () => { + expect(detectLogSource("select 1 from logs where resource = 'nope'")).toBeUndefined() + expect(detectLogSource("select 1 from logs where datasource = 'nope'")).toBeUndefined() + }) + + it('still reads a qualified source column', () => { + expect(detectLogSource("select 1 from logs t where t.source = 'auth_logs'")).toBe('auth_logs') + }) + + it('prefers the real source column over a lookalike earlier in the query', () => { + expect(detectLogSource("select resource = 'nope' from logs where source = 'edge_logs'")).toBe( + 'edge_logs' + ) + }) }) describe('looksLikeLegacyLogsQuery', () => { @@ -112,7 +118,7 @@ describe('rewriteLogsSqlWithAI', () => { vi.unstubAllGlobals() }) - it('posts to the completion endpoint and returns the cleaned query', async () => { + it('declares the rewrite intent and sends the query as the selection', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => '```sql\nselect 1 from logs\n```', @@ -122,6 +128,7 @@ describe('rewriteLogsSqlWithAI', () => { const result = await rewriteLogsSqlWithAI({ sql: 'select 1 from edge_logs', projectRef: 'abc', + availableKeys: ['request.method'], }) expect(result).toBe('select 1 from logs') @@ -129,8 +136,14 @@ describe('rewriteLogsSqlWithAI', () => { expect(url).toContain('/api/ai/code/complete') const body = JSON.parse(init.body) expect(body.dialect).toBe('clickhouse') + expect(body.intent).toBe('rewrite') + // The whole query is the selection, so the rewrite replaces all of it. expect(body.completionMetadata.selection).toBe('select 1 from edge_logs') - expect(body.completionMetadata.prompt.toLowerCase()).toContain('reply with only') + expect(body.completionMetadata.textBeforeCursor).toBe('') + expect(body.completionMetadata.textAfterCursor).toBe('') + expect(body.completionMetadata.availableKeys).toEqual(['request.method']) + // No prompt text is carried client-side — the route owns the instruction. + expect(body.completionMetadata.prompt).toBe('') }) it('throws when the request fails', async () => { diff --git a/apps/studio/data/logs/logs-sql-rewrite.ts b/apps/studio/data/logs/logs-sql-rewrite.ts new file mode 100644 index 00000000000..f9c21d798bd --- /dev/null +++ b/apps/studio/data/logs/logs-sql-rewrite.ts @@ -0,0 +1,113 @@ +import { BASE_PATH } from '@/lib/constants' + +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 = { + pg_cron_logs: 'postgres_logs', +} + +export function detectLogSource(sql: string): string | undefined { + // `\b` so only a standalone `source` column counts — an unanchored match reads + // the value out of `resource = '...'` or `datasource = '...'` too. + const bySource = sql.match(/\bsource\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 +} + +/** + * How long to let the query text settle before re-running the dialect check. + * Shared so every surface offering the rewrite reacts on the same cadence. + */ +export const LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS = 500 + +/** + * Whether to offer the ClickHouse rewrite for a query. Both the flag and the + * dialect check matter: on an org whose logs haven't moved to ClickHouse the + * BigQuery text is still *correct*, so offering to rewrite it would break a + * working query. Callers layer their own dismissal state on top. + */ +export function shouldOfferLegacyLogsRewrite({ + sql, + isClickhouseLogsEnabled, +}: { + sql: string + isClickhouseLogsEnabled: boolean +}): boolean { + return isClickhouseLogsEnabled && looksLikeLegacyLogsQuery(sql) +} + +export interface RewriteLogsSqlArgs { + sql: string + projectRef: string + connectionString?: string | null + orgSlug?: string + authorizationHeader?: string | null + availableKeys?: string[] +} + +/** + * Asks the completion route to rewrite a whole BigQuery logs query as ClickHouse + * SQL. The prompt itself lives server-side (`lib/ai/clickhouse-logs.ts`) — this + * only declares the intent and hands the query over as the selection, the same + * shape an inline edit uses, so exactly one place knows how a completion prompt + * is assembled. + */ +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', + intent: 'rewrite', + orgSlug, + completionMetadata: { + // The whole query is the selection, so the rewrite replaces all of it and + // the route supplies the instruction for the `rewrite` intent. + textBeforeCursor: '', + textAfterCursor: '', + prompt: '', + selection: sql, + availableKeys, + }, + }), + }) + + 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 +} diff --git a/apps/studio/data/logs/otel-log-keys-query.ts b/apps/studio/data/logs/otel-log-keys-query.ts index c4918af0af8..33118f09a4c 100644 --- a/apps/studio/data/logs/otel-log-keys-query.ts +++ b/apps/studio/data/logs/otel-log-keys-query.ts @@ -1,11 +1,14 @@ -import { useQuery } from '@tanstack/react-query' +import { queryOptions, useQuery } from '@tanstack/react-query' import { executeAnalyticsSql } from './execute-analytics-sql' +import { logsKeys } from './keys' import { logsAllEndpointUrl } from './logs-endpoint' import { analyticsLiteral, safeSql } from './safe-analytics-sql' const LOOKBACK_HOURS = 24 * 7 +const KEYS_STALE_TIME = 5 * 60 * 1000 + export async function fetchOtelLogKeys({ projectRef, source, @@ -31,15 +34,31 @@ export async function fetchOtelLogKeys({ return rows.map((r) => r.key).filter(Boolean) } +/** + * Shared by the reactive hook and imperative `queryClient.fetchQuery` callers, so + * a lookup triggered on submit reuses whatever a subscribed component already + * cached for the same source (and vice versa). + */ +export function otelLogKeysQueryOptions({ + projectRef, + source, +}: { + projectRef: string + source: string +}) { + return queryOptions({ + queryKey: logsKeys.otelLogKeys(projectRef, source), + queryFn: ({ signal }) => fetchOtelLogKeys({ projectRef, source, signal }), + staleTime: KEYS_STALE_TIME, + }) +} + 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 }), + ...otelLogKeysQueryOptions({ projectRef: projectRef ?? '', source: source ?? '' }), enabled: enabled && Boolean(projectRef) && Boolean(source), - staleTime: 5 * 60 * 1000, }) } diff --git a/apps/studio/data/organization-members/organization-invitation-accept-mutation.ts b/apps/studio/data/organization-members/organization-invitation-accept-mutation.ts index ccd70b2cd29..017dfc11e2a 100644 --- a/apps/studio/data/organization-members/organization-invitation-accept-mutation.ts +++ b/apps/studio/data/organization-members/organization-invitation-accept-mutation.ts @@ -1,5 +1,4 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' import { handleError, post } from '@/data/fetchers' import { invalidateOrganizationsQuery } from '@/data/organizations/organizations-query' @@ -28,7 +27,6 @@ type OrganizationMemberUpdateData = Awaited> export const useOrganizationLinkAwsMarketplaceMutation = ({ onSuccess, - onError, ...options }: Omit< UseCustomMutationOptions< @@ -41,13 +39,6 @@ export const useOrganizationLinkAwsMarketplaceMutation = ({ async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to link organization to AWS Marketplace: ${data.message}`) - } else { - onError(data, variables, context) - } - }, ...options, }) } diff --git a/apps/studio/data/partners/stripe-projects-confirm-mutation.ts b/apps/studio/data/partners/stripe-projects-confirm-mutation.ts index faf93f77d7f..d1ceef39a4f 100644 --- a/apps/studio/data/partners/stripe-projects-confirm-mutation.ts +++ b/apps/studio/data/partners/stripe-projects-confirm-mutation.ts @@ -1,5 +1,4 @@ import { useMutation } from '@tanstack/react-query' -import { toast } from 'sonner' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -24,7 +23,6 @@ type ConfirmAccountRequestData = Awaited { - const { mutateAsync: stopPipeline } = useStopPipelineMutation() - const { mutateAsync: startPipeline } = useStartPipelineMutation() - - const restartPipeline = async ({ projectRef, pipelineId }: RestartPipelineParams) => { - // Step 1: Stop the pipeline to ensure pods are fully terminated - try { - await stopPipeline({ projectRef, pipelineId }) - } catch (error: any) { - throw new Error(`Failed to stop pipeline: ${error.message}`) - } - - // Step 2: Start the pipeline to create fresh pods with clean state - try { - await startPipeline({ projectRef, pipelineId }) - } catch (error: any) { - throw new Error(`Failed to start pipeline: ${error.message}`) - } - } - - return { restartPipeline } -} diff --git a/apps/studio/data/replication/restart-pipeline-mutation.ts b/apps/studio/data/replication/restart-pipeline-mutation.ts new file mode 100644 index 00000000000..ec6a5d5fa74 --- /dev/null +++ b/apps/studio/data/replication/restart-pipeline-mutation.ts @@ -0,0 +1,75 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { replicationKeys } from './keys' +import { handleError, post } from '@/data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type RestartPipelineParams = { + projectRef: string + pipelineId: number +} + +export async function restartPipeline( + { projectRef, pipelineId }: RestartPipelineParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post( + '/platform/replication/{ref}/pipelines/{pipeline_id}/restart', + { + params: { path: { ref: projectRef, pipeline_id: pipelineId } }, + signal, + } + ) + if (error) { + handleError(error) + } + + return data +} + +type RestartPipelineData = Awaited> + +export const useRestartPipelineMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => restartPipeline(vars), + async onSuccess(data, variables, context) { + const { projectRef, pipelineId } = variables + + await queryClient.invalidateQueries({ + queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), + }) + + // [Joshen] We're manually updating the query client here as the pipeline status is async + // So setting it so starting while letting long poll update the actual status thereafter + queryClient.setQueriesData( + { + queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId), + exact: true, + }, + (prev) => { + if (!prev) return prev + return { ...prev, status: { name: 'starting' } } + } + ) + + await onSuccess?.(data, variables, context) + }, + // No default error toast here: callers already show one from their try/catch around + // mutateAsync, so a default here would double up. onError is only for opt-in callers. + async onError(data, variables, context) { + await onError?.(data, variables, context) + }, + ...options, + }) +} diff --git a/apps/studio/data/reports/report.utils.ts b/apps/studio/data/reports/report.utils.ts index 9c52a8759d4..67f646f33fd 100644 --- a/apps/studio/data/reports/report.utils.ts +++ b/apps/studio/data/reports/report.utils.ts @@ -1,7 +1,7 @@ import { type ComparisonOperator } from '@/components/interfaces/Reports/v2/ReportsNumericFilter' import { AnalyticsInterval } from '@/data/analytics/constants' -import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' +import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' import { safeSql, type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' export type Granularity = 'minute' | 'hour' | 'day' @@ -66,49 +66,19 @@ export const REPORT_STATUS_CODE_COLORS: { [key: string]: { light: string; dark: default: { light: '#757575', dark: '#9E9E9E' }, } -export const useEdgeFnIdToName = ({ projectRef }: { projectRef: string }) => { - const { data: edgeFunctions, isPending: isLoading } = useEdgeFunctionsQuery({ - projectRef, - }) - - function edgeFnIdToName(id: string) { - return edgeFunctions?.find((fn) => fn.id === id)?.name - } - - return { - edgeFnIdToName, - isLoading, - } -} - export async function fetchLogs( projectRef: string, sql: SafeLogSqlFragment, startDate: string, - endDate: string + endDate: string, + useOtel = false ) { return await executeAnalyticsSql({ projectRef, - endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + endpoint: logsAllEndpointUrl(useOtel), sql, iso_timestamp_start: startDate, iso_timestamp_end: endDate, method: 'get', }) } - -export const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = { - '400': { light: '#FFD54F', dark: '#FFF176' }, - '401': { light: '#FF8A65', dark: '#FFAB91' }, - '403': { light: '#FFB74D', dark: '#FFCC80' }, - '404': { light: '#90A4AE', dark: '#B0BEC5' }, - '409': { light: '#BA68C8', dark: '#CE93D8' }, - '410': { light: '#A1887F', dark: '#BCAAA4' }, - '422': { light: '#FF9800', dark: '#FFB74D' }, - '429': { light: '#E65100', dark: '#F57C00' }, - '500': { light: '#B71C1C', dark: '#D32F2F' }, - '502': { light: '#9575CD', dark: '#B39DDB' }, - '503': { light: '#0097A7', dark: '#4DD0E1' }, - '504': { light: '#C0CA33', dark: '#D4E157' }, - default: { light: '#757575', dark: '#9E9E9E' }, -} diff --git a/apps/studio/data/reports/v2/auth.config.otel.test.ts b/apps/studio/data/reports/v2/auth.config.otel.test.ts new file mode 100644 index 00000000000..60ee600de5d --- /dev/null +++ b/apps/studio/data/reports/v2/auth.config.otel.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' + +import { AUTH_REPORT_SQL_OTEL } from './auth.config' + +const sql = (fragment: { toString(): string }) => String(fragment) + +describe('AUTH_REPORT_SQL_OTEL', () => { + it('queries the single OTEL logs table by source, never a per-service table', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h')) + + expect(out).toContain('from logs') + expect(out).toContain("where source = 'auth_logs'") + expect(out).not.toContain('from auth_logs') + expect(out).not.toContain('cross join unnest') + }) + + it('emits 16-digit unix-microsecond timestamps bucketed by granularity', () => { + expect(sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h'))).toContain( + 'toUnixTimestamp(toStartOfHour(timestamp)) * 1000000 as timestamp' + ) + expect(sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1d'))).toContain( + 'toUnixTimestamp(toStartOfDay(timestamp)) * 1000000 as timestamp' + ) + expect(sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('5m'))).toContain( + 'toUnixTimestamp(toStartOfMinute(timestamp)) * 1000000 as timestamp' + ) + }) + + it('groups and orders by the full bucket expression, not the timestamp alias', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h')) + + expect(out).toContain('group by toUnixTimestamp(toStartOfHour(timestamp)) * 1000000') + expect(out).toContain('order by toUnixTimestamp(toStartOfHour(timestamp)) * 1000000 desc') + expect(out).not.toContain('group by timestamp') + expect(out).not.toContain('order by timestamp desc') + }) + + it('reads auth_logs fields from the raw JSON event_message, not BigQuery json_value', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h')) + + expect(out).toContain("JSONExtractString(event_message, 'auth_event', 'action')") + expect(out).toContain( + "count(distinct JSONExtractString(event_message, 'auth_event', 'actor_id')) as count" + ) + expect(out).not.toContain('json_value') + expect(out).not.toContain('timestamp_trunc') + expect(out).not.toContain('count(*)') + }) + + it('groups by provider only when a provider filter is set', () => { + const withProvider = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h', { provider: ['google'] })) + expect(withProvider).toContain( + "coalesce(nullIf(JSONExtractString(event_message, 'provider'), ''), 'unknown') as provider" + ) + expect(withProvider).toContain("JSONExtractString(event_message, 'provider') IN ('google')") + + const withoutProvider = sql(AUTH_REPORT_SQL_OTEL.ActiveUsers('1h')) + expect(withoutProvider).not.toContain('as provider') + }) + + it('translates the sign-in metering predicate and login_type_provider concat', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.SignInAttempts('1h')) + + expect(out).toContain("JSONExtractString(event_message, 'action') = 'login'") + expect(out).toContain("JSONExtractString(event_message, 'metering') = 'true'") + expect(out).toContain('as login_type_provider') + expect(out).toContain('concat(') + }) + + it('uses ClickHouse quantile() for percentiles over the duration field', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.SignInProcessingTimePercentiles('1h')) + + expect(out).toContain( + "round(quantile(0.5)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p50_processing_time_ms" + ) + expect(out).toContain('quantile(0.95)') + expect(out).toContain('quantile(0.99)') + expect(out).not.toContain('approx_quantiles') + }) + + it('filters TotalSignUps/PasswordResetRequests to their respective auth_event actions', () => { + expect(sql(AUTH_REPORT_SQL_OTEL.TotalSignUps('1h'))).toContain( + "JSONExtractString(event_message, 'auth_event', 'action') = 'user_signedup'" + ) + expect(sql(AUTH_REPORT_SQL_OTEL.PasswordResetRequests('1h'))).toContain( + "JSONExtractString(event_message, 'auth_event', 'action') = 'user_recovery_requested'" + ) + }) + + it('uses ClickHouse quantile() for sign-up percentiles over the duration field', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.SignUpProcessingTimePercentiles('1h')) + + expect(out).toContain( + "round(quantile(0.5)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p50_processing_time_ms" + ) + expect(out).toContain('quantile(0.95)') + expect(out).toContain('quantile(0.99)') + expect(out).not.toContain('approx_quantiles') + }) + + it('averages sign-in processing time without percentiles for the basic variant', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.SignInProcessingTimeBasic('1h')) + + expect(out).toContain("JSONExtractString(event_message, 'auth_event', 'action') = 'login'") + expect(out).toContain( + "round(avg(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as avg_processing_time_ms" + ) + expect(out).not.toContain('quantile(') + }) + + it('averages sign-up processing time without percentiles for the basic variant', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.SignUpProcessingTimeBasic('1h')) + + expect(out).toContain( + "round(avg(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as avg_processing_time_ms" + ) + expect(out).not.toContain('quantile(') + }) + + it('reads edge_logs error fields from log_attributes', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.ErrorsByStatus('1h')) + + expect(out).toContain("where source = 'edge_logs'") + expect(out).toContain("log_attributes['request.path'] like '%auth/v1%'") + expect(out).toContain( + "toInt32OrZero(log_attributes['response.status_code']) between 400 and 599" + ) + expect(out).toContain("toInt32OrZero(log_attributes['response.status_code']) as status_code") + }) + + it('selects the x_sb_error_code attribute for the by-code breakdown', () => { + expect(sql(AUTH_REPORT_SQL_OTEL.ErrorsByAuthCode('1h'))).toContain( + "log_attributes['response.headers.x_sb_error_code'] as error_code" + ) + }) + + it('applies the numeric status_code filter to edge_logs error queries', () => { + const out = sql( + AUTH_REPORT_SQL_OTEL.ErrorsByStatus('1h', { status_code: { operator: '>=', value: 500 } }) + ) + expect(out).toContain("AND toInt32OrZero(log_attributes['response.status_code']) >= 500") + }) + + it('ignores a status_code filter on auth_logs-sourced queries (no HTTP response fields there)', () => { + const out = sql( + AUTH_REPORT_SQL_OTEL.ActiveUsers('1h', { status_code: { operator: '>=', value: 500 } }) + ) + expect(out).not.toContain("toInt32OrZero(log_attributes['response.status_code'])") + }) + + it('ignores a provider filter on edge_logs-sourced queries (no auth provider attribute there)', () => { + const out = sql(AUTH_REPORT_SQL_OTEL.ErrorsByStatus('1h', { provider: ['google'] })) + expect(out).not.toContain("JSONExtractString(event_message, 'provider')") + }) +}) diff --git a/apps/studio/data/reports/v2/auth.config.ts b/apps/studio/data/reports/v2/auth.config.ts index 82fcf2490ba..11cfd678b1a 100644 --- a/apps/studio/data/reports/v2/auth.config.ts +++ b/apps/studio/data/reports/v2/auth.config.ts @@ -21,6 +21,7 @@ import { fetchLogs, SAFE_COMPARISON_OPERATOR_SQL, SAFE_GRANULARITY_SQL, + type Granularity, } from '@/data/reports/report.utils' const AUTH_ERROR_CODE_LIST = Object.entries(AUTH_ERROR_CODES).map(([key, value]) => ({ @@ -39,7 +40,7 @@ const METRIC_KEYS = [ 'SignUpProcessingTimePercentiles', 'ErrorsByStatus', 'ErrorsByAuthCode', -] +] as const type MetricKey = (typeof METRIC_KEYS)[number] @@ -48,10 +49,7 @@ type AuthReportFilters = { provider?: string[] | null } -// Static SELECT-clause fragment for the `auth_logs` table. const PROVIDER_SELECT_FRAGMENT = safeSql`COALESCE(JSON_VALUE(event_message, "$.provider"), 'unknown') as provider,` - -// Static SELECT-clause fragment for the aliased `auth_logs f` form. const PROVIDER_SELECT_FRAGMENT_F_ALIAS = safeSql`COALESCE(JSON_VALUE(f.event_message, "$.provider"), 'unknown') as provider,` const PROVIDER_GROUP_BY_FRAGMENT = safeSql`, provider` @@ -66,45 +64,45 @@ function providerGroupBy(groupByProvider: boolean): SafeLogSqlFragment { return groupByProvider ? PROVIDER_GROUP_BY_FRAGMENT : EMPTY } -/** - * Builds an `AND`-prefixed predicate fragment for `auth_logs`-shaped queries. - * Returns the empty fragment when no filters apply. The returned value can be - * spliced directly after a query's existing `WHERE` clause. - */ -function authFiltersToAndPredicates(filters?: AuthReportFilters): SafeLogSqlFragment { - const predicates: SafeLogSqlFragment[] = [] +function authFilterSql(filters?: AuthReportFilters): SafeLogSqlFragment { + const conditions: SafeLogSqlFragment[] = [] if (filters?.status_code) { const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator] - predicates.push( + conditions.push( safeSql`response.status_code ${op} ${analyticsLiteral(filters.status_code.value)}` ) } if (filters?.provider && filters.provider.length > 0) { const list = joinSqlFragments(filters.provider.map(analyticsLiteral), ', ') - predicates.push(safeSql`JSON_VALUE(event_message, "$.provider") IN (${list})`) + conditions.push(safeSql`JSON_VALUE(event_message, "$.provider") IN (${list})`) } - if (predicates.length === 0) return EMPTY - return safeSql`AND ${joinSqlFragments(predicates, ' AND ')}` + if (conditions.length === 0) return EMPTY + return safeSql`AND ${joinSqlFragments(conditions, ' AND ')}` } -/** - * Builds an `AND`-prefixed predicate fragment for `edge_logs`-shaped queries. - */ -function edgeLogsFiltersToAndPredicates(filters?: AuthReportFilters): SafeLogSqlFragment { - const predicates: SafeLogSqlFragment[] = [] +function edgeLogsFilterSql(filters?: AuthReportFilters): SafeLogSqlFragment { + const conditions: SafeLogSqlFragment[] = [] if (filters?.status_code) { const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator] - predicates.push( + conditions.push( safeSql`response.status_code ${op} ${analyticsLiteral(filters.status_code.value)}` ) } - if (predicates.length === 0) return EMPTY - return safeSql`AND ${joinSqlFragments(predicates, ' AND ')}` + if (conditions.length === 0) return EMPTY + return safeSql`AND ${joinSqlFragments(conditions, ' AND ')}` +} + +function authQuerySetup(interval: AnalyticsInterval, filters?: AuthReportFilters) { + return { + granularity: SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)], + filterSql: authFilterSql(filters), + groupByProvider: Boolean(filters?.provider && filters.provider.length > 0), + } } const AUTH_REPORT_SQL: Record< @@ -112,9 +110,7 @@ const AUTH_REPORT_SQL: Record< (interval: AnalyticsInterval, filters?: AuthReportFilters) => SafeLogSqlFragment > = { ActiveUsers: (interval, filters) => { - const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = authFiltersToAndPredicates(filters) - const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const { granularity, filterSql, groupByProvider } = authQuerySetup(interval, filters) return safeSql` --active-users select @@ -126,15 +122,13 @@ const AUTH_REPORT_SQL: Record< 'login', 'user_signedup', 'token_refreshed', 'user_modified', 'user_recovery_requested', 'user_reauthenticate_requested' ) - ${andPredicates} + ${filterSql} group by timestamp${providerGroupBy(groupByProvider)} order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignInAttempts: (interval, filters) => { - const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = authFiltersToAndPredicates(filters) - const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const { granularity, filterSql, groupByProvider } = authQuerySetup(interval, filters) return safeSql` --sign-in-attempts SELECT @@ -157,7 +151,7 @@ const AUTH_REPORT_SQL: Record< WHERE JSON_VALUE(event_message, "$.action") = 'login' AND JSON_VALUE(event_message, "$.metering") = "true" - ${andPredicates} + ${filterSql} GROUP BY timestamp, login_type_provider${providerGroupBy(groupByProvider)} ORDER BY @@ -165,9 +159,7 @@ const AUTH_REPORT_SQL: Record< ` }, PasswordResetRequests: (interval, filters) => { - const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = authFiltersToAndPredicates(filters) - const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const { granularity, filterSql, groupByProvider } = authQuerySetup(interval, filters) return safeSql` --password-reset-requests select @@ -176,15 +168,13 @@ const AUTH_REPORT_SQL: Record< count(*) as count from auth_logs f where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested' - ${andPredicates} + ${filterSql} group by timestamp${providerGroupBy(groupByProvider)} order by timestamp desc${providerGroupBy(groupByProvider)} ` }, TotalSignUps: (interval, filters) => { - const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = authFiltersToAndPredicates(filters) - const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const { granularity, filterSql, groupByProvider } = authQuerySetup(interval, filters) return safeSql` --total-signups select @@ -193,15 +183,13 @@ const AUTH_REPORT_SQL: Record< count(*) as count from auth_logs where json_value(event_message, "$.auth_event.action") = 'user_signedup' - ${andPredicates} + ${filterSql} group by timestamp${providerGroupBy(groupByProvider)} order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignInProcessingTimeBasic: (interval, filters) => { - const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = authFiltersToAndPredicates(filters) - const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const { granularity, filterSql, groupByProvider } = authQuerySetup(interval, filters) return safeSql` --signin-processing-time-basic select @@ -213,15 +201,13 @@ const AUTH_REPORT_SQL: Record< round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'login' - ${andPredicates} + ${filterSql} group by timestamp${providerGroupBy(groupByProvider)} order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignInProcessingTimePercentiles: (interval, filters) => { - const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = authFiltersToAndPredicates(filters) - const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const { granularity, filterSql, groupByProvider } = authQuerySetup(interval, filters) return safeSql` --signin-processing-time-percentiles select @@ -233,15 +219,13 @@ const AUTH_REPORT_SQL: Record< round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'login' - ${andPredicates} + ${filterSql} group by timestamp${providerGroupBy(groupByProvider)} order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignUpProcessingTimeBasic: (interval, filters) => { - const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = authFiltersToAndPredicates(filters) - const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const { granularity, filterSql, groupByProvider } = authQuerySetup(interval, filters) return safeSql` --signup-processing-time-basic select @@ -253,15 +237,13 @@ const AUTH_REPORT_SQL: Record< round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'user_signedup' - ${andPredicates} + ${filterSql} group by timestamp${providerGroupBy(groupByProvider)} order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignUpProcessingTimePercentiles: (interval, filters) => { - const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = authFiltersToAndPredicates(filters) - const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const { granularity, filterSql, groupByProvider } = authQuerySetup(interval, filters) return safeSql` --signup-processing-time-percentiles select @@ -273,14 +255,14 @@ const AUTH_REPORT_SQL: Record< round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'user_signedup' - ${andPredicates} + ${filterSql} group by timestamp${providerGroupBy(groupByProvider)} order by timestamp desc${providerGroupBy(groupByProvider)} ` }, ErrorsByStatus: (interval, filters) => { const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = edgeLogsFiltersToAndPredicates(filters) + const filterSql = edgeLogsFilterSql(filters) return safeSql` --auth-errors-by-status select @@ -294,14 +276,14 @@ const AUTH_REPORT_SQL: Record< cross join unnest(response.headers) as h where path like '%auth/v1%' and response.status_code >= 400 and response.status_code <= 599 - ${andPredicates} + ${filterSql} group by timestamp, status_code order by timestamp desc ` }, ErrorsByAuthCode: (interval, filters) => { const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] - const andPredicates = edgeLogsFiltersToAndPredicates(filters) + const filterSql = edgeLogsFilterSql(filters) return safeSql` --auth-errors-by-code select @@ -315,119 +297,253 @@ const AUTH_REPORT_SQL: Record< cross join unnest(response.headers) as h where path like '%auth/v1%' and response.status_code >= 400 and response.status_code <= 599 - ${andPredicates} + ${filterSql} group by timestamp, error_code order by timestamp desc ` }, } -export const AUTH_ERROR_CODE_VALUES: string[] = [ - 'anonymous_provider_disabled', - 'bad_code_verifier', - 'bad_json', - 'bad_jwt', - 'bad_oauth_callback', - 'bad_oauth_state', - 'captcha_failed', - 'conflict', - 'email_address_invalid', - 'email_address_not_authorized', - 'email_conflict_identity_not_deletable', - 'email_exists', - 'email_not_confirmed', - 'email_provider_disabled', - 'flow_state_expired', - 'flow_state_not_found', - 'hook_payload_invalid_content_type', - 'hook_payload_over_size_limit', - 'hook_timeout', - 'hook_timeout_after_retry', - 'identity_already_exists', - 'identity_not_found', - 'insufficient_aal', - 'invalid_credentials', - 'invite_not_found', - 'manual_linking_disabled', - 'mfa_challenge_expired', - 'mfa_factor_name_conflict', - 'mfa_factor_not_found', - 'mfa_ip_address_mismatch', - 'mfa_phone_enroll_not_enabled', - 'mfa_phone_verify_not_enabled', - 'mfa_totp_enroll_not_enabled', - 'mfa_totp_verify_not_enabled', - 'mfa_verification_failed', - 'mfa_verification_rejected', - 'mfa_verified_factor_exists', - 'mfa_web_authn_enroll_not_enabled', - 'mfa_web_authn_verify_not_enabled', - 'no_authorization', - 'not_admin', - 'oauth_provider_not_supported', - 'otp_disabled', - 'otp_expired', - 'over_email_send_rate_limit', - 'over_request_rate_limit', - 'over_sms_send_rate_limit', - 'phone_exists', - 'phone_not_confirmed', - 'phone_provider_disabled', - 'provider_disabled', - 'provider_email_needs_verification', - 'reauthentication_needed', - 'reauthentication_not_valid', - 'refresh_token_already_used', - 'refresh_token_not_found', - 'request_timeout', - 'same_password', - 'saml_assertion_no_email', - 'saml_assertion_no_user_id', - 'saml_entity_id_mismatch', - 'saml_idp_already_exists', - 'saml_idp_not_found', - 'saml_metadata_fetch_failed', - 'saml_provider_disabled', - 'saml_relay_state_expired', - 'saml_relay_state_not_found', - 'session_expired', - 'session_not_found', - 'signup_disabled', - 'single_identity_not_deletable', - 'sms_send_failed', - 'sso_domain_already_exists', - 'sso_provider_not_found', - 'too_many_enrolled_mfa_factors', - 'unexpected_audience', - 'unexpected_failure', - 'user_already_exists', - 'user_banned', - 'user_not_found', - 'user_sso_managed', - 'validation_failed', - 'weak_password', -] +// fillTimeseries/isUnixMicro expects a 16-digit unix-microsecond timestamp, matching BigQuery's timestamp_trunc. +const OTEL_TIMESTAMP: Record = { + minute: safeSql`toUnixTimestamp(toStartOfMinute(timestamp)) * 1000000`, + hour: safeSql`toUnixTimestamp(toStartOfHour(timestamp)) * 1000000`, + day: safeSql`toUnixTimestamp(toStartOfDay(timestamp)) * 1000000`, +} + +const PROVIDER_SELECT_FRAGMENT_OTEL = safeSql`coalesce(nullIf(JSONExtractString(event_message, 'provider'), ''), 'unknown') as provider,` + +function providerSelectFragmentOtel(groupByProvider: boolean): SafeLogSqlFragment { + return groupByProvider ? PROVIDER_SELECT_FRAGMENT_OTEL : EMPTY +} + +// auth_logs rows have no HTTP response fields, so status_code can't apply here. +function authOtelFilterSql(filters?: AuthReportFilters): SafeLogSqlFragment { + if (filters?.provider && filters.provider.length > 0) { + const list = joinSqlFragments(filters.provider.map(analyticsLiteral), ', ') + return safeSql`AND JSONExtractString(event_message, 'provider') IN (${list})` + } + return EMPTY +} + +// edge_logs rows have no auth provider field, so provider can't apply here. +function edgeLogsOtelFilterSql(filters?: AuthReportFilters): SafeLogSqlFragment { + if (filters?.status_code) { + const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator] + return safeSql`AND toInt32OrZero(log_attributes['response.status_code']) ${op} ${analyticsLiteral(filters.status_code.value)}` + } + return EMPTY +} + +function authOtelQuerySetup(interval: AnalyticsInterval, filters?: AuthReportFilters) { + return { + ts: OTEL_TIMESTAMP[analyticsIntervalToGranularity(interval)], + filterSql: authOtelFilterSql(filters), + groupByProvider: Boolean(filters?.provider && filters.provider.length > 0), + } +} + +export const AUTH_REPORT_SQL_OTEL: Record< + MetricKey, + (interval: AnalyticsInterval, filters?: AuthReportFilters) => SafeLogSqlFragment +> = { + ActiveUsers: (interval, filters) => { + const { ts, filterSql, groupByProvider } = authOtelQuerySetup(interval, filters) + return safeSql` + --active-users (otel) + select + ${ts} as timestamp, + ${providerSelectFragmentOtel(groupByProvider)} + count(distinct JSONExtractString(event_message, 'auth_event', 'actor_id')) as count + from logs + where source = 'auth_logs' + and JSONExtractString(event_message, 'auth_event', 'action') in ( + 'login', 'user_signedup', 'token_refreshed', 'user_modified', + 'user_recovery_requested', 'user_reauthenticate_requested' + ) + ${filterSql} + group by ${ts}${providerGroupBy(groupByProvider)} + order by ${ts} desc${providerGroupBy(groupByProvider)} + ` + }, + SignInAttempts: (interval, filters) => { + const { ts, filterSql, groupByProvider } = authOtelQuerySetup(interval, filters) + return safeSql` + --sign-in-attempts (otel) + select + ${ts} as timestamp, + ${providerSelectFragmentOtel(groupByProvider)} + case + when JSONExtractString(event_message, 'provider') != '' + then concat( + JSONExtractString(event_message, 'login_method'), + ' (', + JSONExtractString(event_message, 'provider'), + ')' + ) + else JSONExtractString(event_message, 'login_method') + end as login_type_provider, + count() as count + from logs + where source = 'auth_logs' + and JSONExtractString(event_message, 'action') = 'login' + and JSONExtractString(event_message, 'metering') = 'true' + ${filterSql} + group by ${ts}, login_type_provider${providerGroupBy(groupByProvider)} + order by ${ts} desc, login_type_provider${providerGroupBy(groupByProvider)} + ` + }, + PasswordResetRequests: (interval, filters) => { + const { ts, filterSql, groupByProvider } = authOtelQuerySetup(interval, filters) + return safeSql` + --password-reset-requests (otel) + select + ${ts} as timestamp, + ${providerSelectFragmentOtel(groupByProvider)} + count() as count + from logs + where source = 'auth_logs' + and JSONExtractString(event_message, 'auth_event', 'action') = 'user_recovery_requested' + ${filterSql} + group by ${ts}${providerGroupBy(groupByProvider)} + order by ${ts} desc${providerGroupBy(groupByProvider)} + ` + }, + TotalSignUps: (interval, filters) => { + const { ts, filterSql, groupByProvider } = authOtelQuerySetup(interval, filters) + return safeSql` + --total-signups (otel) + select + ${ts} as timestamp, + ${providerSelectFragmentOtel(groupByProvider)} + count() as count + from logs + where source = 'auth_logs' + and JSONExtractString(event_message, 'auth_event', 'action') = 'user_signedup' + ${filterSql} + group by ${ts}${providerGroupBy(groupByProvider)} + order by ${ts} desc${providerGroupBy(groupByProvider)} + ` + }, + SignInProcessingTimeBasic: (interval, filters) => { + const { ts, filterSql, groupByProvider } = authOtelQuerySetup(interval, filters) + return safeSql` + --signin-processing-time-basic (otel) + select + ${ts} as timestamp, + ${providerSelectFragmentOtel(groupByProvider)} + count() as count, + round(avg(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as avg_processing_time_ms, + round(min(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as min_processing_time_ms, + round(max(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as max_processing_time_ms + from logs + where source = 'auth_logs' + and JSONExtractString(event_message, 'auth_event', 'action') = 'login' + ${filterSql} + group by ${ts}${providerGroupBy(groupByProvider)} + order by ${ts} desc${providerGroupBy(groupByProvider)} + ` + }, + SignInProcessingTimePercentiles: (interval, filters) => { + const { ts, filterSql, groupByProvider } = authOtelQuerySetup(interval, filters) + return safeSql` + --signin-processing-time-percentiles (otel) + select + ${ts} as timestamp, + ${providerSelectFragmentOtel(groupByProvider)} + count() as count, + round(quantile(0.5)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p50_processing_time_ms, + round(quantile(0.95)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p95_processing_time_ms, + round(quantile(0.99)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p99_processing_time_ms + from logs + where source = 'auth_logs' + and JSONExtractString(event_message, 'auth_event', 'action') = 'login' + ${filterSql} + group by ${ts}${providerGroupBy(groupByProvider)} + order by ${ts} desc${providerGroupBy(groupByProvider)} + ` + }, + SignUpProcessingTimeBasic: (interval, filters) => { + const { ts, filterSql, groupByProvider } = authOtelQuerySetup(interval, filters) + return safeSql` + --signup-processing-time-basic (otel) + select + ${ts} as timestamp, + ${providerSelectFragmentOtel(groupByProvider)} + count() as count, + round(avg(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as avg_processing_time_ms, + round(min(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as min_processing_time_ms, + round(max(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as max_processing_time_ms + from logs + where source = 'auth_logs' + and JSONExtractString(event_message, 'auth_event', 'action') = 'user_signedup' + ${filterSql} + group by ${ts}${providerGroupBy(groupByProvider)} + order by ${ts} desc${providerGroupBy(groupByProvider)} + ` + }, + SignUpProcessingTimePercentiles: (interval, filters) => { + const { ts, filterSql, groupByProvider } = authOtelQuerySetup(interval, filters) + return safeSql` + --signup-processing-time-percentiles (otel) + select + ${ts} as timestamp, + ${providerSelectFragmentOtel(groupByProvider)} + count() as count, + round(quantile(0.5)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p50_processing_time_ms, + round(quantile(0.95)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p95_processing_time_ms, + round(quantile(0.99)(toInt64OrZero(JSONExtractString(event_message, 'duration'))) / 1000000, 2) as p99_processing_time_ms + from logs + where source = 'auth_logs' + and JSONExtractString(event_message, 'auth_event', 'action') = 'user_signedup' + ${filterSql} + group by ${ts}${providerGroupBy(groupByProvider)} + order by ${ts} desc${providerGroupBy(groupByProvider)} + ` + }, + ErrorsByStatus: (interval, filters) => { + const ts = OTEL_TIMESTAMP[analyticsIntervalToGranularity(interval)] + const filterSql = edgeLogsOtelFilterSql(filters) + return safeSql` + --auth-errors-by-status (otel) + select + ${ts} as timestamp, + count() as count, + toInt32OrZero(log_attributes['response.status_code']) as status_code + from logs + where source = 'edge_logs' + and log_attributes['request.path'] like '%auth/v1%' + and toInt32OrZero(log_attributes['response.status_code']) between 400 and 599 + ${filterSql} + group by ${ts}, status_code + order by ${ts} desc + ` + }, + ErrorsByAuthCode: (interval, filters) => { + const ts = OTEL_TIMESTAMP[analyticsIntervalToGranularity(interval)] + const filterSql = edgeLogsOtelFilterSql(filters) + return safeSql` + --auth-errors-by-code (otel) + select + ${ts} as timestamp, + count() as count, + log_attributes['response.headers.x_sb_error_code'] as error_code + from logs + where source = 'edge_logs' + and log_attributes['request.path'] like '%auth/v1%' + and toInt32OrZero(log_attributes['response.status_code']) between 400 and 599 + ${filterSql} + group by ${ts}, error_code + order by ${ts} desc + ` + }, +} -/** - * Transforms raw analytics data into a chart-ready format by ensuring data consistency and completeness. - * - * This function addresses several key requirements for chart rendering: - * 1. Fills missing timestamps with zero values to prevent gaps in charts - * 2. Creates a consistent data structure with `period_start` as the time axis - * 3. Initializes all chart attributes to 0, then populates actual values - * 4. Sorts timestamps chronologically for proper chart ordering - * - * @param rawData - Raw analytics data from backend queries containing timestamp and count fields - * @param attributes - Chart attribute configuration defining what metrics to display - * @returns Formatted data object with consistent time series data and chart attributes - */ export function defaultAuthReportFormatter( rawData: unknown, attributes: ReportDataProviderAttribute[], groupByProvider = false ) { - const chartAttributes = attributes - const rawDataSchema = z.object({ result: z.array( z @@ -441,10 +557,9 @@ export function defaultAuthReportFormatter( const parsedRawData = rawDataSchema.parse(rawData) const result = parsedRawData.result - if (!result) return { data: undefined, chartAttributes } + if (!result) return { data: undefined, chartAttributes: attributes } if (groupByProvider) { - // Group by provider - create separate attributes for each provider const providers = new Set() result.forEach((p: any) => { if (p.provider) { @@ -454,7 +569,7 @@ export function defaultAuthReportFormatter( const providerAttributes: ReportDataProviderAttribute[] = [] providers.forEach((provider) => { - chartAttributes.forEach((attr) => { + attributes.forEach((attr) => { providerAttributes.push({ ...attr, attribute: `${attr.attribute}_${provider}`, @@ -496,20 +611,18 @@ export function defaultAuthReportFormatter( }) return { data, chartAttributes: providerAttributes } } else { - // Original logic for non-provider grouping const timestamps = new Set(result.map((p: any) => String(p.timestamp))) const data = Array.from(timestamps) .sort() .map((timestamp) => { const point: any = { timestamp } - chartAttributes.forEach((attr) => { + attributes.forEach((attr) => { point[attr.attribute] = 0 }) const matchingPoints = result.filter((p: any) => String(p.timestamp) === timestamp) matchingPoints.forEach((p: any) => { - chartAttributes.forEach((attr) => { - // Optional dimension filters used by some reports + attributes.forEach((attr) => { if ('login_type_provider' in (attr as any)) { if (p.login_type_provider !== (attr as any).login_type_provider) return } @@ -531,7 +644,7 @@ export function defaultAuthReportFormatter( }) return point }) - return { data, chartAttributes } + return { data, chartAttributes: attributes } } } @@ -541,14 +654,17 @@ export const createUsageReportConfig = ({ endDate, interval, filters, + useOtel = false, }: { projectRef: string startDate: string endDate: string interval: AnalyticsInterval filters: AuthReportFilters + useOtel?: boolean }): ReportConfig[] => { const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const queries = useOtel ? AUTH_REPORT_SQL_OTEL : AUTH_REPORT_SQL return [ { @@ -568,9 +684,9 @@ export const createUsageReportConfig = ({ { attribute: 'ActiveUsers', provider: 'logs', label: 'Auth Activity', enabled: true }, ] - const sql = AUTH_REPORT_SQL.ActiveUsers(interval, filters) + const sql = queries.ActiveUsers(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) const transformedData = defaultAuthReportFormatter(rawData, attributes, groupByProvider) @@ -624,8 +740,8 @@ export const createUsageReportConfig = ({ }, ] - const sql = AUTH_REPORT_SQL.SignInAttempts(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = queries.SignInAttempts(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) const transformedData = defaultAuthReportFormatter(rawData, attributes, groupByProvider) return { @@ -656,8 +772,8 @@ export const createUsageReportConfig = ({ }, ] - const sql = AUTH_REPORT_SQL.TotalSignUps(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = queries.TotalSignUps(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) const transformedData = defaultAuthReportFormatter(rawData, attributes, groupByProvider) return { @@ -688,8 +804,8 @@ export const createUsageReportConfig = ({ }, ] - const sql = AUTH_REPORT_SQL.PasswordResetRequests(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = queries.PasswordResetRequests(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) const transformedData = defaultAuthReportFormatter(rawData, attributes, groupByProvider) return { @@ -708,72 +824,81 @@ export const createErrorsReportConfig = ({ endDate, interval, filters, + useOtel = false, }: { projectRef: string startDate: string endDate: string interval: AnalyticsInterval filters: AuthReportFilters -}): ReportConfig[] => [ - { - id: 'auth-errors', - label: 'API Gateway Auth Errors', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - titleTooltip: 'The total number of auth errors by status code from the API Gateway.', - dataProvider: async () => { - const sql = AUTH_REPORT_SQL.ErrorsByStatus(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + useOtel?: boolean +}): ReportConfig[] => { + const queries = useOtel ? AUTH_REPORT_SQL_OTEL : AUTH_REPORT_SQL + return [ + { + id: 'auth-errors', + label: 'API Gateway Auth Errors', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of auth errors by status code from the API Gateway.', + dataProvider: async () => { + const sql = queries.ErrorsByStatus(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) - if (!rawData?.result) return { data: [] } + if (!rawData?.result) return { data: [], query: sql } - const statusCodes = extractStatusCodesFromData(rawData.result) - const attributes = generateStatusCodeAttributes(statusCodes) - const data = transformStatusCodeData(rawData.result, statusCodes) + const statusCodes = extractStatusCodesFromData(rawData.result) + const attributes = generateStatusCodeAttributes(statusCodes) + const data = transformStatusCodeData(rawData.result, statusCodes) - return { data, attributes, query: sql } + return { data, attributes, query: sql } + }, }, - }, - { - id: 'auth-errors-by-code', - label: 'Auth Errors by Code', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - titleTooltip: - 'The total number of auth errors by Supabase Auth error code from the API Gateway.', - dataProvider: async () => { - const sql = AUTH_REPORT_SQL.ErrorsByAuthCode(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + { + id: 'auth-errors-by-code', + label: 'Auth Errors by Code', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: + 'The total number of auth errors by Supabase Auth error code from the API Gateway.', + dataProvider: async () => { + const sql = queries.ErrorsByAuthCode(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) - if (!rawData?.result) return { data: [] } + if (!rawData?.result) return { data: [], query: sql } - const categories = rawData.result - .map((r: any) => r.error_code) - .filter((v: any) => v !== null && v !== undefined) - const distinct = Array.from(new Set(categories)).sort() + const rows = z + .array(z.object({ error_code: z.string().nullish() }).catchall(z.unknown())) + .parse(rawData.result) - const attributes = distinct.map((c: string) => ({ - attribute: c, - label: c, - tooltip: AUTH_ERROR_CODE_LIST.find((e) => e.key === c)?.description, - })) + const categories = rows + .map((r) => r.error_code) + .filter((v): v is string => v !== null && v !== undefined) + const distinct = Array.from(new Set(categories)).sort() - const pivoted = transformCategoricalCountData(rawData.result, 'error_code', distinct) + const attributes = distinct.map((c: string) => ({ + attribute: c, + label: c, + tooltip: AUTH_ERROR_CODE_LIST.find((e) => e.key === c)?.description, + })) - return { data: pivoted, attributes, query: sql } + const pivoted = transformCategoricalCountData(rows, 'error_code', distinct) + + return { data: pivoted, attributes, query: sql } + }, }, - }, -] + ] +} export const createLatencyReportConfig = ({ projectRef, @@ -781,14 +906,17 @@ export const createLatencyReportConfig = ({ endDate, interval, filters, + useOtel = false, }: { projectRef: string startDate: string endDate: string interval: AnalyticsInterval filters: AuthReportFilters + useOtel?: boolean }): ReportConfig[] => { const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) + const queries = useOtel ? AUTH_REPORT_SQL_OTEL : AUTH_REPORT_SQL return [ { @@ -820,8 +948,8 @@ export const createLatencyReportConfig = ({ }, ] - const sql = AUTH_REPORT_SQL.SignInProcessingTimeBasic(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = queries.SignInProcessingTimeBasic(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) const transformedData = defaultAuthReportFormatter(rawData, attributes, groupByProvider) return { @@ -862,8 +990,8 @@ export const createLatencyReportConfig = ({ }, ] - const sql = AUTH_REPORT_SQL.SignInProcessingTimePercentiles(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = queries.SignInProcessingTimePercentiles(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) const transformedData = defaultAuthReportFormatter(rawData, attributes, groupByProvider) return { @@ -902,8 +1030,8 @@ export const createLatencyReportConfig = ({ }, ] - const sql = AUTH_REPORT_SQL.SignUpProcessingTimeBasic(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = queries.SignUpProcessingTimeBasic(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) const transformedData = defaultAuthReportFormatter(rawData, attributes, groupByProvider) return { @@ -944,8 +1072,8 @@ export const createLatencyReportConfig = ({ }, ] - const sql = AUTH_REPORT_SQL.SignUpProcessingTimePercentiles(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = queries.SignUpProcessingTimePercentiles(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) const transformedData = defaultAuthReportFormatter(rawData, attributes, groupByProvider) return { @@ -957,21 +1085,3 @@ export const createLatencyReportConfig = ({ }, ] } - -export const createAuthReportConfig = ({ - projectRef, - startDate, - endDate, - interval, - filters, -}: { - projectRef: string - startDate: string - endDate: string - interval: AnalyticsInterval - filters: AuthReportFilters -}): ReportConfig[] => [ - ...createUsageReportConfig({ projectRef, startDate, endDate, interval, filters }), - ...createErrorsReportConfig({ projectRef, startDate, endDate, interval, filters }), - ...createLatencyReportConfig({ projectRef, startDate, endDate, interval, filters }), -] diff --git a/apps/studio/data/reports/v2/edge-functions.config.otel.test.ts b/apps/studio/data/reports/v2/edge-functions.config.otel.test.ts new file mode 100644 index 00000000000..7d53b098f09 --- /dev/null +++ b/apps/studio/data/reports/v2/edge-functions.config.otel.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' + +import { METRIC_SQL_OTEL } from './edge-functions.config' + +const sql = (fragment: { toString(): string }) => String(fragment) + +describe('METRIC_SQL_OTEL', () => { + it('queries the single OTEL logs table by source, never a per-service table', () => { + const out = sql(METRIC_SQL_OTEL.TotalInvocations('1h')) + + expect(out).toContain('from logs') + expect(out).toContain("source = 'function_edge_logs'") + expect(out).not.toContain('from function_edge_logs') + expect(out).not.toContain('cross join unnest') + }) + + it('emits 16-digit unix-microsecond timestamps bucketed by granularity', () => { + expect(sql(METRIC_SQL_OTEL.TotalInvocations('1h'))).toContain( + 'toUnixTimestamp(toStartOfHour(timestamp)) * 1000000 as timestamp' + ) + expect(sql(METRIC_SQL_OTEL.TotalInvocations('1d'))).toContain( + 'toUnixTimestamp(toStartOfDay(timestamp)) * 1000000 as timestamp' + ) + expect(sql(METRIC_SQL_OTEL.TotalInvocations('5m'))).toContain( + 'toUnixTimestamp(toStartOfMinute(timestamp)) * 1000000 as timestamp' + ) + }) + + it('reads function_id from log_attributes for total invocations', () => { + const out = sql(METRIC_SQL_OTEL.TotalInvocations('1h')) + expect(out).toContain("log_attributes['function_id'] as function_id") + expect(out).toContain('count() as count') + }) + + it('reads response status code from log_attributes, coerced to a number', () => { + const out = sql(METRIC_SQL_OTEL.ExecutionStatusCodes('1h')) + expect(out).toContain("toInt32OrZero(log_attributes['response.status_code']) as status_code") + }) + + it('reads region from the response headers attribute and drops empty values', () => { + const out = sql(METRIC_SQL_OTEL.InvocationsByRegion('1h')) + expect(out).toContain("log_attributes['response.headers.x_sb_edge_region'] as region") + expect(out).toContain("log_attributes['response.headers.x_sb_edge_region'] != ''") + }) + + it('averages execution_time_ms from log_attributes, coerced to a float', () => { + const out = sql(METRIC_SQL_OTEL.ExecutionTime('1h')) + expect(out).toContain( + "avg(toFloat64OrZero(log_attributes['execution_time_ms'])) as avg_execution_time" + ) + }) + + it('filters by function_id list when functions filter is set', () => { + const out = sql(METRIC_SQL_OTEL.TotalInvocations('1h', { functions: ['fn-1', 'fn-2'] } as any)) + expect(out).toContain("log_attributes['function_id'] IN ('fn-1','fn-2')") + }) + + it('applies the numeric status_code filter using toInt32OrZero', () => { + const out = sql( + METRIC_SQL_OTEL.ExecutionStatusCodes('1h', { + status_code: { operator: '>=', value: 500 }, + } as any) + ) + expect(out).toContain("AND toInt32OrZero(log_attributes['response.status_code']) >= 500") + }) + + it('applies the numeric execution_time filter using toFloat64OrZero', () => { + const out = sql( + METRIC_SQL_OTEL.ExecutionTime('1h', { + execution_time: { operator: '>=', value: 100 }, + } as any) + ) + expect(out).toContain("AND toFloat64OrZero(log_attributes['execution_time_ms']) >= 100") + }) +}) diff --git a/apps/studio/data/reports/v2/edge-functions.config.ts b/apps/studio/data/reports/v2/edge-functions.config.ts index 9d5d53d5f7c..31bdd62156c 100644 --- a/apps/studio/data/reports/v2/edge-functions.config.ts +++ b/apps/studio/data/reports/v2/edge-functions.config.ts @@ -25,6 +25,7 @@ import { fetchLogs, SAFE_COMPARISON_OPERATOR_SQL, SAFE_GRANULARITY_SQL, + type Granularity, } from '@/data/reports/report.utils' type EdgeFunctionReportFilters = { @@ -168,6 +169,122 @@ order by }, } +// fillTimeseries/isUnixMicro expects a 16-digit unix-microsecond timestamp, matching BigQuery's timestamp_trunc. +const OTEL_TIMESTAMP: Record = { + minute: safeSql`toUnixTimestamp(toStartOfMinute(timestamp)) * 1000000`, + hour: safeSql`toUnixTimestamp(toStartOfHour(timestamp)) * 1000000`, + day: safeSql`toUnixTimestamp(toStartOfDay(timestamp)) * 1000000`, +} + +function filterToWhereClauseOtel(filters?: EdgeFunctionReportFilters): SafeLogSqlFragment { + const whereClauses: SafeLogSqlFragment[] = [safeSql`source = 'function_edge_logs'`] + + if (filters?.functions && filters.functions.length > 0) { + const ids = joinSqlFragments(filters.functions.map(analyticsLiteral), ',') + whereClauses.push(safeSql`log_attributes['function_id'] IN (${ids})`) + } + + if (filters?.status_code) { + const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator] + whereClauses.push( + safeSql`toInt32OrZero(log_attributes['response.status_code']) ${op} ${analyticsLiteral(filters.status_code.value)}` + ) + } + + if (filters?.region && filters.region.length > 0) { + const regions = joinSqlFragments(filters.region.map(analyticsLiteral), ',') + whereClauses.push(safeSql`log_attributes['response.headers.x_sb_edge_region'] IN (${regions})`) + } + + if (filters?.execution_time) { + const op = SAFE_COMPARISON_OPERATOR_SQL[filters.execution_time.operator] + whereClauses.push( + safeSql`toFloat64OrZero(log_attributes['execution_time_ms']) ${op} ${analyticsLiteral(filters.execution_time.value)}` + ) + } + + return safeSql`WHERE ${joinSqlFragments(whereClauses, ' AND ')}` +} + +export const METRIC_SQL_OTEL: Record< + string, + (interval: AnalyticsInterval, filters?: EdgeFunctionReportFilters) => SafeLogSqlFragment +> = { + TotalInvocations: (interval, filters) => { + const ts = OTEL_TIMESTAMP[analyticsIntervalToGranularity(interval)] + const whereClause = filterToWhereClauseOtel(filters) + return safeSql` +--edgefn-report-invocations (otel) +select + ${ts} as timestamp, + log_attributes['function_id'] as function_id, + count() as count +from logs +${whereClause} +group by + timestamp, + function_id +order by + timestamp desc +` + }, + ExecutionStatusCodes: (interval, filters) => { + const ts = OTEL_TIMESTAMP[analyticsIntervalToGranularity(interval)] + const whereClause = filterToWhereClauseOtel(filters) + return safeSql` +--edgefn-report-execution-status-codes (otel) +select + ${ts} as timestamp, + toInt32OrZero(log_attributes['response.status_code']) as status_code, + count() as count +from logs +${whereClause} +group by + timestamp, + status_code +order by + timestamp desc +` + }, + InvocationsByRegion: (interval, filters) => { + const ts = OTEL_TIMESTAMP[analyticsIntervalToGranularity(interval)] + const whereClause = filterToWhereClauseOtel(filters) + return safeSql` +--edgefn-report-invocations-by-region (otel) +select + ${ts} as timestamp, + log_attributes['response.headers.x_sb_edge_region'] as region, + count() as count +from logs +${whereClause} + AND log_attributes['response.headers.x_sb_edge_region'] != '' +group by + timestamp, + region +order by + timestamp desc +` + }, + ExecutionTime: (interval, filters) => { + const ts = OTEL_TIMESTAMP[analyticsIntervalToGranularity(interval)] + const whereClause = filterToWhereClauseOtel(filters) + return safeSql` +--edgefn-report-execution-time (otel) +select + ${ts} as timestamp, + log_attributes['function_id'] as function_id, + avg(toFloat64OrZero(log_attributes['execution_time_ms'])) as avg_execution_time +from logs +${whereClause} +group by + timestamp, + function_id +order by + timestamp desc +` + }, +} + /** * Transforms raw invocation data by normalizing timestamps and adding function names * @param data - Raw data from the database @@ -209,6 +326,7 @@ export const edgeFunctionReports = ({ endDate, interval, filters, + useOtel = false, }: { projectRef: string functions: { id: string; name: string }[] @@ -216,6 +334,7 @@ export const edgeFunctionReports = ({ endDate: string interval: AnalyticsInterval filters: EdgeFunctionReportFilters + useOtel?: boolean }): ReportConfig[] => [ { id: 'total-invocations', @@ -229,8 +348,8 @@ export const edgeFunctionReports = ({ defaultChartStyle: 'line', titleTooltip: 'The total number of edge function invocations over time.', dataProvider: async () => { - const sql = METRIC_SQL.TotalInvocations(interval, filters) - const response = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = (useOtel ? METRIC_SQL_OTEL : METRIC_SQL).TotalInvocations(interval, filters) + const response = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) if (!response?.result) return { data: [] } @@ -260,8 +379,8 @@ export const edgeFunctionReports = ({ defaultChartStyle: 'line', titleTooltip: 'The total number of edge function executions by status code.', dataProvider: async () => { - const sql = METRIC_SQL.ExecutionStatusCodes(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = (useOtel ? METRIC_SQL_OTEL : METRIC_SQL).ExecutionStatusCodes(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) if (!rawData?.result) return { data: [] } @@ -296,8 +415,8 @@ export const edgeFunctionReports = ({ }, format: (value: unknown) => millisecondFormatter(Number(value)), dataProvider: async () => { - const sql = METRIC_SQL.ExecutionTime(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = (useOtel ? METRIC_SQL_OTEL : METRIC_SQL).ExecutionTime(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) if (!rawData?.result) return { data: [] } @@ -354,8 +473,8 @@ export const edgeFunctionReports = ({ entitlement: 'edge_functions', requiredPlan: 'Pro', dataProvider: async () => { - const sql = METRIC_SQL.InvocationsByRegion(interval, filters) - const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const sql = (useOtel ? METRIC_SQL_OTEL : METRIC_SQL).InvocationsByRegion(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate, useOtel) const data = rawData.result?.map((point: any) => ({ ...point, timestamp: isUnixMicro(point.timestamp) diff --git a/apps/studio/data/scoped-access-tokens/keys.ts b/apps/studio/data/scoped-access-tokens/keys.ts index f3dc146c2bf..b1ef15e336e 100644 --- a/apps/studio/data/scoped-access-tokens/keys.ts +++ b/apps/studio/data/scoped-access-tokens/keys.ts @@ -1,4 +1,5 @@ export const scopedAccessTokenKeys = { list: () => ['scoped-access-tokens'] as const, detail: (id: string) => ['scoped-access-tokens', id] as const, + permissions: () => ['scoped-access-token-permissions'], } diff --git a/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts b/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts new file mode 100644 index 00000000000..604cc8d68eb --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts @@ -0,0 +1,271 @@ +import { useQuery } from '@tanstack/react-query' +import { z } from 'zod' + +import { scopedAccessTokenKeys } from './keys' +import { BASE_PATH } from '@/lib/constants' +import { ResponseError } from '@/types' + +/** + * Cross-reference between OpenFGA permission scopes, Management API endpoints, and MCP tools. + */ + +export interface ScopeMapEntry { + endpoints: string[] + mcp_tools: string[] +} + +/* e.g + { + "branching_production_read": { + "endpoints": [ + "GET /v1/branches/{branch_id_or_ref}", + "GET /v1/projects/{ref}/branches", + "GET /v1/projects/{ref}/branches/{name}" + ], + "mcp_tools": [ + "list_branches" + ] + } + } +*/ +export type ScopeMap = Record + +/** A set of scopes that must ALL be granted (conjunctive). */ +export type ScopeGroup = string[] +/** + * Alternative scope groups, mirroring the mgmt-api `x-fga-permissions` semantics: a token satisfies + * the requirement when it holds ALL scopes of at least ONE group — OR between groups, AND within a + * group. `GET /v1/projects/{ref}/branches` is annotated + * `[['branching_development_read'], ['branching_production_read']]`, and either alternative alone + * authorizes the call. + * + * Standard disjunctive-normal-form semantics apply at the edges, so no special cases are needed: + * `[[]]` — one empty group — is satisfied by every token (an empty AND is true), which is how MCP + * tools that make no Management API call are recorded. `[]` — no alternatives at all — is satisfied + * by nobody (an empty OR is false), so an item that somehow loses its groups is hidden rather than + * advertised to everyone. + */ +export type ScopeGroupAlternatives = ScopeGroup[] + +// e.g { 'GET /v1/projects/{ref}/branches': [['branching_development_read'], ['branching_production_read']] } +export type EndpointMap = Record +// e.g { 'deploy_edge_function': [['edge_functions_write']] } +export type McpMap = Record + +export interface PermissionScopeMap { + /** scope id -> the endpoints / MCP tools it (partially) authorizes */ + scopes: ScopeMap + /** endpoint -> alternative scope groups (OR between groups, AND within a group) */ + endpoints: EndpointMap + /** MCP tool -> alternative scope groups (OR between groups, AND within a group) */ + mcp_tools: McpMap +} + +export interface EnabledEndpoint { + /** HTTP method, e.g. "GET" */ + method: string + /** Path, e.g. "/v1/projects/{ref}" */ + path: string + /** The raw "METHOD /path" key */ + raw: string +} + +const splitEndpoint = (raw: string): EnabledEndpoint => { + const spaceIndex = raw.indexOf(' ') + if (spaceIndex === -1) return { method: '', path: raw, raw } + return { method: raw.slice(0, spaceIndex), path: raw.slice(spaceIndex + 1), raw } +} + +// Plain disjunctive-normal-form evaluation: some group where every scope is granted. An empty +// group is vacuously satisfied, which is exactly what "nothing gates this" should mean. +const isSatisfied = (groups: ScopeGroupAlternatives, granted: Set) => + groups.some((group) => group.every((scope) => granted.has(scope))) + +/** + * Given the set of granted scope ids, returns the Management API endpoints the token can call. + * An endpoint is enabled when ALL scopes of at least ONE of its alternative groups are granted, + * matching the `x-fga-permissions` contract described on `ScopeGroupAlternatives`. + */ +export const getEnabledEndpoints = ({ + grantedScopes, + permissionScopeMap, +}: { + grantedScopes: Iterable + permissionScopeMap: PermissionScopeMap | undefined +}): EnabledEndpoint[] => { + if (permissionScopeMap == null) return [] + + const granted = new Set(grantedScopes) + return Object.entries(permissionScopeMap.endpoints) + .filter(([, groups]) => isSatisfied(groups, granted)) + .map(([raw]) => splitEndpoint(raw)) +} + +/** + * Given the set of granted scope ids, returns the MCP tools the token can call: those with at least + * one fully-granted scope group. Ungated tools — recorded as one empty group (`[[]]`), see + * ScopeGroupAlternatives — are vacuously satisfied and so reported for every token. A tool with no + * alternatives at all (`[]`) is reported for nobody. + */ +export const getEnabledMcpTools = ({ + grantedScopes, + permissionScopeMap, +}: { + grantedScopes: Iterable + permissionScopeMap: PermissionScopeMap | undefined +}): string[] => { + if (permissionScopeMap == null) return [] + + const granted = new Set(grantedScopes) + return Object.entries(permissionScopeMap.mcp_tools) + .filter(([, groups]) => isSatisfied(groups, granted)) + .map(([tool]) => tool) +} + +/** + * Endpoints that are enabled by the complete granted-scope set AND owe that to `capabilityScopes`: + * some fully-granted group must contain at least one capability scope. Used by the review step to + * group enabled endpoints under the capability that contributes them (a multi-scope group only + * appears once all its scopes are granted, and shows under each contributing capability). + * + * An endpoint enabled purely through a group that holds none of `capabilityScopes` is not + * attributed to this capability, and an ungated item is never attributed to any capability. + */ +export const getEnabledEndpointsForCapability = ({ + capabilityScopes, + allGrantedScopes, + permissionScopeMap, +}: { + capabilityScopes: Iterable + allGrantedScopes: Iterable + permissionScopeMap: PermissionScopeMap | undefined +}): EnabledEndpoint[] => { + if (permissionScopeMap == null) return [] + + const granted = new Set(allGrantedScopes) + const capability = new Set(capabilityScopes) + return Object.entries(permissionScopeMap.endpoints) + .filter(([, groups]) => + groups.some( + (group) => + group.some((scope) => capability.has(scope)) && group.every((scope) => granted.has(scope)) + ) + ) + .map(([raw]) => splitEndpoint(raw)) +} + +/** + * Informational lookup for the per-permission risk tooltip: the MCP tools associated with any of + * the given scopes. Unlike getEnabledMcpTools this is not conjunctive — it surfaces every tool that + * lists one of these scopes, so users can see what a capability relates to before granting it. + */ +export const getMcpToolsForScopes = ({ + scopeIds, + permissionScopeMap, +}: { + scopeIds: Iterable + permissionScopeMap: PermissionScopeMap | undefined +}): string[] => { + if (permissionScopeMap == null) return [] + + const tools = new Set() + for (const id of scopeIds) { + permissionScopeMap.scopes[id]?.mcp_tools.forEach((tool) => tools.add(tool)) + } + return Array.from(tools) +} + +/** + * The endpoint's payload changed endpoint/tool requirements from a flat conjunctive scope list + * (string[]) to alternative groups (string[][], ScopeGroupAlternatives) at the same URL, and the + * response is CDN-cached (s-maxage + stale-while-revalidate). Right after a deploy a new client + * can still receive a stale flat payload; evaluating it as groups would call group.every on a + * string and crash the render. Interpret a flat list as what it was — a single conjunctive group. + */ +const isScopeGroup = (value: unknown): value is ScopeGroup => + Array.isArray(value) && value.every((scope) => typeof scope === 'string') + +const normalizeGroups = (groups: unknown): ScopeGroupAlternatives => { + // A non-array value is not a shape any server ever emitted — fail closed (nobody) rather + // than crash the defense itself. + if (!Array.isArray(groups)) return [] + if (groups.every(isScopeGroup)) return groups + if (groups.every((scope): scope is string => typeof scope === 'string')) return [groups] + return [] +} + +const scopeMapEntrySchema = z.object({ + endpoints: z.array(z.string()), + mcp_tools: z.array(z.string()), +}) + +// Group values stay unknown here — normalizeGroups upgrades legacy flat payloads and fails closed +// per entry, which a strict schema would turn into all-or-nothing. `.catch({})` empties a field +// whose record shape is wrong without discarding the salvageable rest of the payload. +const rawPermissionScopeMapSchema = z.object({ + scopes: z.record(scopeMapEntrySchema).catch({}), + endpoints: z.record(z.unknown()).catch({}), + mcp_tools: z.record(z.unknown()).catch({}), +}) + +const EMPTY_PERMISSION_SCOPE_MAP: PermissionScopeMap = { scopes: {}, endpoints: {}, mcp_tools: {} } + +export const normalizePermissionScopeMap = (raw: unknown): PermissionScopeMap => { + const parsed = rawPermissionScopeMapSchema.safeParse(raw) + // A non-object body (null, an error string) has nothing salvageable — fail closed (empty map) + // instead of throwing out of the query fn. + if (!parsed.success) return EMPTY_PERMISSION_SCOPE_MAP + + const { scopes, endpoints, mcp_tools } = parsed.data + return { + scopes, + endpoints: Object.fromEntries( + Object.entries(endpoints).map(([endpoint, groups]) => [endpoint, normalizeGroups(groups)]) + ), + mcp_tools: Object.fromEntries( + Object.entries(mcp_tools).map(([tool, groups]) => [tool, normalizeGroups(groups)]) + ), + } +} + +export async function getGetScopedTokenPermissionsForScope(signal?: AbortSignal) { + const response = await fetch(`${BASE_PATH}/api/scoped-access-token-permissions`, { + signal, + method: 'GET', + credentials: 'omit', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('[getScopedAccessTokenPermissions] Failed:', response.status, errorText) + + let retryAfter: number | undefined + const retryAfterHeader = response.headers.get('Retry-After') + if (retryAfterHeader !== null) { + const parsed = Number(retryAfterHeader) + if (Number.isFinite(parsed) && parsed > 0) retryAfter = parsed + } + + throw new ResponseError( + `Failed to fetch scoped access token permissions: ${response.statusText}`, + response.status, + undefined, + retryAfter + ) + } + + const payload: unknown = await response.json() + return normalizePermissionScopeMap(payload) +} + +export type ScopedAccessTokenPermissionsForScopeError = ResponseError + +export const useGetEnabledEndpointsForCapability = () => { + return useQuery({ + queryKey: scopedAccessTokenKeys.permissions(), + queryFn: ({ signal }) => getGetScopedTokenPermissionsForScope(signal), + }) +} diff --git a/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts b/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts index 7ec01cb363c..7e2dc7abf0b 100644 --- a/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts +++ b/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts @@ -26,7 +26,7 @@ export async function createScopedAccessToken({ export type NewScopedAccessToken = components['schemas']['CreateScopedAccessTokenResponse'] -export const useAccessTokenCreateMutation = ({ +export const useScopedAccessTokenCreateMutation = ({ onSuccess, onError, ...options diff --git a/apps/studio/data/ssl-enforcement/ssl-enforcement-update-mutation.ts b/apps/studio/data/ssl-enforcement/ssl-enforcement-update-mutation.ts index 9922f05b490..8e1423debc0 100644 --- a/apps/studio/data/ssl-enforcement/ssl-enforcement-update-mutation.ts +++ b/apps/studio/data/ssl-enforcement/ssl-enforcement-update-mutation.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { sslEnforcementKeys } from './keys' import { handleError, put } from '@/data/fetchers' +import { jitDbAccessKeys } from '@/data/jit-db-access/keys' import type { ResponseError, UseCustomMutationOptions } from '@/types' export type SSLEnforcementUpdateVariables = { @@ -48,6 +49,9 @@ export const useSSLEnforcementUpdateMutation = ({ async onSuccess(data, variables, context) { const { projectRef } = variables await queryClient.invalidateQueries({ queryKey: sslEnforcementKeys.list(projectRef) }) + // JIT DB access can report `unavailableReason: 'ssl_enforcement_required'`, + // so its status needs to be refetched whenever SSL enforcement changes. + await queryClient.invalidateQueries({ queryKey: jitDbAccessKeys.list(projectRef) }) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx b/apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx new file mode 100644 index 00000000000..0dd6b1d4438 --- /dev/null +++ b/apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx @@ -0,0 +1,163 @@ +import { act, waitFor } from '@testing-library/react' +import { delay, http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + INITIAL_LEGACY_LOGS_REWRITE_STATE, + legacyLogsRewriteReducer as reduce, + useLegacyLogsRewrite, + type LegacyLogsRewriteEvent, + type LegacyLogsRewriteState, +} from './useLegacyLogsRewrite' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { API_URL } from '@/lib/constants' +import { mswServer } from '@/tests/lib/msw' +import { renderSqlEditorHook, setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' + +const run = ( + events: LegacyLogsRewriteEvent[], + from: LegacyLogsRewriteState = INITIAL_LEGACY_LOGS_REWRITE_STATE +) => events.reduce(reduce, from) + +const FAILED: LegacyLogsRewriteEvent = { type: 'rewriteFailed', message: 'boom' } + +describe('legacyLogsRewriteReducer', () => { + it('starts out offering the rewrite', () => { + expect(INITIAL_LEGACY_LOGS_REWRITE_STATE).toEqual({ status: 'offered' }) + }) + + it('requesting a rewrite moves to rewriting, and a proposal returns to offered', () => { + expect(run([{ type: 'rewriteRequested' }])).toEqual({ status: 'rewriting' }) + expect(run([{ type: 'rewriteRequested' }, { type: 'rewriteProposed' }])).toEqual({ + status: 'offered', + }) + }) + + it('a failure lands in failed and keeps its message for the UI', () => { + expect(run([{ type: 'rewriteRequested' }, FAILED])).toEqual({ + status: 'failed', + message: 'boom', + }) + }) + + it('a failure is recoverable — the same request retries it', () => { + expect(run([{ type: 'rewriteRequested' }, FAILED, { type: 'rewriteRequested' }])).toEqual({ + status: 'rewriting', + }) + }) + + it('an unchanged response waits for acknowledgement instead of retiring silently', () => { + const noop = run([{ type: 'rewriteRequested' }, { type: 'rewriteNoop' }]) + expect(noop).toEqual({ status: 'noRewriteNeeded' }) + expect(run([{ type: 'dismissed' }], noop)).toEqual({ status: 'dismissed' }) + }) + + it('both outcomes can be dismissed, and neither can be retried into a new outcome', () => { + const failed = run([{ type: 'rewriteRequested' }, FAILED]) + expect(run([{ type: 'dismissed' }], failed)).toEqual({ status: 'dismissed' }) + // noRewriteNeeded only accepts dismissal — no silent retry. + const noop = run([{ type: 'rewriteRequested' }, { type: 'rewriteNoop' }]) + expect(run([{ type: 'rewriteRequested' }], noop)).toEqual({ status: 'noRewriteNeeded' }) + }) + + it('dismissal is terminal — nothing resurrects the offer', () => { + const dismissed = run([{ type: 'dismissed' }]) + expect(dismissed).toEqual({ status: 'dismissed' }) + expect( + run( + [ + { type: 'rewriteRequested' }, + { type: 'rewriteProposed' }, + FAILED, + { type: 'rewriteNoop' }, + ], + dismissed + ) + ).toEqual({ status: 'dismissed' }) + }) + + it('cannot be dismissed mid-rewrite, so a settling request never resurrects it', () => { + expect(run([{ type: 'rewriteRequested' }, { type: 'dismissed' }])).toEqual({ + status: 'rewriting', + }) + }) + + it('ignores events that are invalid for the current state', () => { + // No rewrite in flight to settle. + expect(run([{ type: 'rewriteProposed' }])).toEqual({ status: 'offered' }) + expect(run([{ type: 'rewriteNoop' }])).toEqual({ status: 'offered' }) + expect(run([FAILED])).toEqual({ status: 'offered' }) + // Already rewriting; a second request is a no-op rather than a restart. + expect(run([{ type: 'rewriteRequested' }, { type: 'rewriteRequested' }])).toEqual({ + status: 'rewriting', + }) + }) +}) + +describe('useLegacyLogsRewrite — dismiss', () => { + // No detectable source, so key discovery is skipped and the only outbound + // request is the completion call we control below. + const SQL_WITHOUT_SOURCE = 'select 1 from logs limit 5' + + /** Keeps a requested rewrite in flight for the duration of the test. */ + function stallTheRewrite() { + mswServer.use( + http.post(`${API_URL}/ai/code/complete`, async () => { + await delay(10_000) + return HttpResponse.json('select 1 from logs') + }) + ) + } + + /** + * Exposes the resolved project alongside the hook: `requestRewrite` no-ops + * without a project ref, so tests must wait for that query before asking. + */ + async function renderDismissHarness() { + const onDismissed = vi.fn() + const utils = renderSqlEditorHook(() => { + const { data: project } = useSelectedProjectQuery() + const rewrite = useLegacyLogsRewrite({ + readSql: () => SQL_WITHOUT_SOURCE, + onProposal: vi.fn(), + onDismissed, + }) + return { ...rewrite, projectRef: project?.ref } + }) + await waitFor(() => expect(utils.result.current.projectRef).toBe('default')) + return { ...utils, onDismissed } + } + + beforeEach(() => { + setupSqlEditorMocks() + }) + + it('dismisses from the offer and reports it', async () => { + const { result, onDismissed } = await renderDismissHarness() + + await act(async () => { + result.current.dismiss() + }) + + expect(result.current.state.status).toBe('dismissed') + expect(onDismissed).toHaveBeenCalledTimes(1) + }) + + it('does not report a dismissal the machine rejects mid-rewrite', async () => { + stallTheRewrite() + const { result, onDismissed } = await renderDismissHarness() + + act(() => { + void result.current.requestRewrite() + }) + await waitFor(() => expect(result.current.state.status).toBe('rewriting')) + + await act(async () => { + result.current.dismiss() + }) + + // Persisting this would suppress an offer that's still live. + expect(onDismissed).not.toHaveBeenCalled() + expect(result.current.state.status).toBe('rewriting') + }) +}) diff --git a/apps/studio/hooks/analytics/useLegacyLogsRewrite.ts b/apps/studio/hooks/analytics/useLegacyLogsRewrite.ts new file mode 100644 index 00000000000..9b0ac2aa2a5 --- /dev/null +++ b/apps/studio/hooks/analytics/useLegacyLogsRewrite.ts @@ -0,0 +1,162 @@ +import { useReducer } from 'react' + +import { constructHeaders } from '@/data/fetchers' +import { rewriteLogsSqlWithAI } from '@/data/logs/logs-sql-rewrite' +import { useLogsAttributeKeys } from '@/hooks/analytics/useLogsAttributeKeys' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { getErrorMessage } from '@/lib/get-error-message' + +export type LegacyLogsRewriteState = + | { status: 'offered' } + | { status: 'rewriting' } + | { status: 'failed'; message: string } + | { status: 'noRewriteNeeded' } + | { status: 'dismissed' } + +export type LegacyLogsRewriteEvent = + | { type: 'rewriteRequested' } + | { type: 'rewriteProposed' } + | { type: 'rewriteFailed'; message: string } + | { type: 'rewriteNoop' } + | { type: 'dismissed' } + +export const INITIAL_LEGACY_LOGS_REWRITE_STATE: LegacyLogsRewriteState = { status: 'offered' } + +/** + * The events each state accepts. Anything absent is an invalid transition and + * leaves the state untouched — notably `dismissed` is terminal, and the offer + * can't be dismissed mid-rewrite. + */ +const VALID_EVENTS: { + [S in LegacyLogsRewriteState['status']]: readonly LegacyLogsRewriteEvent['type'][] +} = { + offered: ['rewriteRequested', 'dismissed'], + rewriting: ['rewriteProposed', 'rewriteFailed', 'rewriteNoop'], + // A failure is recoverable: the same Rewrite control retries it. + failed: ['rewriteRequested', 'dismissed'], + noRewriteNeeded: ['dismissed'], + dismissed: [], +} + +function targetState(event: LegacyLogsRewriteEvent): LegacyLogsRewriteState { + switch (event.type) { + case 'rewriteRequested': + return { status: 'rewriting' } + // The proposal is handed to the caller; the offer returns to idle behind it so + // it's ready again if the user discards the proposal. + case 'rewriteProposed': + return { status: 'offered' } + case 'rewriteFailed': + return { status: 'failed', message: event.message } + case 'rewriteNoop': + return { status: 'noRewriteNeeded' } + case 'dismissed': + return { status: 'dismissed' } + } +} + +export function legacyLogsRewriteReducer( + state: LegacyLogsRewriteState, + event: LegacyLogsRewriteEvent +): LegacyLogsRewriteState { + if (!VALID_EVENTS[state.status].includes(event.type)) return state + return targetState(event) +} + +const CHANGED_WHILE_REWRITING_MESSAGE = + 'The query changed while the Assistant was working, so the rewrite no longer matches it.' + +const NO_RESPONSE_MESSAGE = 'The Assistant did not respond. Try again.' + +export type LegacyLogsRewriteProposal = { original: string; modified: string } + +type UseLegacyLogsRewriteArgs = { + /** + * Reads the query to rewrite at the moment the user asks. A callback rather than + * a value so the rewrite operates on exactly what the user sees, not on whatever + * a surface last rendered. + */ + readSql: () => string + /** Receives a rewrite worth reviewing. Each surface routes this to its own diff. */ + onProposal: (proposal: LegacyLogsRewriteProposal) => void + /** + * Called when the offer is dismissed, for surfaces that persist that. The + * machine covers the current session only — a surface that remembers dismissals + * across sessions layers that on top of its own visibility check, since a value + * read from storage isn't available in time to seed the machine. + */ + onDismissed?: () => void +} + +/** + * Owns the BigQuery → ClickHouse rewrite request end to end: key discovery, the + * completion call, the stale-edit guard, no-op detection, and the resulting state. + */ +export function useLegacyLogsRewrite({ + readSql, + onProposal, + onDismissed, +}: UseLegacyLogsRewriteArgs) { + const { data: project } = useSelectedProjectQuery() + const { data: organization } = useSelectedOrganizationQuery() + const projectRef = project?.ref + + const [state, dispatch] = useReducer(legacyLogsRewriteReducer, INITIAL_LEGACY_LOGS_REWRITE_STATE) + + const { fetchAttributeKeys } = useLogsAttributeKeys() + + const requestRewrite = async () => { + if (!projectRef) return console.error('[useLegacyLogsRewrite] Project ref is required') + + const currentSql = readSql() + if (currentSql.trim().length === 0) return + + dispatch({ type: 'rewriteRequested' }) + try { + const [headerData, availableKeys] = await Promise.all([ + constructHeaders(), + fetchAttributeKeys(currentSql), + ]) + const rewritten = await rewriteLogsSqlWithAI({ + sql: currentSql, + projectRef, + connectionString: project?.connectionString, + orgSlug: organization?.slug, + authorizationHeader: headerData.get('Authorization'), + availableKeys, + }) + + // The user may have kept typing while the model worked; a proposal built from + // stale text would clobber those edits when accepted. + if (readSql() !== currentSql) { + dispatch({ type: 'rewriteFailed', message: CHANGED_WHILE_REWRITING_MESSAGE }) + return + } + + // An unchanged response means the query already runs on ClickHouse and the + // dialect heuristic was over-eager. Proposing it would show an empty diff. + if (rewritten.trim() === currentSql.trim()) { + dispatch({ type: 'rewriteNoop' }) + return + } + + onProposal({ original: currentSql, modified: rewritten }) + dispatch({ type: 'rewriteProposed' }) + } catch (error) { + dispatch({ type: 'rewriteFailed', message: getErrorMessage(error, NO_RESPONSE_MESSAGE) }) + } + } + + const dismiss = () => { + const dismissed: LegacyLogsRewriteEvent = { type: 'dismissed' } + // The transition table is the contract, not the UI that happens to disable the + // control: never report a dismissal the machine rejected (mid-rewrite, say), + // or a surface that persists it would suppress an offer that's still live. + if (legacyLogsRewriteReducer(state, dismissed) === state) return + dispatch(dismissed) + onDismissed?.() + } + + return { state, requestRewrite, dismiss } +} diff --git a/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx b/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx new file mode 100644 index 00000000000..8b9577015b1 --- /dev/null +++ b/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx @@ -0,0 +1,124 @@ +import { act, waitFor } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useLogsAttributeKeys } from './useLogsAttributeKeys' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { addAPIMock } from '@/tests/lib/msw' +import { renderSqlEditorHook, setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' + +const OTEL_ENDPOINT = '/platform/projects/:ref/analytics/endpoints/logs.all.otel' + +const queryFor = (source: string) => `select 1 from logs where source = '${source}'` + +/** Records the SQL of every key-discovery request so we can count them. */ +function mockKeyDiscovery({ fails = false }: { fails?: boolean } = {}) { + const requests: string[] = [] + addAPIMock({ + method: 'post', + path: OTEL_ENDPOINT, + response: async ({ request }) => { + const body = (await request.clone().json()) as { sql: string } + requests.push(body.sql) + if (fails) return HttpResponse.json({ message: 'boom' }, { status: 500 }) + return HttpResponse.json({ result: [{ key: 'request.method' }] }) + }, + }) + return requests +} + +/** + * Exposes the resolved project alongside the hook. Discovery needs a project ref, + * so tests must wait for that query before asking — otherwise a lookup no-ops and + * the request counts below would pass for the wrong reason. + */ +function useKeysHarness() { + const { data: project } = useSelectedProjectQuery() + const { fetchAttributeKeys } = useLogsAttributeKeys() + return { projectRef: project?.ref, fetchAttributeKeys } +} + +async function renderReadyHarness() { + const utils = renderSqlEditorHook(useKeysHarness) + await waitFor(() => expect(utils.result.current.projectRef).toBe('default')) + return utils +} + +beforeEach(() => { + setupSqlEditorMocks() +}) + +describe('useLogsAttributeKeys', () => { + it('makes no request until asked', async () => { + const requests = mockKeyDiscovery() + + await renderReadyHarness() + + expect(requests).toHaveLength(0) + }) + + it('returns the discovered keys for the query source when asked', async () => { + const requests = mockKeyDiscovery() + const { result } = await renderReadyHarness() + + let keys: string[] | undefined + await act(async () => { + keys = await result.current.fetchAttributeKeys(queryFor('edge_logs')) + }) + + expect(keys).toEqual(['request.method']) + expect(requests).toHaveLength(1) + expect(requests[0]).toContain("source = 'edge_logs'") + }) + + it('reuses the cached result for a source already looked up', async () => { + const requests = mockKeyDiscovery() + const { result } = await renderReadyHarness() + + await act(async () => { + await result.current.fetchAttributeKeys(queryFor('edge_logs')) + await result.current.fetchAttributeKeys(queryFor('edge_logs')) + }) + + expect(requests).toHaveLength(1) + }) + + it('looks up a different source separately', async () => { + const requests = mockKeyDiscovery() + const { result } = await renderReadyHarness() + + await act(async () => { + await result.current.fetchAttributeKeys(queryFor('edge_logs')) + await result.current.fetchAttributeKeys(queryFor('postgres_logs')) + }) + + expect(requests).toHaveLength(2) + expect(requests[1]).toContain("source = 'postgres_logs'") + }) + + it('resolves undefined without a request when no source is detectable', async () => { + const requests = mockKeyDiscovery() + const { result } = await renderReadyHarness() + + let keys: string[] | undefined + await act(async () => { + keys = await result.current.fetchAttributeKeys('select 1 from logs limit 5') + }) + + expect(keys).toBeUndefined() + expect(requests).toHaveLength(0) + }) + + it('resolves undefined rather than throwing when discovery fails', async () => { + mockKeyDiscovery({ fails: true }) + const { result } = await renderReadyHarness() + + let keys: string[] | undefined + await act(async () => { + keys = await result.current.fetchAttributeKeys(queryFor('edge_logs')) + }) + + // Keys are an enhancement — a failed lookup must not block the caller. + expect(keys).toBeUndefined() + }) +}) diff --git a/apps/studio/hooks/analytics/useLogsAttributeKeys.ts b/apps/studio/hooks/analytics/useLogsAttributeKeys.ts new file mode 100644 index 00000000000..52a20be8985 --- /dev/null +++ b/apps/studio/hooks/analytics/useLogsAttributeKeys.ts @@ -0,0 +1,43 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' + +import { detectLogSource } from '@/data/logs/logs-sql-rewrite' +import { otelLogKeysQueryOptions } from '@/data/logs/otel-log-keys-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +/** + * Looks up the real `log_attributes` keys for whichever source a logs query + * targets. The AI flows pass these along so the model uses exact dotted paths + * instead of inventing them. + * + * Deliberately imperative: discovery aggregates a week of logs, and the source is + * derived from query text the user is editing, so anything reactive fires requests + * for half-typed source names. Fetching at submit time means one request per + * action the user actually took. It still goes through the query client, so a + * result already cached for that source (by an earlier submit, or by a component + * subscribed via `useOtelLogKeysQuery`) is reused rather than refetched. + * + * Keys are an enhancement, never a requirement — a failed or impossible lookup + * resolves to `undefined` and the caller proceeds without them. + */ +export function useLogsAttributeKeys() { + const queryClient = useQueryClient() + const { data: project } = useSelectedProjectQuery() + const projectRef = project?.ref + + const fetchAttributeKeys = useCallback( + async (sql: string): Promise => { + const source = detectLogSource(sql) + if (!projectRef || source === undefined) return undefined + + try { + return await queryClient.fetchQuery(otelLogKeysQueryOptions({ projectRef, source })) + } catch { + return undefined + } + }, + [projectRef, queryClient] + ) + + return { fetchAttributeKeys } +} diff --git a/apps/studio/hooks/analytics/useProjectUsageStats.tsx b/apps/studio/hooks/analytics/useProjectUsageStats.tsx deleted file mode 100644 index e09b4d27dd2..00000000000 --- a/apps/studio/hooks/analytics/useProjectUsageStats.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useMemo } from 'react' - -import { useFillTimeseriesSorted } from './useFillTimeseriesSorted' -import useTimeseriesUnixToIso from './useTimeseriesUnixToIso' -import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants' -import type { - EventChart, - EventChartData, - Filters, - LogsEndpointParams, -} from '@/components/interfaces/Settings/Logs/Logs.types' -import { genChartQuery } from '@/components/interfaces/Settings/Logs/Logs.utils' -import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' - -interface ProjectUsageStatsHookResult { - error: string | Object | null - isLoading: boolean - filters: Filters - params: LogsEndpointParams - eventChartData: EventChartData[] - refresh: () => void -} - -function useProjectUsageStats({ - projectRef, - table, - timestampStart, - timestampEnd, - filterOverride, -}: { - projectRef: string - table: LogsTableName - timestampStart: string - timestampEnd: string - filterOverride?: Filters -}): ProjectUsageStatsHookResult { - const filterOverrideString = JSON.stringify(filterOverride) - const mergedFilters = useMemo( - () => ({ - ...filterOverride, - }), - [filterOverrideString] - ) - - const params: LogsEndpointParams = useMemo(() => { - return { iso_timestamp_start: timestampStart, iso_timestamp_end: timestampEnd } - }, [timestampStart, timestampEnd]) - - const chartQuery = useMemo( - () => genChartQuery(table, params, mergedFilters), - [table, params, mergedFilters] - ) - - const chartQueryKey = useMemo( - () => [ - 'projects', - projectRef, - 'logs-chart', - table, - { - projectRef, - sql: chartQuery, - iso_timestamp_start: timestampStart, - iso_timestamp_end: timestampEnd, - }, - ], - [projectRef, chartQuery, timestampStart, timestampEnd, table] - ) - - const { data: eventChartResponse, refetch: refreshEventChart } = useQuery({ - queryKey: chartQueryKey, - queryFn: async ({ signal }) => { - const data = await executeAnalyticsSql({ - projectRef, - endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', - sql: chartQuery, - iso_timestamp_start: timestampStart, - iso_timestamp_end: timestampEnd, - method: 'get', - signal, - }) - - return data as unknown as EventChart - }, - refetchOnWindowFocus: false, - enabled: typeof projectRef !== 'undefined', - }) - - const normalizedEventChartData = useTimeseriesUnixToIso( - eventChartResponse?.result ?? [], - 'timestamp' - ) - - const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted({ - data: normalizedEventChartData, - timestampKey: 'timestamp', - valueKey: 'count', - defaultValue: 0, - startDate: timestampStart, - endDate: timestampEnd ?? new Date().toISOString(), - }) - - return { - isLoading: !eventChartResponse, - error: eventChartError, - filters: mergedFilters, - params, - eventChartData, - refresh: refreshEventChart, - } -} -export default useProjectUsageStats diff --git a/apps/studio/lib/ai/assistant-context.test.ts b/apps/studio/lib/ai/assistant-context.test.ts new file mode 100644 index 00000000000..8f641d01577 --- /dev/null +++ b/apps/studio/lib/ai/assistant-context.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { buildAssistantContextMessages, NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context' + +const SCHEMAS = 'The available database schema names are: ["public"]' + +describe('buildAssistantContextMessages', () => { + it('describes the project when there is project context', () => { + const messages = buildAssistantContextMessages({ + projectRef: 'abcdefghijklmnopqrst', + chatName: 'Slow queries', + schemasString: SCHEMAS, + }) + + expect(messages).toHaveLength(1) + expect(messages[0].role).toBe('assistant') + expect(messages[0].content).toContain('abcdefghijklmnopqrst') + expect(messages[0].content).toContain(SCHEMAS) + expect(messages[0].content).toContain('Slow queries') + }) + + it('omits the project message when there is nothing to say', () => { + const messages = buildAssistantContextMessages({ + schemasString: NO_SCHEMA_ACCESS_MESSAGE, + }) + + expect(messages).toEqual([]) + }) + + it('adds the support instructions in support mode', () => { + const messages = buildAssistantContextMessages({ + schemasString: NO_SCHEMA_ACCESS_MESSAGE, + supportMode: true, + }) + + expect(messages).toHaveLength(1) + expect(messages[0].content).toContain('escalate_to_human') + }) + + it('adds ClickHouse instructions when the conversation attached a logs query', () => { + const messages = buildAssistantContextMessages({ + projectRef: 'abcdefghijklmnopqrst', + schemasString: SCHEMAS, + includesLogsSnippets: true, + }) + + expect(messages).toHaveLength(2) + const logsContext = messages[1].content + // The dialect rules, so it doesn't answer in Postgres... + expect(logsContext).toContain('ClickHouse') + // ...and the table reference, so it doesn't invent BigQuery-style unnests. + expect(logsContext).toContain('log_attributes') + expect(logsContext).toContain("where source = 'edge_logs'") + }) + + it('adds nothing extra for a database-only conversation', () => { + const messages = buildAssistantContextMessages({ + projectRef: 'abcdefghijklmnopqrst', + schemasString: SCHEMAS, + includesLogsSnippets: false, + }) + + expect(messages).toHaveLength(1) + expect(messages[0].content).not.toContain('ClickHouse') + }) +}) diff --git a/apps/studio/lib/ai/assistant-context.ts b/apps/studio/lib/ai/assistant-context.ts new file mode 100644 index 00000000000..1c4032b77a3 --- /dev/null +++ b/apps/studio/lib/ai/assistant-context.ts @@ -0,0 +1,79 @@ +import { + buildClickhouseLogsSchemaSection, + CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS, +} from '@/lib/ai/clickhouse-logs' + +/** + * Stands in for the schema list when the org hasn't opted into sharing schemas. + * Doubles as a sentinel — "no project context worth sending" is decided by + * comparing against this exact sentence — so the producer and the comparison have + * to agree on it, hence one exported constant instead of copies per call site. + */ +export const NO_SCHEMA_ACCESS_MESSAGE = "You don't have access to any schemas." + +/** + * A request-scoped context message, sent as an assistant turn ahead of the + * conversation. Deliberately NOT part of the system prompt: the system prompt is + * static so Bedrock can cache it, and anything derived from the current project, + * chat, or open editor tab would break that cache. + */ +export type AssistantContextMessage = { role: 'assistant'; content: string } + +/** + * Tells the model that the snippet in the SQL editor targets the logs backend, so + * SQL it writes for that snippet comes back as ClickHouse for the `logs` table + * rather than Postgres. Carries the same dialect rules and table reference the + * inline editor completions use, so there's one description of the logs schema. + */ +function buildLogsSnippetContext(): string { + return [ + "Some SQL snippets are marked with the dialect 'clickhouse', which means they query the Supabase logs backend, not the Postgres database. Any SQL you write, edit, or debug for that snippet must be ClickHouse SQL against the logs table described below — the database schema and the Postgres tools don't apply to it. You can help a user iterate on their ClickHouse SQL query, but you cannot run it for them (the execute_query tool does not run log queries). Postgres SQL is still the right answer for anything else the user asks about their database, or for non-ClickHouse marked queries.", + CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS.trim(), + buildClickhouseLogsSchemaSection().trim(), + ].join('\n\n') +} + +/** + * Assemble the context messages that precede the conversation: what project and + * chat this is, whether it's a support chat, and which backend the open SQL editor + * snippet targets. Pure and per-request — see {@link AssistantContextMessage} for + * why none of this belongs in the system prompt. + */ +export function buildAssistantContextMessages({ + projectRef, + chatName, + schemasString, + supportMode, + includesLogsSnippets, +}: { + projectRef?: string + chatName?: string + schemasString: string + supportMode?: boolean + /** Whether any user message in the conversation attached a logs (ClickHouse) query. */ + includesLogsSnippets?: boolean +}): AssistantContextMessage[] { + const messages: AssistantContextMessage[] = [] + + const hasProjectContext = !!projectRef || !!chatName || schemasString !== NO_SCHEMA_ACCESS_MESSAGE + if (hasProjectContext) { + messages.push({ + role: 'assistant', + content: `The user's current project is ${projectRef || 'unknown'}. Their available schemas are: ${schemasString}. The current chat name is: ${chatName || 'unnamed'}.`, + }) + } + + if (supportMode) { + messages.push({ + role: 'assistant', + content: + 'This is an active support chat. Help the user while they wait for a human agent. Keep guidance practical and concise. If the user asks for a human, or if the issue cannot be safely resolved, call escalate_to_human with a short reason. Only call resolve_support_conversation after the user explicitly confirms the issue is resolved; otherwise keep helping.', + }) + } + + if (includesLogsSnippets) { + messages.push({ role: 'assistant', content: buildLogsSnippetContext() }) + } + + return messages +} diff --git a/apps/studio/lib/ai/assistant-message-metadata.test.ts b/apps/studio/lib/ai/assistant-message-metadata.test.ts new file mode 100644 index 00000000000..2206121e900 --- /dev/null +++ b/apps/studio/lib/ai/assistant-message-metadata.test.ts @@ -0,0 +1,90 @@ +import type { UIMessage } from 'ai' +import { describe, expect, it } from 'vitest' + +import { + assistantMessageMetadataSchema, + messagesIncludeLogsSnippets, +} from '@/lib/ai/assistant-message-metadata' + +function userMessage(id: string, text: string, metadata?: unknown): UIMessage { + return { id, role: 'user', parts: [{ type: 'text', text }], metadata } as UIMessage +} + +function assistantMessage(id: string, text: string): UIMessage { + return { id, role: 'assistant', parts: [{ type: 'text', text }] } as UIMessage +} + +describe('assistantMessageMetadataSchema', () => { + // safeValidateUIMessages applies this schema to EVERY message's metadata, so a + // message with none (i.e. every message written before this field existed) has to + // pass — otherwise an existing conversation 400s on its next turn. + it('accepts a message with no metadata', () => { + expect(assistantMessageMetadataSchema.safeParse(undefined).success).toBe(true) + }) + + it('accepts metadata flagging attached logs queries', () => { + const result = assistantMessageMetadataSchema.safeParse({ containsLogsSnippets: true }) + expect(result.success).toBe(true) + expect(result.data?.containsLogsSnippets).toBe(true) + }) + + it('rejects a non-boolean flag', () => { + expect(assistantMessageMetadataSchema.safeParse({ containsLogsSnippets: 'yes' }).success).toBe( + false + ) + }) +}) + +describe('messagesIncludeLogsSnippets', () => { + it('detects a message that attached a logs query', () => { + expect( + messagesIncludeLogsSnippets([ + userMessage('1', 'show me 500s', { containsLogsSnippets: true }), + ]) + ).toBe(true) + }) + + it('stays true once any earlier message attached a logs query', () => { + expect( + messagesIncludeLogsSnippets([ + userMessage('1', 'show me 500s', { containsLogsSnippets: true }), + assistantMessage('2', 'select ...'), + userMessage('3', 'now count my users', { containsLogsSnippets: false }), + ]) + ).toBe(true) + }) + + it('is false for a conversation that only attached database queries', () => { + expect( + messagesIncludeLogsSnippets([ + userMessage('1', 'count my users', { containsLogsSnippets: false }), + assistantMessage('2', 'select ...'), + ]) + ).toBe(false) + }) + + it('is false when no message carries metadata', () => { + expect(messagesIncludeLogsSnippets([userMessage('1', 'hello')])).toBe(false) + expect(messagesIncludeLogsSnippets([userMessage('1', 'hello', {})])).toBe(false) + expect(messagesIncludeLogsSnippets([])).toBe(false) + }) + + // Only the user states which query they attached; an assistant message must not be + // able to talk the server into a different dialect. + it('ignores metadata on assistant messages', () => { + const assistantWithMetadata = { + id: '1', + role: 'assistant', + parts: [{ type: 'text', text: 'hi' }], + metadata: { containsLogsSnippets: true }, + } as UIMessage + expect(messagesIncludeLogsSnippets([assistantWithMetadata])).toBe(false) + }) + + it('is false rather than throwing on malformed persisted metadata', () => { + expect( + messagesIncludeLogsSnippets([userMessage('1', 'hi', { containsLogsSnippets: 'yes' })]) + ).toBe(false) + expect(messagesIncludeLogsSnippets([userMessage('1', 'hi', 'not an object')])).toBe(false) + }) +}) diff --git a/apps/studio/lib/ai/assistant-message-metadata.ts b/apps/studio/lib/ai/assistant-message-metadata.ts new file mode 100644 index 00000000000..d30af08387e --- /dev/null +++ b/apps/studio/lib/ai/assistant-message-metadata.ts @@ -0,0 +1,30 @@ +import type { UIMessage } from 'ai' +import z from 'zod' + +export const assistantMessageMetadataSchema = z + .object({ + /** + * Whether any query attached to this message is a logs (ClickHouse) query. A boolean + * rather than a single source, because one message can attach several queries and + * only some of them may target the logs backend — the per-attachment dialect is + * carried by each snippet's own fence in the message text. + */ + containsLogsSnippets: z.boolean().optional(), + }) + .optional() + +export type AssistantMessageMetadata = z.infer + +/** + * Whether any user message in the conversation attached a logs query. + * + * Parsed rather than cast — `UIMessage['metadata']` is `unknown`, and metadata can come + * from a chat persisted by an older build. + */ +export function messagesIncludeLogsSnippets(messages: UIMessage[]): boolean { + return messages.some((message) => { + if (message.role !== 'user') return false + const metadata = assistantMessageMetadataSchema.safeParse(message.metadata) + return metadata.success && metadata.data?.containsLogsSnippets === true + }) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts b/apps/studio/lib/ai/clickhouse-logs.ts similarity index 50% rename from apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts rename to apps/studio/lib/ai/clickhouse-logs.ts index ca532f204a4..b50e17e05e2 100644 --- a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts +++ b/apps/studio/lib/ai/clickhouse-logs.ts @@ -1,12 +1,34 @@ -import { BASE_PATH } from '@/lib/constants' +/** + * Everything the model needs to know about the ClickHouse-backed Supabase `logs` + * table, in one place. Both ClickHouse completion flows — the inline "edit this + * query" path and the whole-query BigQuery rewrite — are assembled from these, + * server-side in `pages/api/ai/code/complete.ts`, so there is exactly one + * description of the schema to keep current. + */ -export const LOGS_SCHEMA_REFERENCE = `The logs table (ClickHouse) has these columns: +/** System-prompt half: the dialect rules that hold for every ClickHouse request. */ +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. +- Do not use \`select *\`, this is disallowed by the backend. +` + +/** + * User-message half: the concrete shape of the table. Complements the dialect + * rules above — this is the part that changes when a log source or its fields + * change, and it is the ClickHouse counterpart to the Postgres DDL section. + */ +const CLICKHOUSE_LOGS_COLUMN_REFERENCE = `The logs table 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. +- log_attributes (Map(String, String)): structured per-source fields, read as log_attributes['key'] Sources and their common log_attributes keys: - edge_logs: request.method, request.path, request.search, response.status_code, identifier @@ -17,7 +39,7 @@ Sources and their common log_attributes keys: - 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).` +The editor applies the user's selected time range as a request parameter, so an explicit timestamp filter is usually unnecessary.` function renderAvailableKeys(availableKeys?: string[]): string { if (!availableKeys || availableKeys.length === 0) return '' @@ -26,10 +48,25 @@ function renderAvailableKeys(availableKeys?: string[]): string { ${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: +/** + * The ClickHouse schema section of the user message — the counterpart to + * `buildDatabaseSchemaSection` for Postgres. `availableKeys` are the real + * `log_attributes` keys observed for the query's source, when the caller + * discovered them. + */ +export function buildClickhouseLogsSchemaSection(availableKeys?: string[]): string { + return `${CLICKHOUSE_LOGS_COLUMN_REFERENCE}\n${renderAvailableKeys(availableKeys)}` +} + +/** + * The instruction for the whole-query BigQuery → ClickHouse rewrite. Used in + * place of a user instruction when the request's intent is `rewrite`. + * + * It states that a rewrite is REQUIRED: the system prompt covers writing and + * editing ClickHouse SQL generally, and without an explicit demand here the model + * echoes the input back, which surfaces to the user as an empty diff. + */ +export const CLICKHOUSE_LOGS_REWRITE_INSTRUCTION = `Your task is to REWRITE the selected query. It is BigQuery SQL and will not run on ClickHouse, so returning it unchanged is likely wrong — every rule below that applies must be applied. 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". @@ -56,87 +93,4 @@ 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 = { - 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 -} +limit 100` diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index 9679be6fb9b..f131119b549 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -14,6 +14,7 @@ import { source } from 'common-tags' import type { AssistantEvalInput } from '@/evals/scorer' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' +import { buildAssistantContextMessages, NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context' import { IS_TRACING_ENABLED } from '@/lib/ai/braintrust-logger' import { CHAT_PROMPT, GENERAL_PROMPT, LIMITATIONS_PROMPT, SECURITY_PROMPT } from '@/lib/ai/prompts' import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' @@ -34,6 +35,7 @@ export async function generateAssistantResponse({ userId, orgId, planId, + includesLogsSnippets, systemProviderOptions, providerOptions, requestedModel, @@ -53,6 +55,8 @@ export async function generateAssistantResponse({ userId?: string orgId?: number planId?: string + /** Whether any user message in the conversation attached a logs (ClickHouse) query. */ + includesLogsSnippets?: boolean requestedModel?: string systemProviderOptions?: Record providerOptions?: Record @@ -98,7 +102,7 @@ export async function generateAssistantResponse({ ? shouldTrace ? await traced(async () => getSchemas(), { name: 'getSchemas', type: 'function' }) : await getSchemas() - : "You don't have access to any schemas." + : NO_SCHEMA_ACCESS_MESSAGE // Important: do not use dynamic content in the system prompt or Bedrock will not cache it const system = source` @@ -117,16 +121,6 @@ export async function generateAssistantResponse({ - \`realtime\` — Supabase Realtime ` - const hasProjectContext = - projectRef || chatName || schemasString !== "You don't have access to any schemas." - - const assistantContent = hasProjectContext - ? `The user's current project is ${projectRef || 'unknown'}. Their available schemas are: ${schemasString}. The current chat name is: ${chatName || 'unnamed'}.` - : undefined - const supportAssistantContent = supportMode - ? `This is an active support chat. Help the user while they wait for a human agent. Keep guidance practical and concise. If the user asks for a human, or if the issue cannot be safely resolved, call escalate_to_human with a short reason. Only call resolve_support_conversation after the user explicitly confirms the issue is resolved; otherwise keep helping.` - : undefined - const systemMessage: SystemModelMessage = { role: 'system', content: system, @@ -134,22 +128,13 @@ export async function generateAssistantResponse({ } const coreMessages: ModelMessage[] = [ - ...(assistantContent - ? [ - { - role: 'assistant' as const, - content: assistantContent, - }, - ] - : []), - ...(supportAssistantContent - ? [ - { - role: 'assistant' as const, - content: supportAssistantContent, - }, - ] - : []), + ...buildAssistantContextMessages({ + projectRef, + chatName, + schemasString, + supportMode, + includesLogsSnippets, + }), ...(await convertToModelMessages(messages)), ] diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index 3a74b8b6dcc..81c6f0435f6 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -786,15 +786,6 @@ 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. diff --git a/apps/studio/lib/get-error-message.test.ts b/apps/studio/lib/get-error-message.test.ts index 57f12e24cbd..93cabc4b3ab 100644 --- a/apps/studio/lib/get-error-message.test.ts +++ b/apps/studio/lib/get-error-message.test.ts @@ -3,44 +3,48 @@ import { describe, expect, it } from 'vitest' import { getErrorMessage } from './get-error-message' describe('getErrorMessage', () => { - it('returns null for null', () => { - expect(getErrorMessage(null)).toBe(null) - }) - - it('returns null for undefined', () => { - expect(getErrorMessage(undefined)).toBe(null) - }) - - it('returns the string for string errors', () => { - expect(getErrorMessage('Something went wrong')).toBe('Something went wrong') - expect(getErrorMessage('')).toBe('') - }) - it('returns the message for Error instances', () => { expect(getErrorMessage(new Error('Failed to load'))).toBe('Failed to load') expect(getErrorMessage(new TypeError('Invalid type'))).toBe('Invalid type') }) - it('returns the message property for objects with message', () => { + it('returns a string throw, trimmed', () => { + expect(getErrorMessage('Something went wrong')).toBe('Something went wrong') + expect(getErrorMessage(' boom ')).toBe('boom') + }) + + it('reads a string message off a plain object', () => { expect(getErrorMessage({ message: 'Custom error' })).toBe('Custom error') - expect(getErrorMessage({ message: 123 })).toBe('123') - expect(getErrorMessage({ message: null })).toBe('null') }) - it('converts other types to string', () => { - expect(getErrorMessage(123)).toBe('123') - expect(getErrorMessage(true)).toBe('true') - expect(getErrorMessage(false)).toBe('false') - expect(getErrorMessage({})).toBe('[object Object]') - expect(getErrorMessage([])).toBe('') + it('returns null when there is no usable message', () => { + expect(getErrorMessage(null)).toBe(null) + expect(getErrorMessage(undefined)).toBe(null) + expect(getErrorMessage('')).toBe(null) + expect(getErrorMessage(' ')).toBe(null) + expect(getErrorMessage(123)).toBe(null) + expect(getErrorMessage(true)).toBe(null) + expect(getErrorMessage([])).toBe(null) }) - it('handles objects without message property', () => { - expect(getErrorMessage({ code: 500 })).toBe('[object Object]') - expect(getErrorMessage({ error: 'test' })).toBe('[object Object]') + it('never surfaces a stringified object as the message', () => { + expect(getErrorMessage({})).toBe(null) + expect(getErrorMessage({ code: 500 })).toBe(null) + expect(getErrorMessage({ error: 'test' })).toBe(null) + expect(getErrorMessage({ message: 123 })).toBe(null) + expect(getErrorMessage({ message: null })).toBe(null) + expect(getErrorMessage({ message: { nested: 'error' } })).toBe(null) }) - it('handles nested error objects', () => { - expect(getErrorMessage({ message: { nested: 'error' } })).toBe('[object Object]') + it('returns the fallback instead of null when one is given', () => { + expect(getErrorMessage(null, 'fallback')).toBe('fallback') + expect(getErrorMessage({}, 'fallback')).toBe('fallback') + expect(getErrorMessage({ message: ' ' }, 'fallback')).toBe('fallback') + expect(getErrorMessage(123, 'fallback')).toBe('fallback') + }) + + it('prefers a real message over the fallback', () => { + expect(getErrorMessage(new Error('Rewrite failed'), 'fallback')).toBe('Rewrite failed') + expect(getErrorMessage({ message: 'Bad request' }, 'fallback')).toBe('Bad request') }) }) diff --git a/apps/studio/lib/get-error-message.ts b/apps/studio/lib/get-error-message.ts index 87e7b4ace56..e88851c9992 100644 --- a/apps/studio/lib/get-error-message.ts +++ b/apps/studio/lib/get-error-message.ts @@ -1,12 +1,22 @@ /** - * Extracts a human-readable error message from various error types. + * Extracts a human-readable message from an unknown thrown value, optionally + * falling back when there isn't one. */ -export function getErrorMessage(error: unknown): string | null { - if (error === null || error === undefined) return null - if (typeof error === 'string') return error - if (error instanceof Error) return error.message - if (typeof error === 'object' && error !== null && 'message' in error) { - return String(error.message) +export function getErrorMessage(error: unknown): string | null +export function getErrorMessage(error: unknown, fallback: string): string +export function getErrorMessage(error: unknown, fallback?: string): string | null { + if (typeof error === 'string') { + const trimmed = error.trim() + if (trimmed.length > 0) return trimmed } - return String(error) + + if (typeof error === 'object' && error !== null && 'message' in error) { + const { message } = error + if (typeof message === 'string') { + const trimmed = message.trim() + if (trimmed.length > 0) return trimmed + } + } + + return fallback ?? null } diff --git a/apps/studio/lib/hosted-api-allowlist.ts b/apps/studio/lib/hosted-api-allowlist.ts index 614d863ddc3..8d70221994d 100644 --- a/apps/studio/lib/hosted-api-allowlist.ts +++ b/apps/studio/lib/hosted-api-allowlist.ts @@ -27,6 +27,7 @@ export const HOSTED_SUPPORTED_API_URLS = [ '/api/integrations/stripe-sync', '/content/graphql', '/parse-query', + '/scoped-access-token-permissions', ] // `pathname` must be basePath-relative — Next's `nextUrl.pathname` already is, diff --git a/apps/studio/lib/iso-datetime.test.ts b/apps/studio/lib/iso-datetime.test.ts new file mode 100644 index 00000000000..332782ad9f0 --- /dev/null +++ b/apps/studio/lib/iso-datetime.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' + +import { isoDateTimeString } from './iso-datetime' + +describe('isoDateTimeString', () => { + it('accepts a valid ISO datetime', () => { + const raw = '2025-01-01T12:00:00.000Z' + expect(isoDateTimeString(raw)).toBe(raw) + }) + + it('rejects an empty string', () => { + expect(isoDateTimeString('')).toBeNull() + }) + + it('rejects junk', () => { + expect(isoDateTimeString('not-a-date')).toBeNull() + expect(isoDateTimeString('2025-13-45T99:99:99Z')).toBeNull() + }) +}) diff --git a/apps/studio/lib/iso-datetime.ts b/apps/studio/lib/iso-datetime.ts new file mode 100644 index 00000000000..9153213804d --- /dev/null +++ b/apps/studio/lib/iso-datetime.ts @@ -0,0 +1,19 @@ +import dayjs from 'dayjs' + +/** + * An ISO-8601 datetime proven valid at construction via a dayjs parse. Callers that need to + * carry a datetime through the type system without re-validating it (e.g. absolute log + * ranges, notebook time ranges) use this instead of a raw string, so an unvalidated value + * can never reach execution. + */ +export type IsoDateTimeString = string & { readonly __isoDateTimeBrand: unique symbol } + +/** + * Validate a raw string as an ISO datetime, returning the branded value or null. The sole + * construction site for `IsoDateTimeString` besides a direct `toISOString()` call (which is + * always valid ISO-8601 by construction). + */ +export function isoDateTimeString(raw: string): IsoDateTimeString | null { + if (!raw) return null + return dayjs(raw).isValid() ? (raw as IsoDateTimeString) : null +} diff --git a/apps/studio/package.json b/apps/studio/package.json index 9fbf0c5c4eb..66c215a9681 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -76,7 +76,7 @@ "@supabase/mcp-server-supabase": "^0.7.0", "@supabase/pg-meta": "workspace:*", "@supabase/realtime-js": "catalog:", - "@supabase/shared-types": "0.1.88", + "@supabase/shared-types": "0.1.91", "@supabase/supabase-js": "catalog:", "@tanstack/react-devtools": "^0.10.3", "@tanstack/react-hotkeys": "^0.10.0", diff --git a/apps/studio/pages/account/tokens.tsx b/apps/studio/pages/account/tokens.tsx index f12f55577bf..b8ad06552c4 100644 --- a/apps/studio/pages/account/tokens.tsx +++ b/apps/studio/pages/account/tokens.tsx @@ -1,3 +1,4 @@ +import { useFlag } from 'common' import { ExternalLink, Search } from 'lucide-react' import { useState } from 'react' import { Button } from 'ui' @@ -6,21 +7,26 @@ import { Input } from 'ui-patterns/DataInputs/Input' import { AccessTokenList } from '@/components/interfaces/Account/AccessTokens/AccessTokenList' import { AccessTokenNewBanner } from '@/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner' import { NewTokenButton } from '@/components/interfaces/Account/AccessTokens/Classic/NewTokenButton' +import { MigrationAdmonition } from '@/components/interfaces/Account/AccessTokens/MigrationAdmonition' +import { NewScopedTokenSheet } from '@/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet' import { AccessTokensLayout } from '@/components/layouts/AccessTokens/AccessTokensLayout' import AccountLayout from '@/components/layouts/AccountLayout/AccountLayout' import { AppLayout } from '@/components/layouts/AppLayout/AppLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { NewAccessToken } from '@/data/access-tokens/access-tokens-create-mutation' +import { NewScopedAccessToken } from '@/data/scoped-access-tokens/scoped-access-token-create-mutation' import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' const UserAccessTokens: NextPageWithLayout = () => { - const [newToken, setNewToken] = useState() + const scopedTokensEnabled = useFlag('scopedPAT') + const [newToken, setNewToken] = useState() const [searchString, setSearchString] = useState('') return (
    + {scopedTokensEnabled && } {newToken && ( { CLI docs - + {scopedTokensEnabled ? ( + + ) : ( + + )}
    { onDeleteSuccess={(id) => { if (id === newToken?.id) setNewToken(undefined) }} + scopedTokensEnabled={scopedTokensEnabled} />
    diff --git a/apps/studio/pages/account/tokens/scoped.tsx b/apps/studio/pages/account/tokens/scoped.tsx index 15ac0f3ef11..1f3ee998a49 100644 --- a/apps/studio/pages/account/tokens/scoped.tsx +++ b/apps/studio/pages/account/tokens/scoped.tsx @@ -1,91 +1,13 @@ -import { ExternalLink, Search } from 'lucide-react' -import { useState } from 'react' -import { Button } from 'ui' -import { Input } from 'ui-patterns/DataInputs/Input' +import { useRouter } from 'next/router' +import { useEffect } from 'react' -import { AccessTokenNewBanner } from '@/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner' -import { NewScopedTokenButton } from '@/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton' -import { ScopedTokenList } from '@/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList' -import { AccessTokensLayout } from '@/components/layouts/AccessTokens/AccessTokensLayout' -import AccountLayout from '@/components/layouts/AccountLayout/AccountLayout' -import { AppLayout } from '@/components/layouts/AppLayout/AppLayout' -import { DefaultLayout } from '@/components/layouts/DefaultLayout' -import { NewScopedAccessToken } from '@/data/scoped-access-tokens/scoped-access-token-create-mutation' -import { DOCS_URL } from '@/lib/constants' import type { NextPageWithLayout } from '@/types' const ScopedTokens: NextPageWithLayout = () => { - const [searchString, setSearchString] = useState('') - const [newToken, setNewToken] = useState() - - return ( - -
    - {newToken && ( - setNewToken(undefined)} - getTokenValue={(token) => token.token} - getTokenPermissions={(token) => token.permissions} - /> - )} -
    - } - value={searchString} - onChange={(e) => setSearchString(e.target.value)} - name="search" - id="search" - placeholder="Filter by name" - /> -
    - - - -
    -
    - - { - if (id === newToken?.id) setNewToken(undefined) - }} - /> - -

    - Looking for Private Apps?{' '} - - Go to Organization Settings → Connections → Private Apps - -

    -
    -
    - ) + const router = useRouter() + useEffect(() => { + router.replace('/account/tokens') + }, [router]) } -ScopedTokens.getLayout = (page) => ( - - - {page} - - -) - export default ScopedTokens diff --git a/apps/studio/pages/api/ai/code/complete.ts b/apps/studio/pages/api/ai/code/complete.ts index 326400a381e..84b9eff76b6 100644 --- a/apps/studio/pages/api/ai/code/complete.ts +++ b/apps/studio/pages/api/ai/code/complete.ts @@ -8,10 +8,14 @@ import z from 'zod' import { executeSql } from '@/data/sql/execute-sql-mutation' import { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { getOrgAIDetails } from '@/lib/ai/ai-details' +import { + buildClickhouseLogsSchemaSection, + CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS, + CLICKHOUSE_LOGS_REWRITE_INSTRUCTION, +} from '@/lib/ai/clickhouse-logs' import { getModel } from '@/lib/ai/model' 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, @@ -124,12 +128,24 @@ const requestBodySchema = z.object({ textAfterCursor: z.string(), prompt: z.string(), selection: z.string(), + /** + * The real `log_attributes` keys observed for the query's source, when the + * client discovered them. ClickHouse-only — there is no schema to fetch + * server-side for the logs table the way there is for Postgres DDL. + */ + availableKeys: z.array(z.string()).optional(), }), projectRef: z.string(), connectionString: z.string().nullish(), orgSlug: z.string().optional(), language: z.string().optional(), dialect: z.enum(['postgres', 'clickhouse']).optional(), + /** + * What the caller wants done. `rewrite` swaps the user instruction for the + * canonical BigQuery → ClickHouse rewrite instruction, so the client never has + * to carry prompt text. ClickHouse-only; defaults to `edit`. + */ + intent: z.enum(['edit', 'rewrite']).optional(), }) async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -150,8 +166,10 @@ 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, dialect } = data - const { textBeforeCursor, textAfterCursor, prompt, selection } = completionMetadata + const { completionMetadata, projectRef, connectionString, orgSlug, language, dialect, intent } = + data + const { textBeforeCursor, textAfterCursor, prompt, selection, availableKeys } = + completionMetadata const isClickhouse = dialect === 'clickhouse' const authorization = req.headers.authorization @@ -232,8 +250,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { 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. + You write and edit ClickHouse SQL for the Supabase logs table. + Reply with ONLY the SQL that replaces the block below, keeping the + surrounding query valid: no explanation, no comments, and no markdown code fences. ${CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS} ${SECURITY_PROMPT} ` @@ -243,23 +262,31 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { ${SECURITY_PROMPT} ` - const userMessage = isClickhouse - ? prompt - : source` - ## Database Schema + const schemaSection = isClickhouse + ? { heading: 'Logs Schema', body: buildClickhouseLogsSchemaSection(availableKeys) } + : { + heading: 'Database Schema', + body: buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult }), + } - ${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })} + const instruction = + isClickhouse && intent === 'rewrite' ? CLICKHOUSE_LOGS_REWRITE_INSTRUCTION : prompt - ## Code + const userMessage = source` + ## ${schemaSection.heading} - \`\`\`${language ?? ''} - ${textBeforeCursor}${selection}${textAfterCursor} - \`\`\` + ${schemaSection.body} - ## Instruction + ## Code - ${prompt} - ` + \`\`\`${language ?? ''} + ${textBeforeCursor}${selection}${textAfterCursor} + \`\`\` + + ## Instruction + + ${instruction} + ` // 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 diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 3bb67ff91b1..5535ce02c60 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -8,6 +8,11 @@ import z from 'zod' import { executeSql } from '@/data/sql/execute-sql-mutation' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { getOrgAIDetails, getProjectAIDetails } from '@/lib/ai/ai-details' +import { NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context' +import { + assistantMessageMetadataSchema, + messagesIncludeLogsSnippets, +} from '@/lib/ai/assistant-message-metadata' import { isTracingAllowed } from '@/lib/ai/braintrust-logger' import { generateAssistantResponse } from '@/lib/ai/generate-assistant-response' import { getModel } from '@/lib/ai/model' @@ -100,6 +105,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw const messagesValidation = await safeValidateUIMessages({ messages: rawMessages, + metadataSchema: assistantMessageMetadataSchema, }) if (!messagesValidation.success) { return res.status(400).json({ @@ -109,6 +115,8 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw } const messages = messagesValidation.data + const includesLogsSnippets = messagesIncludeLogsSnippets(messages) + let aiOptInLevel: AiOptInLevel = 'disabled' let hasAccessToAdvanceModel = false let orgHasHipaaAddon: boolean | undefined @@ -203,7 +211,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw return schemas?.length > 0 ? `The available database schema names are: ${JSON.stringify(schemas)}` - : "You don't have access to any schemas." + : NO_SCHEMA_ACCESS_MESSAGE } const result = await generateAssistantResponse({ @@ -224,6 +232,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw userId, orgId, planId, + includesLogsSnippets, requestedModel, systemProviderOptions, abortSignal: abortController.signal, diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 02e1c050ab2..ba81df5aeec 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -1,6 +1,10 @@ import { useParams } from 'common' import Head from 'next/head' -import { useEffect, useState } from 'react' +import Link from 'next/link' +import { useCallback, useEffect, useState } from 'react' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { isVercelUrl } from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils' import { @@ -13,7 +17,6 @@ import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-que import { useIntegrationsQuery } from '@/data/integrations/integrations-query' import { useIntegrationVercelConnectionsCreateMutation } from '@/data/integrations/integrations-vercel-connections-create-mutation' import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-projects-query' -import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { withAuth } from '@/hooks/misc/withAuth' import { buildStudioPageTitle } from '@/lib/page-title' import { useIntegrationInstallationSnapshot } from '@/state/integration-installation' @@ -29,20 +32,26 @@ const VercelIntegration: NextPageWithLayout = () => { const snapshot = useIntegrationInstallationSnapshot() const [newProjectRef, setNewProjectRef] = useState() + const [connectionError, setConnectionError] = useState() + const [isConnecting, setIsConnecting] = useState(false) + const [isConnectionComplete, setIsConnectionComplete] = useState(false) const { data: integrationData } = useIntegrationsQuery() const organizationIntegration = integrationData?.find((x) => x.organization.slug === slug) - const { data: organizationData } = useOrganizationsQuery() - const organization = organizationData?.find((x) => x.slug === slug) - const { data: vercelProjects } = useVercelProjectsQuery( { organization_integration_id: organizationIntegration?.id }, { enabled: organizationIntegration !== undefined } ) // Wait for the new project to be created before creating the connection - const { data, isSuccess } = useProjectSettingsV2Query( + const { + data, + isSuccess, + isError: isProjectSettingsError, + error: projectSettingsError, + refetch: refetchProjectSettings, + } = useProjectSettingsV2Query( { projectRef: newProjectRef }, { enabled: newProjectRef !== undefined, @@ -56,49 +65,117 @@ const VercelIntegration: NextPageWithLayout = () => { const { mutateAsync: createConnections } = useIntegrationVercelConnectionsCreateMutation() - useEffect(() => { - if (!isSuccess) return + const connectProject = useCallback(async () => { + const isReady = (data?.service_api_keys ?? []).length > 0 - const onSuccessFunc = async () => { - const isReady = (data.service_api_keys ?? []).length > 0 + if (!isReady || !organizationIntegration || !foreignProjectId || !newProjectRef) { + return + } - if (!isReady || !organizationIntegration || !foreignProjectId || !newProjectRef) { - return - } + const projectDetails = vercelProjects?.find((x) => x.id === foreignProjectId) + setConnectionError(undefined) + setIsConnecting(true) - const projectDetails = vercelProjects?.find((x) => x.id === foreignProjectId) - - try { - await createConnections({ - organizationIntegrationId: organizationIntegration?.id, - connection: { - foreign_project_id: foreignProjectId, - supabase_project_ref: newProjectRef, - integration_id: '0', - metadata: { - ...projectDetails, - supabaseConfig: { - projectEnvVars: { - write: true, - }, + try { + await createConnections({ + organizationIntegrationId: organizationIntegration.id, + connection: { + foreign_project_id: foreignProjectId, + supabase_project_ref: newProjectRef, + integration_id: '0', + metadata: { + ...projectDetails, + supabaseConfig: { + projectEnvVars: { + write: true, }, }, }, - orgSlug: organization?.slug, - }) - } catch (error) { - console.error('An error occurred during createConnections:', error) - return - } - + }, + orgSlug: slug, + }) snapshot.setLoading(false) - + setIsConnectionComplete(true) if (next && isVercelUrl(next)) window.location.href = next + } catch (error) { + const message = error instanceof Error ? error.message : 'An unknown error occurred' + setConnectionError(message) + } finally { + setIsConnecting(false) + } + }, [ + createConnections, + data?.service_api_keys, + foreignProjectId, + newProjectRef, + next, + organizationIntegration, + snapshot, + slug, + vercelProjects, + ]) + + useEffect(() => { + if (isSuccess && !connectionError && !isConnecting && !isConnectionComplete) { + void connectProject() + } + }, [connectProject, connectionError, isConnecting, isConnectionComplete, isSuccess]) + + const renderContent = () => { + if (newProjectRef === undefined) { + return } - onSuccessFunc() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, isSuccess]) + if (isProjectSettingsError) { + return ( + void refetchProjectSettings()} + /> + ) + } + + if (connectionError) { + return ( + void connectProject()} + /> + ) + } + + if (isConnectionComplete) { + return ( +
    +
    + + +
    +
    + ) + } + + return ( +
    +
    +

    + {isConnecting + ? 'Connecting your project to Vercel' + : 'Waiting for your project to be ready'} +

    + +
    +
    + ) + } return ( <> @@ -113,10 +190,38 @@ const VercelIntegration: NextPageWithLayout = () => { footer={} widthClassName="max-w-2xl" > - + {renderContent()} ) } +export function VercelConnectionError({ + projectRef, + message, + onRetry, +}: { + projectRef: string + message: string + onRetry: () => void +}) { + return ( +
    +
    + +
    +
    + + +
    +
    + ) +} + export default withAuth(VercelIntegration) diff --git a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx index 7febfc533af..ccd04468424 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -2,7 +2,6 @@ import { useParams } from 'common' import { keyBy } from 'lodash' import Head from 'next/head' import { useCallback, useMemo } from 'react' -import { toast } from 'sonner' import { Card, CardContent } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -101,25 +100,29 @@ const VercelChooseProjectPage: NextPageWithLayout = () => { const snapshot = useIntegrationInstallationSnapshot() - const { mutate: createConnections, isPending: isCreatingConnection } = - useIntegrationVercelConnectionsCreateMutation({ - onSuccess() { - if (next && isVercelUrl(next)) { - snapshot.setLoading(false) - window.location.href = next - } - }, - onMutate() { - snapshot.setLoading(true) - }, - onError(error) { + const { + mutate: createConnections, + isPending: isCreatingConnection, + error: createConnectionsError, + reset: resetCreateConnectionsError, + } = useIntegrationVercelConnectionsCreateMutation({ + onSuccess() { + if (next && isVercelUrl(next)) { snapshot.setLoading(false) - toast.error(`Creating connection failed: ${error.message}`) - }, - }) + window.location.href = next + } + }, + onMutate() { + snapshot.setLoading(true) + }, + }) + const actionError = createConnectionsError + ? `Creating connection failed: ${createConnectionsError.message}` + : undefined const onCreateConnections = useCallback( (vars: Parameters[0]) => { + resetCreateConnectionsError() createConnections({ ...vars, connection: { @@ -135,7 +138,7 @@ const VercelChooseProjectPage: NextPageWithLayout = () => { }, }) }, - [createConnections] + [createConnections, resetCreateConnectionsError] ) const showLoadingState = @@ -212,6 +215,8 @@ const VercelChooseProjectPage: NextPageWithLayout = () => { } loadingForeignProjects={isLoadingVercelProjectsData} mode="Vercel" + actionError={actionError} + onSelectionChange={resetCreateConnectionsError} />
    )} diff --git a/apps/studio/pages/integrations/vercel/install.tsx b/apps/studio/pages/integrations/vercel/install.tsx index fb2365a9730..9236e3f3359 100644 --- a/apps/studio/pages/integrations/vercel/install.tsx +++ b/apps/studio/pages/integrations/vercel/install.tsx @@ -3,7 +3,6 @@ import Head from 'next/head' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' import { Badge, Button, @@ -24,7 +23,11 @@ import { VercelIntegrationLogo, } from '@/components/interfaces/Integrations/Vercel/VercelIntegrationInterstitial' import { getHasInstalledObject } from '@/components/layouts/IntegrationsLayout/Integrations.utils' -import { InterstitialAccountRow, InterstitialLayout } from '@/components/layouts/InterstitialLayout' +import { + InterstitialAccountRow, + InterstitialActionError, + InterstitialLayout, +} from '@/components/layouts/InterstitialLayout' import { useIntegrationsQuery } from '@/data/integrations/integrations-query' import { useVercelIntegrationCreateMutation } from '@/data/integrations/vercel-integration-create-mutation' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' @@ -62,6 +65,7 @@ const VercelIntegration: NextPageWithLayout = () => { const { code, configurationId, currentProjectId, externalId, next, teamId, source } = useParams() const [selectedOrg, setSelectedOrg] = useState(null) + const [validationError, setValidationError] = useState() const { username, primaryEmail, avatarUrl } = useProfileNameAndPicture() const displayName = primaryEmail ?? username ?? '' @@ -146,46 +150,56 @@ const VercelIntegration: NextPageWithLayout = () => { break } default: - toast.error( + setValidationError( `Unsupported Vercel installation source: ${source}. Please contact support if this error persists.` ) } } - const { mutate, isPending: isLoadingVercelIntegrationCreateMutation } = - useVercelIntegrationCreateMutation({ - onMutate() { - snapshot.setLoading(true) - }, - onSuccess() { - handleRouteChange() - snapshot.setLoading(false) - }, - onError(error) { - snapshot.setLoading(false) - toast.error(`Creating Vercel integration failed: ${error.message}`) - }, - }) + const { + mutate, + isPending: isLoadingVercelIntegrationCreateMutation, + error: createIntegrationError, + reset: resetCreateIntegrationError, + } = useVercelIntegrationCreateMutation({ + onMutate() { + snapshot.setLoading(true) + }, + onSuccess() { + handleRouteChange() + snapshot.setLoading(false) + }, + onError() { + snapshot.setLoading(false) + }, + }) + const actionError = + validationError ?? + (createIntegrationError + ? `Creating Vercel integration failed: ${createIntegrationError.message}` + : undefined) function onInstall() { + setValidationError(undefined) + resetCreateIntegrationError() const orgSlug = selectedOrg?.slug const isIntegrationInstalled = orgSlug ? installed[orgSlug] : false if (!orgSlug) { - return toast.error('Please select an organization') + return setValidationError('Please select an organization') } if (!code) { - return toast.error('Vercel code missing') + return setValidationError('Vercel code missing') } if (!configurationId) { - return toast.error('Vercel Configuration ID missing') + return setValidationError('Vercel configuration ID missing') } if (!source) { - return toast.error('Vercel Configuration source missing') + return setValidationError('Vercel configuration source missing') } /** @@ -271,7 +285,11 @@ const VercelIntegration: NextPageWithLayout = () => { selectedOrg={selectedOrg} disabled={noOrganizations || dataLoading} installed={installed} - onSelectedOrgChange={setSelectedOrg} + onSelectedOrgChange={(organization) => { + setSelectedOrg(organization) + setValidationError(undefined) + resetCreateIntegrationError() + }} /> {missingParams.length > 0 && ( @@ -311,6 +329,7 @@ const VercelIntegration: NextPageWithLayout = () => { > {selectedOrg && installed[selectedOrg.slug] ? 'Continue' : 'Install integration'} +
    )} diff --git a/apps/studio/pages/partners/stripe/projects/login.tsx b/apps/studio/pages/partners/stripe/projects/login.tsx index dd19e0ca54a..37ae262f3d4 100644 --- a/apps/studio/pages/partners/stripe/projects/login.tsx +++ b/apps/studio/pages/partners/stripe/projects/login.tsx @@ -51,9 +51,7 @@ export const StripeProjectsLoginPage: NextPageWithLayout = () => { isSuccess: isConfirmationSuccess, error: confirmationMutationError, reset: resetConfirmationError, - } = useConfirmAccountRequestMutation({ - onError: () => undefined, - }) + } = useConfirmAccountRequestMutation() const confirmationError = confirmationMutationError ? `Failed to authorize Stripe Projects: ${confirmationMutationError.message}` : undefined diff --git a/apps/studio/pages/project/[ref]/explorer/index.tsx b/apps/studio/pages/project/[ref]/explorer/index.tsx new file mode 100644 index 00000000000..d44d4c1a2b7 --- /dev/null +++ b/apps/studio/pages/project/[ref]/explorer/index.tsx @@ -0,0 +1,16 @@ +import { ExplorerHome } from '@/components/interfaces/Explorer/ExplorerHome' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout' +import type { NextPageWithLayout } from '@/types' + +const ProjectExplorerPage: NextPageWithLayout = () => { + return +} + +ProjectExplorerPage.getLayout = (page) => ( + + {page} + +) + +export default ProjectExplorerPage diff --git a/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx b/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx new file mode 100644 index 00000000000..40dc4b68e8f --- /dev/null +++ b/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx @@ -0,0 +1,16 @@ +import { NotebookEditor } from '@/components/interfaces/Explorer/NotebookEditor' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout' +import type { NextPageWithLayout } from '@/types' + +const NotebookPage: NextPageWithLayout = () => { + return +} + +NotebookPage.getLayout = (page) => ( + + {page} + +) + +export default NotebookPage diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index 4d736497fc4..65153b9cb3c 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -1,5 +1,5 @@ import { useMonaco } from '@monaco-editor/react' -import { useLocalStorage } from '@uidotdev/usehooks' +import { useDebounce, useLocalStorage } from '@uidotdev/usehooks' import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import dayjs from 'dayjs' import type { editor } from 'monaco-editor' @@ -8,11 +8,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' -import { - detectLogSource, - looksLikeLegacyLogsQuery, - rewriteLogsSqlWithAI, -} from '@/components/interfaces/Settings/Logs/logs-sql-rewrite' +import { LegacyLogsRewriteAdmonition } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition' import { EXPLORER_DATEPICKER_HELPERS, getDefaultHelper, @@ -36,7 +32,6 @@ import { buildLogQueryParams, resolveLogDateRange, } from '@/components/interfaces/Settings/Logs/logsDateRange' -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' @@ -52,16 +47,20 @@ import { UpsertContentPayload, useContentUpsertMutation, } from '@/data/content/content-upsert-mutation' -import { constructHeaders } from '@/data/fetchers' -import { fetchOtelLogKeys } from '@/data/logs/otel-log-keys-query' +import { + LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS, + shouldOfferLegacyLogsRewrite, +} from '@/data/logs/logs-sql-rewrite' import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { + useLegacyLogsRewrite, + type LegacyLogsRewriteProposal, +} from '@/hooks/analytics/useLegacyLogsRewrite' 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' @@ -101,9 +100,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const useOtelEndpoint = useFlag('otelLegacyLogs') const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template']) - const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const allTemplates = useMemo(() => { const templates = getLogsTemplates(useOtelEndpoint) if (logsShowMetadataIpTemplate) return templates @@ -142,11 +138,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const [warnings, setWarnings] = useState([]) const [showMissingLimitError, setShowMissingLimitError] = useState(false) const [selectedLog, setSelectedLog] = useState(null) - const [rewriteProposal, setRewriteProposal] = useState<{ - original: string - modified: string - } | null>(null) - const [isRewriting, setIsRewriting] = useState(false) + const [rewriteProposal, setRewriteProposal] = useState(null) const [rewriteBannerDismissed, setRewriteBannerDismissed] = useLocalStorage( `project-${projectRef}-logs-rewrite-banner-dismissed`, false @@ -189,7 +181,32 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const results = logData const isLoading = logsLoading - const showRewriteCTA = useOtelEndpoint && looksLikeLegacyLogsQuery(editorValue) + // Debounced so the dialect heuristics don't run on every keystroke, matching the + // SQL editor's rewrite banner. + const settledEditorValue = useDebounce(editorValue, LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS) + const shouldShowRewriteCTA = useMemo( + () => + shouldOfferLegacyLogsRewrite({ + sql: settledEditorValue, + isClickhouseLogsEnabled: useOtelEndpoint, + }), + [settledEditorValue, useOtelEndpoint] + ) + + const { + state: rewriteState, + requestRewrite, + dismiss: dismissRewriteBanner, + } = useLegacyLogsRewrite({ + // Read straight from the editor instance — `editorValue` state can lag the + // most recent keystroke. + readSql: () => editorRef.current?.getValue() ?? editorValue, + onProposal: setRewriteProposal, + onDismissed: () => setRewriteBannerDismissed(true), + }) + const isRewriting = rewriteState.status === 'rewriting' + const hasUnacknowledgedRewriteOutcome = + rewriteState.status === 'failed' || rewriteState.status === 'noRewriteNeeded' const { mutateAsync: upsertContent, isPending: isUpsertingContent } = useContentUpsertMutation({ onError: (e) => { @@ -243,42 +260,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => { addRecentLogSqlSnippet({ unchecked_sql: untrustedLogSql(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: projectRef!, source }).catch(() => undefined) - : undefined - const rewritten = await rewriteLogsSqlWithAI({ - sql: currentSql, - projectRef: 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) @@ -504,15 +485,16 @@ export const LogsExplorerPage: NextPageWithLayout = () => { templates={allTemplates.filter((template) => template.mode === 'custom')} onSelectTemplate={onSelectTemplate} warnings={warnings} - showRewriteAction={showRewriteCTA && rewriteBannerDismissed} + showRewriteAction={shouldShowRewriteCTA && rewriteBannerDismissed} isRewriting={isRewriting} - onRewrite={handleRewrite} + onRewrite={requestRewrite} /> - {showRewriteCTA && !rewriteBannerDismissed && ( - setRewriteBannerDismissed(true)} + {(hasUnacknowledgedRewriteOutcome || + (shouldShowRewriteCTA && !rewriteBannerDismissed)) && ( + )} diff --git a/apps/studio/pages/project/[ref]/observability/edge-functions.tsx b/apps/studio/pages/project/[ref]/observability/edge-functions.tsx index 1c8c9392d9d..97a06e39ed3 100644 --- a/apps/studio/pages/project/[ref]/observability/edge-functions.tsx +++ b/apps/studio/pages/project/[ref]/observability/edge-functions.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from '@tanstack/react-query' -import { useParams } from 'common' +import { useFlag, useParams } from 'common' import dayjs from 'dayjs' import { ArrowRight, RefreshCw } from 'lucide-react' import { parseAsJson, useQueryState } from 'nuqs' @@ -60,6 +60,7 @@ const REPORT_TITLE = 'Edge Functions' const EdgeFunctionsUsage = () => { const { ref } = useParams() + const useOtel = useFlag('otelReports') const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref, }) @@ -114,6 +115,7 @@ const EdgeFunctionsUsage = () => { region: regionFilter ?? [], execution_time: executionTimeFilter, }, + useOtel, }) }, [ ref, @@ -123,6 +125,7 @@ const EdgeFunctionsUsage = () => { statusCodeFilter, regionFilter, executionTimeFilter, + useOtel, ]) const onRefreshReport = useRefreshHandler( diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index f40ea989fbf..0f1514f93f2 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -2,11 +2,12 @@ import { usePrevious } from '@uidotdev/usehooks' import { useParams } from 'common/hooks/useParams' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect } from 'react' +import { useEffect, useEffectEvent } from 'react' import { toast } from 'sonner' import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' +import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor' import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' import { DefaultLayout } from '@/components/layouts/DefaultLayout' @@ -107,11 +108,33 @@ const SqlEditor: NextPageWithLayout = () => { metadata: { sqlId: id, name: snippet?.name, + // The snippet may not be loaded yet at tab-creation time; the effect + // below backfills the source once it is. Source is immutable, so once + // set it never needs updating again. + ...(snippet !== undefined && { sqlSource: getSnippetSource(snippet) }), }, }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.isReady, id]) + // Backfill the tab's source once the snippet loads. Covers both a freshly + // created tab whose snippet arrived after creation and tabs persisted before + // `sqlSource` existed (absent → filled from the loaded snippet type). Reads the + // latest tabs snapshot via useEffectEvent so the effect only re-runs on id/snippet. + const backfillTabSource = useEffectEvent(() => { + if (!id || id === 'new' || snippet === undefined) return + + const tabId = createTabId('sql', { id }) + const tab = tabs.tabsMap[tabId] + if (tab !== undefined && tab.metadata?.sqlSource === undefined) { + tabs.updateTab(tabId, { sqlSource: getSnippetSource(snippet) }) + } + }) + useEffect(() => { + backfillTabSource() + // eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't correctly ignore useEffectEvent yet) + }, [id, snippet]) + // The snippet no longer exists (e.g. deleted from another tab or session): clean up // any stale tab and dashboard history references so navigation doesn't resurrect it, // then fall back to a new snippet instead of rendering a dead state diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 3e6b5c721d8..93a2d78dc37 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as OrgChar91_Char93RouteImport } from './routes/org.[_]' import { Route as NewSlugRouteImport } from './routes/new/$slug' import { Route as IntegrationsVercelRouteImport } from './routes/integrations/vercel' import { Route as ApiStatusOverrideRouteImport } from './routes/api/status-override' +import { Route as ApiScopedAccessTokenPermissionsRouteImport } from './routes/api/scoped-access-token-permissions' import { Route as ApiParseQueryRouteImport } from './routes/api/parse-query' import { Route as ApiIncidentStatusRouteImport } from './routes/api/incident-status' import { Route as ApiIncidentBannerRouteImport } from './routes/api/incident-banner' @@ -63,6 +64,7 @@ import { Route as ProjectRefMergeRouteImport } from './routes/project/$ref/merge import { Route as ProjectRefLogsRouteImport } from './routes/project/$ref/logs' import { Route as ProjectRefIntegrationsRouteImport } from './routes/project/$ref/integrations' import { Route as ProjectRefFunctionsRouteImport } from './routes/project/$ref/functions' +import { Route as ProjectRefExplorerRouteImport } from './routes/project/$ref/explorer' import { Route as ProjectRefEditorRouteImport } from './routes/project/$ref/editor' import { Route as ProjectRefDatabaseRouteImport } from './routes/project/$ref/database' import { Route as ProjectRefBranchesRouteImport } from './routes/project/$ref/branches' @@ -87,6 +89,7 @@ import { Route as ProjectRefObservabilityIndexRouteImport } from './routes/proje import { Route as ProjectRefLogsIndexRouteImport } from './routes/project/$ref/logs/index' import { Route as ProjectRefIntegrationsIndexRouteImport } from './routes/project/$ref/integrations/index' import { Route as ProjectRefFunctionsIndexRouteImport } from './routes/project/$ref/functions/index' +import { Route as ProjectRefExplorerIndexRouteImport } from './routes/project/$ref/explorer/index' import { Route as ProjectRefEditorIndexRouteImport } from './routes/project/$ref/editor/index' import { Route as ProjectRefBranchesIndexRouteImport } from './routes/project/$ref/branches/index' import { Route as ProjectRefApiIndexRouteImport } from './routes/project/$ref/api/index' @@ -226,6 +229,7 @@ import { Route as ProjectRefFunctionsFunctionSlugLogsRouteImport } from './route import { Route as ProjectRefFunctionsFunctionSlugInvocationsRouteImport } from './routes/project/$ref/functions/$functionSlug/invocations' import { Route as ProjectRefFunctionsFunctionSlugDetailsRouteImport } from './routes/project/$ref/functions/$functionSlug/details' import { Route as ProjectRefFunctionsFunctionSlugCodeRouteImport } from './routes/project/$ref/functions/$functionSlug/code' +import { Route as ProjectRefExplorerNotebookIdRouteImport } from './routes/project/$ref/explorer/notebook/$id' import { Route as ProjectRefDatabaseTriggersEventRouteImport } from './routes/project/$ref/database/triggers/event' import { Route as ProjectRefDatabaseTriggersDataRouteImport } from './routes/project/$ref/database/triggers/data' import { Route as ProjectRefDatabaseTablesIdRouteImport } from './routes/project/$ref/database/tables/$id' @@ -400,6 +404,12 @@ const ApiStatusOverrideRoute = ApiStatusOverrideRouteImport.update({ path: '/api/status-override', getParentRoute: () => rootRouteImport, } as any) +const ApiScopedAccessTokenPermissionsRoute = + ApiScopedAccessTokenPermissionsRouteImport.update({ + id: '/api/scoped-access-token-permissions', + path: '/api/scoped-access-token-permissions', + getParentRoute: () => rootRouteImport, + } as any) const ApiParseQueryRoute = ApiParseQueryRouteImport.update({ id: '/api/parse-query', path: '/api/parse-query', @@ -588,6 +598,11 @@ const ProjectRefFunctionsRoute = ProjectRefFunctionsRouteImport.update({ path: '/functions', getParentRoute: () => ProjectRefRoute, } as any) +const ProjectRefExplorerRoute = ProjectRefExplorerRouteImport.update({ + id: '/explorer', + path: '/explorer', + getParentRoute: () => ProjectRefRoute, +} as any) const ProjectRefEditorRoute = ProjectRefEditorRouteImport.update({ id: '/editor', path: '/editor', @@ -715,6 +730,11 @@ const ProjectRefFunctionsIndexRoute = path: '/', getParentRoute: () => ProjectRefFunctionsRoute, } as any) +const ProjectRefExplorerIndexRoute = ProjectRefExplorerIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ProjectRefExplorerRoute, +} as any) const ProjectRefEditorIndexRoute = ProjectRefEditorIndexRouteImport.update({ id: '/', path: '/', @@ -1496,6 +1516,12 @@ const ProjectRefFunctionsFunctionSlugCodeRoute = path: '/code', getParentRoute: () => ProjectRefFunctionsFunctionSlugRoute, } as any) +const ProjectRefExplorerNotebookIdRoute = + ProjectRefExplorerNotebookIdRouteImport.update({ + id: '/notebook/$id', + path: '/notebook/$id', + getParentRoute: () => ProjectRefExplorerRoute, + } as any) const ProjectRefDatabaseTriggersEventRoute = ProjectRefDatabaseTriggersEventRouteImport.update({ id: '/event', @@ -2060,6 +2086,7 @@ export interface FileRoutesByFullPath { '/api/incident-banner': typeof ApiIncidentBannerRoute '/api/incident-status': typeof ApiIncidentStatusRoute '/api/parse-query': typeof ApiParseQueryRoute + '/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute '/api/status-override': typeof ApiStatusOverrideRoute '/integrations/vercel': typeof IntegrationsVercelRouteWithChildren '/new/$slug': typeof NewSlugRoute @@ -2085,6 +2112,7 @@ export interface FileRoutesByFullPath { '/project/$ref/branches': typeof ProjectRefBranchesRouteWithChildren '/project/$ref/database': typeof ProjectRefDatabaseRouteWithChildren '/project/$ref/editor': typeof ProjectRefEditorRouteWithChildren + '/project/$ref/explorer': typeof ProjectRefExplorerRouteWithChildren '/project/$ref/functions': typeof ProjectRefFunctionsRouteWithChildren '/project/$ref/integrations': typeof ProjectRefIntegrationsRouteWithChildren '/project/$ref/logs': typeof ProjectRefLogsRouteWithChildren @@ -2209,6 +2237,7 @@ export interface FileRoutesByFullPath { '/project/$ref/api/': typeof ProjectRefApiIndexRoute '/project/$ref/branches/': typeof ProjectRefBranchesIndexRoute '/project/$ref/editor/': typeof ProjectRefEditorIndexRoute + '/project/$ref/explorer/': typeof ProjectRefExplorerIndexRoute '/project/$ref/functions/': typeof ProjectRefFunctionsIndexRoute '/project/$ref/integrations/': typeof ProjectRefIntegrationsIndexRoute '/project/$ref/logs/': typeof ProjectRefLogsIndexRoute @@ -2253,6 +2282,7 @@ export interface FileRoutesByFullPath { '/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute '/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute '/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute + '/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute '/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute '/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute '/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute @@ -2365,6 +2395,7 @@ export interface FileRoutesByTo { '/api/incident-banner': typeof ApiIncidentBannerRoute '/api/incident-status': typeof ApiIncidentStatusRoute '/api/parse-query': typeof ApiParseQueryRoute + '/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute '/api/status-override': typeof ApiStatusOverrideRoute '/integrations/vercel': typeof IntegrationsVercelRouteWithChildren '/new/$slug': typeof NewSlugRoute @@ -2503,6 +2534,7 @@ export interface FileRoutesByTo { '/project/$ref/api': typeof ProjectRefApiIndexRoute '/project/$ref/branches': typeof ProjectRefBranchesIndexRoute '/project/$ref/editor': typeof ProjectRefEditorIndexRoute + '/project/$ref/explorer': typeof ProjectRefExplorerIndexRoute '/project/$ref/functions': typeof ProjectRefFunctionsIndexRoute '/project/$ref/integrations': typeof ProjectRefIntegrationsIndexRoute '/project/$ref/logs': typeof ProjectRefLogsIndexRoute @@ -2547,6 +2579,7 @@ export interface FileRoutesByTo { '/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute '/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute '/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute + '/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute '/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute '/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute '/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute @@ -2663,6 +2696,7 @@ export interface FileRoutesById { '/api/incident-banner': typeof ApiIncidentBannerRoute '/api/incident-status': typeof ApiIncidentStatusRoute '/api/parse-query': typeof ApiParseQueryRoute + '/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute '/api/status-override': typeof ApiStatusOverrideRoute '/integrations/vercel': typeof IntegrationsVercelRouteWithChildren '/new/$slug': typeof NewSlugRoute @@ -2688,6 +2722,7 @@ export interface FileRoutesById { '/project/$ref/branches': typeof ProjectRefBranchesRouteWithChildren '/project/$ref/database': typeof ProjectRefDatabaseRouteWithChildren '/project/$ref/editor': typeof ProjectRefEditorRouteWithChildren + '/project/$ref/explorer': typeof ProjectRefExplorerRouteWithChildren '/project/$ref/functions': typeof ProjectRefFunctionsRouteWithChildren '/project/$ref/integrations': typeof ProjectRefIntegrationsRouteWithChildren '/project/$ref/logs': typeof ProjectRefLogsRouteWithChildren @@ -2812,6 +2847,7 @@ export interface FileRoutesById { '/project/$ref/api/': typeof ProjectRefApiIndexRoute '/project/$ref/branches/': typeof ProjectRefBranchesIndexRoute '/project/$ref/editor/': typeof ProjectRefEditorIndexRoute + '/project/$ref/explorer/': typeof ProjectRefExplorerIndexRoute '/project/$ref/functions/': typeof ProjectRefFunctionsIndexRoute '/project/$ref/integrations/': typeof ProjectRefIntegrationsIndexRoute '/project/$ref/logs/': typeof ProjectRefLogsIndexRoute @@ -2856,6 +2892,7 @@ export interface FileRoutesById { '/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute '/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute '/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute + '/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute '/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute '/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute '/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute @@ -2971,6 +3008,7 @@ export interface FileRouteTypes { | '/api/incident-banner' | '/api/incident-status' | '/api/parse-query' + | '/api/scoped-access-token-permissions' | '/api/status-override' | '/integrations/vercel' | '/new/$slug' @@ -2996,6 +3034,7 @@ export interface FileRouteTypes { | '/project/$ref/branches' | '/project/$ref/database' | '/project/$ref/editor' + | '/project/$ref/explorer' | '/project/$ref/functions' | '/project/$ref/integrations' | '/project/$ref/logs' @@ -3120,6 +3159,7 @@ export interface FileRouteTypes { | '/project/$ref/api/' | '/project/$ref/branches/' | '/project/$ref/editor/' + | '/project/$ref/explorer/' | '/project/$ref/functions/' | '/project/$ref/integrations/' | '/project/$ref/logs/' @@ -3164,6 +3204,7 @@ export interface FileRouteTypes { | '/project/$ref/database/tables/$id' | '/project/$ref/database/triggers/data' | '/project/$ref/database/triggers/event' + | '/project/$ref/explorer/notebook/$id' | '/project/$ref/functions/$functionSlug/code' | '/project/$ref/functions/$functionSlug/details' | '/project/$ref/functions/$functionSlug/invocations' @@ -3276,6 +3317,7 @@ export interface FileRouteTypes { | '/api/incident-banner' | '/api/incident-status' | '/api/parse-query' + | '/api/scoped-access-token-permissions' | '/api/status-override' | '/integrations/vercel' | '/new/$slug' @@ -3414,6 +3456,7 @@ export interface FileRouteTypes { | '/project/$ref/api' | '/project/$ref/branches' | '/project/$ref/editor' + | '/project/$ref/explorer' | '/project/$ref/functions' | '/project/$ref/integrations' | '/project/$ref/logs' @@ -3458,6 +3501,7 @@ export interface FileRouteTypes { | '/project/$ref/database/tables/$id' | '/project/$ref/database/triggers/data' | '/project/$ref/database/triggers/event' + | '/project/$ref/explorer/notebook/$id' | '/project/$ref/functions/$functionSlug/code' | '/project/$ref/functions/$functionSlug/details' | '/project/$ref/functions/$functionSlug/invocations' @@ -3573,6 +3617,7 @@ export interface FileRouteTypes { | '/api/incident-banner' | '/api/incident-status' | '/api/parse-query' + | '/api/scoped-access-token-permissions' | '/api/status-override' | '/integrations/vercel' | '/new/$slug' @@ -3598,6 +3643,7 @@ export interface FileRouteTypes { | '/project/$ref/branches' | '/project/$ref/database' | '/project/$ref/editor' + | '/project/$ref/explorer' | '/project/$ref/functions' | '/project/$ref/integrations' | '/project/$ref/logs' @@ -3722,6 +3768,7 @@ export interface FileRouteTypes { | '/project/$ref/api/' | '/project/$ref/branches/' | '/project/$ref/editor/' + | '/project/$ref/explorer/' | '/project/$ref/functions/' | '/project/$ref/integrations/' | '/project/$ref/logs/' @@ -3766,6 +3813,7 @@ export interface FileRouteTypes { | '/project/$ref/database/tables/$id' | '/project/$ref/database/triggers/data' | '/project/$ref/database/triggers/event' + | '/project/$ref/explorer/notebook/$id' | '/project/$ref/functions/$functionSlug/code' | '/project/$ref/functions/$functionSlug/details' | '/project/$ref/functions/$functionSlug/invocations' @@ -3871,6 +3919,7 @@ export interface RootRouteChildren { ApiIncidentBannerRoute: typeof ApiIncidentBannerRoute ApiIncidentStatusRoute: typeof ApiIncidentStatusRoute ApiParseQueryRoute: typeof ApiParseQueryRoute + ApiScopedAccessTokenPermissionsRoute: typeof ApiScopedAccessTokenPermissionsRoute ApiStatusOverrideRoute: typeof ApiStatusOverrideRoute IntegrationsVercelRoute: typeof IntegrationsVercelRouteWithChildren NewSlugRoute: typeof NewSlugRoute @@ -4091,6 +4140,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStatusOverrideRouteImport parentRoute: typeof rootRouteImport } + '/api/scoped-access-token-permissions': { + id: '/api/scoped-access-token-permissions' + path: '/api/scoped-access-token-permissions' + fullPath: '/api/scoped-access-token-permissions' + preLoaderRoute: typeof ApiScopedAccessTokenPermissionsRouteImport + parentRoute: typeof rootRouteImport + } '/api/parse-query': { id: '/api/parse-query' path: '/api/parse-query' @@ -4350,6 +4406,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectRefFunctionsRouteImport parentRoute: typeof ProjectRefRoute } + '/project/$ref/explorer': { + id: '/project/$ref/explorer' + path: '/explorer' + fullPath: '/project/$ref/explorer' + preLoaderRoute: typeof ProjectRefExplorerRouteImport + parentRoute: typeof ProjectRefRoute + } '/project/$ref/editor': { id: '/project/$ref/editor' path: '/editor' @@ -4518,6 +4581,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectRefFunctionsIndexRouteImport parentRoute: typeof ProjectRefFunctionsRoute } + '/project/$ref/explorer/': { + id: '/project/$ref/explorer/' + path: '/' + fullPath: '/project/$ref/explorer/' + preLoaderRoute: typeof ProjectRefExplorerIndexRouteImport + parentRoute: typeof ProjectRefExplorerRoute + } '/project/$ref/editor/': { id: '/project/$ref/editor/' path: '/' @@ -5491,6 +5561,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectRefFunctionsFunctionSlugCodeRouteImport parentRoute: typeof ProjectRefFunctionsFunctionSlugRoute } + '/project/$ref/explorer/notebook/$id': { + id: '/project/$ref/explorer/notebook/$id' + path: '/notebook/$id' + fullPath: '/project/$ref/explorer/notebook/$id' + preLoaderRoute: typeof ProjectRefExplorerNotebookIdRouteImport + parentRoute: typeof ProjectRefExplorerRoute + } '/project/$ref/database/triggers/event': { id: '/project/$ref/database/triggers/event' path: '/event' @@ -6437,6 +6514,19 @@ const ProjectRefEditorRouteChildren: ProjectRefEditorRouteChildren = { const ProjectRefEditorRouteWithChildren = ProjectRefEditorRoute._addFileChildren(ProjectRefEditorRouteChildren) +interface ProjectRefExplorerRouteChildren { + ProjectRefExplorerIndexRoute: typeof ProjectRefExplorerIndexRoute + ProjectRefExplorerNotebookIdRoute: typeof ProjectRefExplorerNotebookIdRoute +} + +const ProjectRefExplorerRouteChildren: ProjectRefExplorerRouteChildren = { + ProjectRefExplorerIndexRoute: ProjectRefExplorerIndexRoute, + ProjectRefExplorerNotebookIdRoute: ProjectRefExplorerNotebookIdRoute, +} + +const ProjectRefExplorerRouteWithChildren = + ProjectRefExplorerRoute._addFileChildren(ProjectRefExplorerRouteChildren) + interface ProjectRefFunctionsFunctionSlugRouteChildren { ProjectRefFunctionsFunctionSlugCodeRoute: typeof ProjectRefFunctionsFunctionSlugCodeRoute ProjectRefFunctionsFunctionSlugDetailsRoute: typeof ProjectRefFunctionsFunctionSlugDetailsRoute @@ -6716,6 +6806,7 @@ interface ProjectRefRouteChildren { ProjectRefBranchesRoute: typeof ProjectRefBranchesRouteWithChildren ProjectRefDatabaseRoute: typeof ProjectRefDatabaseRouteWithChildren ProjectRefEditorRoute: typeof ProjectRefEditorRouteWithChildren + ProjectRefExplorerRoute: typeof ProjectRefExplorerRouteWithChildren ProjectRefFunctionsRoute: typeof ProjectRefFunctionsRouteWithChildren ProjectRefIntegrationsRoute: typeof ProjectRefIntegrationsRouteWithChildren ProjectRefLogsRoute: typeof ProjectRefLogsRouteWithChildren @@ -6735,6 +6826,7 @@ const ProjectRefRouteChildren: ProjectRefRouteChildren = { ProjectRefBranchesRoute: ProjectRefBranchesRouteWithChildren, ProjectRefDatabaseRoute: ProjectRefDatabaseRouteWithChildren, ProjectRefEditorRoute: ProjectRefEditorRouteWithChildren, + ProjectRefExplorerRoute: ProjectRefExplorerRouteWithChildren, ProjectRefFunctionsRoute: ProjectRefFunctionsRouteWithChildren, ProjectRefIntegrationsRoute: ProjectRefIntegrationsRouteWithChildren, ProjectRefLogsRoute: ProjectRefLogsRouteWithChildren, @@ -6815,6 +6907,7 @@ const rootRouteChildren: RootRouteChildren = { ApiIncidentBannerRoute: ApiIncidentBannerRoute, ApiIncidentStatusRoute: ApiIncidentStatusRoute, ApiParseQueryRoute: ApiParseQueryRoute, + ApiScopedAccessTokenPermissionsRoute: ApiScopedAccessTokenPermissionsRoute, ApiStatusOverrideRoute: ApiStatusOverrideRoute, IntegrationsVercelRoute: IntegrationsVercelRouteWithChildren, NewSlugRoute: NewSlugRoute, diff --git a/apps/studio/routes/api/scoped-access-token-permissions.ts b/apps/studio/routes/api/scoped-access-token-permissions.ts new file mode 100644 index 00000000000..558cfd9f582 --- /dev/null +++ b/apps/studio/routes/api/scoped-access-token-permissions.ts @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router' + +// App Router route (apps/studio/app/api/scoped-access-token/route.ts) — already +// Web-native, uses NextResponse which extends `Response`. Direct re-export of +// each HTTP method; no shim needed. +import { GET, HEAD, OPTIONS } from '@/app/api/scoped-access-token-permissions/route' + +export const Route = createFileRoute('/api/scoped-access-token-permissions')({ + server: { + handlers: { + GET: () => GET(), + HEAD: () => HEAD(), + OPTIONS: () => OPTIONS(), + }, + }, +}) diff --git a/apps/studio/routes/project/$ref/explorer.tsx b/apps/studio/routes/project/$ref/explorer.tsx new file mode 100644 index 00000000000..96a78bdcee6 --- /dev/null +++ b/apps/studio/routes/project/$ref/explorer.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router' + +import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout' + +export const Route = createFileRoute('/project/$ref/explorer')({ + component: ExplorerShell, +}) + +function ExplorerShell() { + return ( + + + + ) +} diff --git a/apps/studio/routes/project/$ref/explorer/index.tsx b/apps/studio/routes/project/$ref/explorer/index.tsx new file mode 100644 index 00000000000..c77bdd40f67 --- /dev/null +++ b/apps/studio/routes/project/$ref/explorer/index.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { ExplorerHome } from '@/components/interfaces/Explorer/ExplorerHome' + +export const Route = createFileRoute('/project/$ref/explorer/')({ + component: ProjectExplorerIndexRoute, +}) + +function ProjectExplorerIndexRoute() { + return +} diff --git a/apps/studio/routes/project/$ref/explorer/notebook/$id.tsx b/apps/studio/routes/project/$ref/explorer/notebook/$id.tsx new file mode 100644 index 00000000000..54c476a94ad --- /dev/null +++ b/apps/studio/routes/project/$ref/explorer/notebook/$id.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + +import NotebookPage from '@/pages/project/[ref]/explorer/notebook/[id]' + +export const Route = createFileRoute('/project/$ref/explorer/notebook/$id')({ + component: ProjectExplorerNotebookRoute, +}) + +function ProjectExplorerNotebookRoute() { + return +} diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index 9e51958f1ab..5355074cf65 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -7,6 +7,7 @@ import { createContext, PropsWithChildren, useContext, useEffect, useState } fro import { v4 as uuidv4 } from 'uuid' import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' +import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import type { AiSupportStatus } from '@/data/feedback/ai-chat-front-sync' import { constructHeaders } from '@/data/fetchers' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -22,7 +23,12 @@ type SuggestionsType = { export type AssistantMessageType = MessageType -export type SqlSnippet = string | { label: string; content: string } +/** + * A query attached to the composer (the "Current Query" chip). `source` records which + * backend the attached query runs against, so the dialect travels with the query it + * describes. + */ +export type SqlSnippet = string | { label: string; content: string; source?: SqlSnippetSource } export type AssistantModel = AssistantModelId diff --git a/apps/studio/state/notebooks/notebook-session-state.ts b/apps/studio/state/notebooks/notebook-session-state.ts new file mode 100644 index 00000000000..ed760aecd43 --- /dev/null +++ b/apps/studio/state/notebooks/notebook-session-state.ts @@ -0,0 +1,9 @@ +/** + * Ephemeral, per-session notebook state that is NOT persisted: query cell + * results and the row limit. Kept separate from the notebook content store + * (which deals with persistence) because none of this is saved — it lives + * only for the current editing session, keyed by cell id rather than + * notebook id since a single notebook can have many independent query cells. + * + * [Joshen] Will be fleshed out in subsequent PRs + */ diff --git a/apps/studio/state/notebooks/notebooks-state.test.ts b/apps/studio/state/notebooks/notebooks-state.test.ts new file mode 100644 index 00000000000..6c0de761ebf --- /dev/null +++ b/apps/studio/state/notebooks/notebooks-state.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { notebooksState } from './notebooks-state' +import type { Notebook } from './types' + +function makeNotebook(id: string, overrides: Partial = {}): Notebook { + return { + id, + type: 'notebook', + name: 'My Notebook', + description: '', + visibility: 'project', + favorite: false, + owner_id: 7, + project_id: 42, + content: { schema_version: '1.0', cells: [] }, + ...overrides, + } +} + +describe('notebooksState', () => { + beforeEach(() => { + // notebooksState is a module-level singleton, so reset the state these tests touch + for (const id of Object.keys(notebooksState.notebooks)) { + delete notebooksState.notebooks[id] + } + notebooksState.needsSaving.clear() + }) + + it('addNotebook marks a locally-created notebook as new', () => { + notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') }) + + expect(notebooksState.notebooks['notebook-1'].status).toBe('new') + }) + + it('setNotebook marks a notebook not yet in the store as saved', () => { + notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') }) + + expect(notebooksState.notebooks['notebook-1'].status).toBe('saved') + }) + + it('editing a loaded (saved) notebook transitions it to unsaved and queues it for saving', () => { + notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') }) + + notebooksState.updateCells({ + id: 'notebook-1', + cells: [{ type: 'markdown', content: 'hello' }], + }) + + expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved') + expect(notebooksState.needsSaving.get('notebook-1')).toBe(false) + }) + + it('editing a notebook that has never been saved keeps it as new', () => { + notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') }) + + notebooksState.updateCells({ + id: 'notebook-1', + cells: [{ type: 'markdown', content: 'hello' }], + }) + + expect(notebooksState.notebooks['notebook-1'].status).toBe('new') + }) + + it('setNotebook does not downgrade an already-loaded notebook back to saved after edits', () => { + notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') }) + notebooksState.updateCells({ + id: 'notebook-1', + cells: [{ type: 'markdown', content: 'hello' }], + }) + expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved') + + // Re-fetching/merging content for the same notebook (e.g. a second setNotebook + // call) must not reset its status back to 'saved' while edits are pending. + notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') }) + + expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved') + }) +}) diff --git a/apps/studio/state/notebooks/notebooks-state.ts b/apps/studio/state/notebooks/notebooks-state.ts new file mode 100644 index 00000000000..4cca4f9e95e --- /dev/null +++ b/apps/studio/state/notebooks/notebooks-state.ts @@ -0,0 +1,110 @@ +import { useMemo } from 'react' +import { proxy, snapshot, useSnapshot } from 'valtio' +import { proxyMap } from 'valtio/utils' + +import type { Notebook, NotebookCell, StateNotebook } from './types' +import type { SnippetStatus } from '@/data/content/snippet-status' + +// [Joshen] Deliberately copied from sql-editor-lifecycle cause we might deprecate +// that in favor of notebooks in the long run +function statusOnEdit(status: SnippetStatus): SnippetStatus { + return status === 'saved' ? 'unsaved' : status +} + +export const notebooksState = proxy({ + notebooks: {} as Record, + needsSaving: proxyMap([]), + + /** + * Load notebook into the Valtio store. No-ops if already present. + */ + addNotebook: ({ projectRef, notebook }: { projectRef: string; notebook: Notebook }) => { + if (notebooksState.notebooks[notebook.id]) return + notebooksState.notebooks[notebook.id] = { projectRef, notebook, status: 'new' } + }, + + /** + * Load notebook content into the store. Notebooks fetched from the list + * endpoint don't have `content` loaded (to keep that response small), so + * content is fetched separately and merged in here on demand. + * + * Unlike `addNotebook` (for locally-created notebooks, status 'new'), a + * notebook reaching this function was already persisted, so it's inserted + * with status 'saved'. + */ + setNotebook: ({ projectRef, notebook }: { projectRef: string; notebook: Notebook }) => { + const stateNotebook = notebooksState.notebooks[notebook.id] + if (stateNotebook) { + if (!stateNotebook.notebook.content) { + stateNotebook.notebook.content = notebook.content + } + } else { + notebooksState.notebooks[notebook.id] = { projectRef, notebook, status: 'saved' } + } + }, + + /** + * Rename follows its own async save directly at the call site rather than going + * through needsSaving/the debounced scheduler. + */ + renameNotebook: ({ id, name }: { id: string; name: string }) => { + const stateNotebook = notebooksState.notebooks[id] + if (stateNotebook) { + stateNotebook.notebook.name = name + } + }, + + /** + * Remove notebook from the store, and optionally remove it from the sync + * saving queue. Also clears any cached query-cell results for this notebook + * from the ephemeral session store. + */ + removeNotebook: ({ id, skipSave = false }: { id: string; skipSave?: boolean }) => { + const { [id]: notebook, ...otherNotebooks } = notebooksState.notebooks + notebooksState.notebooks = otherNotebooks + if (!skipSave) notebooksState.needsSaving.delete(id) + + // TODO: clear notebookSessionState once it exists + }, + + /** + * Replace a notebook's full cell array and queue it for sync saving. The + * single entry point for every cell-level change — adding, removing, + * reordering, or editing a cell's content all compute the next `cells` array + * at the call site and pass it here, since the notebook is saved as one JSON + * document rather than per-cell. + */ + updateCells: ({ + id, + cells, + skipSave, + }: { + id: string + cells: NotebookCell[] + skipSave?: boolean + }) => { + const stateNotebook = notebooksState.notebooks[id] + if (!stateNotebook?.notebook.content) return + stateNotebook.notebook.content.cells = cells + stateNotebook.status = statusOnEdit(stateNotebook.status) + if (!skipSave) notebooksState.needsSaving.set(id, false) + }, + + addNeedsSaving: (id: string) => notebooksState.needsSaving.set(id, true), +}) + +export const getNotebooksStateSnapshot = () => snapshot(notebooksState) + +export const useNotebooksStateSnapshot = (options?: Parameters[1]) => + useSnapshot(notebooksState, options) + +export const useNotebooks = (projectRef: string) => { + const snapshot = useNotebooksStateSnapshot() + return useMemo( + () => + Object.values(snapshot.notebooks) + .filter((x) => x.projectRef === projectRef) + .map((x) => x.notebook), + [projectRef, snapshot.notebooks] + ) +} diff --git a/apps/studio/state/notebooks/types.ts b/apps/studio/state/notebooks/types.ts new file mode 100644 index 00000000000..69ee18a1ea1 --- /dev/null +++ b/apps/studio/state/notebooks/types.ts @@ -0,0 +1,34 @@ +import { SnippetStatus } from '@/data/content/snippet-status' + +/** Start and end follows ISO8601 convention */ +type AbsoluteTimeRange = { start: string; end: string } +type RelativeTimeRange = { unit: 'm' | 'h' | 'd' | 'w' | 'M' | 'y'; amount: number } +type TimeRange = AbsoluteTimeRange | RelativeTimeRange + +type DatabaseQueryCell = { type: 'sql'; sql: string } +type LogsQueryCell = { type: 'logs'; sql: string; range: TimeRange } +type MarkdownCell = { type: 'markdown'; content: string } +export type NotebookCell = DatabaseQueryCell | LogsQueryCell | MarkdownCell + +interface NotebookContent { + schema_version: string + cells: NotebookCell[] +} + +export interface Notebook { + id: string + type: 'notebook' + name: string + description?: string + visibility: 'project' + favorite: boolean + owner_id: number + project_id: number + content?: NotebookContent // Undefined until loaded +} + +export interface StateNotebook { + projectRef: string + notebook: Notebook + status: SnippetStatus +} diff --git a/apps/studio/state/tabs.test.ts b/apps/studio/state/tabs.test.ts index 9cbfa766a10..000814297f7 100644 --- a/apps/studio/state/tabs.test.ts +++ b/apps/studio/state/tabs.test.ts @@ -72,6 +72,46 @@ describe('tabs recent items', () => { }) }) +describe('tabs sql source metadata', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('backfills sqlSource onto a tab and its recent item', () => { + const store = createTabsState('default') + + store.addTab({ + id: 'sql-a', + type: 'sql', + label: 'Logs query', + metadata: { sqlId: 'a', name: 'Logs query' }, + isPreview: false, + }) + + // Persisted before the field existed → absent until backfilled from the snippet. + expect(store.tabsMap['sql-a'].metadata?.sqlSource).toBeUndefined() + + store.updateTab('sql-a', { sqlSource: 'logs' }) + + expect(store.tabsMap['sql-a'].metadata?.sqlSource).toBe('logs') + expect(store.recentItems[0].metadata?.sqlSource).toBe('logs') + }) + + it('carries sqlSource from a tab into its recent item on creation', () => { + const store = createTabsState('default') + + store.addTab({ + id: 'sql-a', + type: 'sql', + label: 'Logs query', + metadata: { sqlId: 'a', name: 'Logs query', sqlSource: 'logs' }, + isPreview: false, + }) + + expect(store.recentItems[0].metadata?.sqlSource).toBe('logs') + }) +}) + describe('tabs removal', () => { beforeEach(() => { localStorage.clear() diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index c0ef9b95fe2..a836818022c 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -12,14 +12,17 @@ import { import { proxy, subscribe, useSnapshot } from 'valtio' import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils' -import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' +import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' +import type { EditorType } from '@/components/layouts/editors/EditorsLayout.hooks' +import type { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' export const editorEntityTypes = { table: ['r', 'v', 'm', 'f', 'p'], sql: ['sql'], + explorer: ['notebook'], } -export type TabType = ENTITY_TYPE | 'sql' +export type TabType = ENTITY_TYPE | 'sql' | 'notebook' type CreateTabIdParams = { r: { id: number } @@ -28,6 +31,7 @@ type CreateTabIdParams = { f: { id: number } p: { id: number } sql: { id: string } + notebook: { id: string } schema: { schema: string } view: never function: never @@ -43,7 +47,16 @@ export interface Tab { name?: string tableId?: number sqlId?: string + notebookId?: string scrollTop?: number + /** + * For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`), + * so the tab can show the matching icon without re-fetching the snippet. + * Absent on tabs persisted before this field existed — treat absent as + * `'database'` and backfill from the loaded snippet (source is immutable, so + * it never goes stale once set). + */ + sqlSource?: SqlSnippetSource } isPreview?: boolean createdAt?: Date @@ -98,6 +111,7 @@ export interface RecentItem { name?: string tableId?: number sqlId?: string + sqlSource?: SqlSnippetSource } } @@ -270,22 +284,35 @@ export function createTabsState(projectRef: string) { store.previewTabId = tab.id store.activeTab = tab.id }, - updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => { - if (!!store.tabsMap[id]) { - if ('label' in updates) { - store.tabsMap[id].label = updates.label - // Keep the persisted name aligned with the visible label so browser titles - // and tab state recover cleanly after entity renames. - if (typeof updates.label === 'string' && store.tabsMap[id].metadata) { - store.tabsMap[id].metadata.name = updates.label - } + updateTab: ( + id: string, + updates: { label?: string; scrollTop?: number; sqlSource?: SqlSnippetSource } + ) => { + const tab = store.tabsMap[id] + if (!tab) return - const recentItem = store.recentItems.find((item) => item.id === id) - if (recentItem) syncRecentItemWithTab(recentItem, store.tabsMap[id]) - } - if ('scrollTop' in updates && store.tabsMap[id].metadata) { - store.tabsMap[id].metadata.scrollTop = updates.scrollTop + if ('label' in updates) { + tab.label = updates.label + // Keep the persisted name aligned with the visible label so browser titles + // and tab state recover cleanly after entity renames. + if (typeof updates.label === 'string' && tab.metadata) { + tab.metadata.name = updates.label } + + const recentItem = store.recentItems.find((item) => item.id === id) + if (recentItem) syncRecentItemWithTab(recentItem, tab) + } + if ('scrollTop' in updates && tab.metadata) { + tab.metadata.scrollTop = updates.scrollTop + } + // Backfill the immutable source onto a tab (and its recent item) that + // predates the field, so its icon resolves correctly once the snippet loads. + if (updates.sqlSource !== undefined) { + if (tab.metadata) tab.metadata.sqlSource = updates.sqlSource + else tab.metadata = { sqlSource: updates.sqlSource } + + const recentItem = store.recentItems.find((item) => item.id === id) + if (recentItem) syncRecentItemWithTab(recentItem, tab) } }, // Function to remove a tab from the store @@ -361,6 +388,9 @@ export function createTabsState(projectRef: string) { const schema = (router.query.schema as string) || 'public' router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`) break + case 'notebook': + router.push(`/project/${router.query.ref}/explorer/notebook/${tab.metadata?.notebookId}`) + break case 'r': case 'v': case 'm': @@ -446,7 +476,7 @@ export function createTabsState(projectRef: string) { }: { id: string router: NextRouter - editor?: 'sql' | 'table' + editor?: EditorType onClose?: (id: string) => void onClearDashboardHistory: () => void }) => { @@ -494,6 +524,9 @@ export function createTabsState(projectRef: string) { case 'sql': router.push(`/project/${router.query.ref}/sql`) break + case 'notebook': + router.push(`/project/${router.query.ref}/explorer`) + break case 'r': case 'v': case 'm': @@ -613,6 +646,8 @@ export function createTabId(type: T, params: CreateTabIdParam return `p-${(params as CreateTabIdParams['p']).id}` case 'sql': return `sql-${(params as CreateTabIdParams['sql']).id}` + case 'notebook': + return `notebook-${(params as CreateTabIdParams['sql']).id}` default: return '' } diff --git a/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx new file mode 100644 index 00000000000..6985f0ad822 --- /dev/null +++ b/apps/studio/tests/components/SQLEditor/QuerySourceMenu.test.tsx @@ -0,0 +1,71 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { describe, expect, it } from 'vitest' + +import { DEFAULT_LOG_DATE_RANGE } from '@/components/interfaces/SQLEditor/querySource' +import { QuerySourceMenu } from '@/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +// QuerySourceMenu renders a Radix dropdown (+ nested dialog), both of which use Web Animations. +mockAnimationsApi() + +addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: { + 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, + }, +}) + +describe('QuerySourceMenu', () => { + it('keeps the dropdown open across a source switch, so the new source’s controls appear without reopening it', async () => { + // Selecting a source doesn't mutate `runSource` in place — it navigates to a + // fresh tab, and the parent re-renders this component with the new source once + // the route lands. Rerendering with the switched-to prop below stands in for + // that navigation, so the test observes exactly what the user does: does the + // dropdown have to be reopened to see the newly-available controls? + const { rerender } = customRender( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' })) + + expect(await screen.findByText('Run as')).toBeInTheDocument() + expect(screen.queryByText('Time range')).not.toBeInTheDocument() + + await userEvent.click(screen.getByText('Logs')) + + rerender( + + ) + + // The dropdown never closed, so the logs-only "Time range" control is visible + // immediately, and the database-only controls are gone — without the user + // having to reopen the menu. + expect(screen.getByText('Time range')).toBeInTheDocument() + expect(screen.queryByText('Run as')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/tests/pages/vercel-deploy-button-new-project.test.tsx b/apps/studio/tests/pages/vercel-deploy-button-new-project.test.tsx new file mode 100644 index 00000000000..432df5dfcb7 --- /dev/null +++ b/apps/studio/tests/pages/vercel-deploy-button-new-project.test.tsx @@ -0,0 +1,32 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { expect, test, vi } from 'vitest' + +import { VercelConnectionError } from '@/pages/integrations/vercel/[slug]/deploy-button/new-project' +import { customRender } from '@/tests/lib/custom-render' + +test('shows a recoverable partial-success state without recreating the project', async () => { + const user = userEvent.setup() + const retry = vi.fn() + + customRender( + + ) + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent('Unable to connect to Vercel') + expect(alert).toHaveTextContent( + 'Your Supabase project was still created. Error: Connection request failed' + ) + expect(screen.getByRole('link', { name: 'Open project' })).toHaveAttribute( + 'href', + '/project/project-ref' + ) + + await user.click(screen.getByRole('button', { name: 'Retry connection' })) + expect(retry).toHaveBeenCalledOnce() +}) diff --git a/apps/studio/types/base.ts b/apps/studio/types/base.ts index 089e876b3fa..23852a697bc 100644 --- a/apps/studio/types/base.ts +++ b/apps/studio/types/base.ts @@ -77,7 +77,11 @@ export interface Permission { organization_slug: string resources: string[] restrictive?: boolean - project_refs: string[] + /** + * Projects the row is limited to. Organization-wide rows arrive as [] or null — the API + * contract is nullable and some view-synthesized rows serialize as null. + */ + project_refs: string[] | null } export interface ResponseFailure { diff --git a/apps/studio/types/userContent.ts b/apps/studio/types/userContent.ts index 0910808bb82..e1206a77739 100644 --- a/apps/studio/types/userContent.ts +++ b/apps/studio/types/userContent.ts @@ -1,8 +1,19 @@ import type { UntrustedSqlFragment } from '@supabase/pg-meta' import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +import type * as NotebookSchema from '@/data/content/notebooks/notebook-schema' import type { UntrustedLogSqlFragment } from '@/data/logs/safe-analytics-sql' +export namespace Notebooks { + export type Content = NotebookSchema.NotebookContent + export type Cell = NotebookSchema.Cell + export type MarkdownCell = NotebookSchema.MarkdownCell + export type DatabaseCell = NotebookSchema.DatabaseCell + export type LogCell = NotebookSchema.LogCell + export type TimeRange = NotebookSchema.TimeRange + export type ChartConfig = NotebookSchema.ChartConfig +} + export interface UserContent< T = Dashboards.Content | SqlSnippets.Content | LogSqlSnippets.Content, > { diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 29998d6d8a2..a238efe54a3 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -792,11 +792,11 @@ export default defineConfig(({ command, mode }) => { postcss: { plugins: [] }, }, ssr: { - // `lodash` must stay inlined so its ids flow through the plugin - // pipeline, where ssrLodashEs (above) rewrites them to lodash-es. - // Externalized bare ids skip user plugins in the dev module runner, so - // dropping this entry resurfaces Node's CJS named-export failure - // ("Named export 'debounce' not found") on every `from 'lodash'` import. + optimizeDeps: { + include: ['lodash'], + }, + + // `lodash` is CJS; its named-export interop fails in Node ESM unless bundled. // `next/*` must be bundled so our nextCompat shim wins — otherwise Vite's // SSR externalizer leaves `next/router` as a runtime package import and // Node resolves it to Next's real module. diff --git a/apps/www/_blog/2026-08-05-postgres-changes-filters-and-column-selection.mdx b/apps/www/_blog/2026-08-05-postgres-changes-filters-and-column-selection.mdx new file mode 100644 index 00000000000..a9b394a3f41 --- /dev/null +++ b/apps/www/_blog/2026-08-05-postgres-changes-filters-and-column-selection.mdx @@ -0,0 +1,105 @@ +--- +title: 'Postgres Changes gets AND filters, new operators, and column selection' +description: 'Postgres Changes subscriptions can now combine filters with AND, match on more operators, and select only the columns you need in the payload.' +author: filipe +date: '2026-08-05' +categories: + - product +tags: + - realtime + - postgres +imgSocial: 'postgres-changes-filters-and-column-selection/og.png' +imgThumb: 'postgres-changes-filters-and-column-selection/thumb.png' +toc_depth: 2 +--- + +Postgres Changes just got more capable. We're shipping three improvements: filters that combine across multiple columns, more filter operators, and column selection so a subscription returns only the fields you ask for. All three are available now. + +Postgres Changes lets you subscribe to inserts, updates, and deletes on a Postgres table and receive them over a WebSocket as they happen. + +## Filter on more than one column + +A support inbox is a good example. You want a live view of the tickets that are open and assigned to your team, and nothing else. Until now a filter could only look at one column, so you could match on status or on team, but not both at once. The workaround was to subscribe to every open ticket in the table and drop the ones for other teams once they reached your app. + +Filters now compose. Separate them with a comma and every condition has to match: + +```typescript +supabase + .channel('team-inbox') + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'tickets', + filter: 'status=eq.open,team=eq.billing', + }, + (payload) => console.log(payload) + ) + .subscribe() +``` + +## More filter operators + +Filters also support `like` and `ilike` for patterns, `is` for null and boolean checks, `match` and `imatch` for POSIX regular expressions, and `isdistinct` for NULL-safe inequality. Prefix any of them with `not.` to invert it: + +```typescript +filter: 'email=like.%@example.com' +filter: 'deleted_at=is.null' +filter: 'state=isdistinct.active' +filter: 'status=not.eq.archived' +``` + +If you'd rather build filters in code than assemble strings, there's a builder: + +```typescript +import { postgresChangesFilter } from '@supabase/supabase-js' + +filter: postgresChangesFilter().eq('status', 'open').gte('priority', 3) +``` + +Chained conditions combine with AND, the same as the comma form. This gets Postgres Changes closer to the filter syntax the Supabase client libraries already use elsewhere. + +## Select which columns you receive + +Filters narrow which rows arrive. Column selection narrows what's inside them. Every event used to carry the whole row, ticket body and metadata included, even when your UI only rendered a subject line. + +Add a `select` option and only those columns come back in the payload: + +```typescript +supabase + .channel('team-inbox') + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'tickets', + select: ['id', 'subject', 'updated_at'], + }, + (payload) => console.log(payload) + ) + .subscribe() +``` + +The table's primary key always comes through, even if you leave it out, so you can still tell which row changed. This one is opt in: existing subscriptions keep receiving the full row until you add `select`. + +## What this does to your usage + +Realtime [bills on messages](https://supabase.com/docs/guides/realtime/pricing) and egress. A database change counts as one message for every client subscribed to it, so an event a filter rejects saves a message per subscriber. A payload trimmed by `select` sends fewer bytes to every subscriber. Both reduce billable usage, so the same workload costs less. + +## Things to know + +- Column selection requires `@supabase/supabase-js` 2.109.0 or newer. The filter changes work with the client library you already have. +- The columns you list must be selectable by the subscribing role. Row Level Security still applies. +- DELETE events only carry the row's primary key, so column filters can't be evaluated on deletes. +- Only AND composition shipped in this release. OR is not supported. + +## What's not supported yet + +Postgres Changes filters don't cover the whole PostgREST surface. Array and JSON containment, full text search, and range operators aren't included, and neither is OR composition. None of these are scheduled yet. If one of them would unblock you, tell us in [feature requests](https://github.com/orgs/supabase/discussions/categories/feature-requests) and describe your use case. + +## Get started + +- Read the [Postgres Changes documentation](https://supabase.com/docs/guides/realtime/postgres-changes) for the full operator table and publication setup +- If you're already subscribed to Postgres Changes, nothing changes unless you add a filter or a `select` option diff --git a/apps/www/_blog/2026-08-07-supabase-is-now-a-connector-on-perplexity-computer.mdx b/apps/www/_blog/2026-08-07-supabase-is-now-a-connector-on-perplexity-computer.mdx new file mode 100644 index 00000000000..8169c94f88f --- /dev/null +++ b/apps/www/_blog/2026-08-07-supabase-is-now-a-connector-on-perplexity-computer.mdx @@ -0,0 +1,45 @@ +--- +title: 'Supabase is now a connector on Perplexity Computer' +description: 'Supabase is now a connector on Perplexity Computer. Query your Postgres data, look up users, and invoke Edge Functions from a Perplexity chat.' +author: natalie_roberge +date: '2026-08-07' +categories: + - product +tags: + - ai + - perplexity + - integrations +imgSocial: '2026-08-07-supabase-is-now-a-connector-on-perplexity-computer/og.png' +imgThumb: '2026-08-07-supabase-is-now-a-connector-on-perplexity-computer/thumb.png' +toc_depth: 2 +--- + +Supabase is available as a [connector on Perplexity Computer](https://www.perplexity.ai/connectors/supabase?utm_source=supabase&utm_medium=partner&utm_campaign=builders_blog_%2Baug6). With Supabase as a persistent data layer, Computer can read from and write back to your Postgres tables, keeping state across runs without custom glue code. + +[Perplexity Computer](https://www.perplexity.ai/computer) is an agent that runs multi-step work across 400+ apps, including GitHub, Vercel, Datadog, Stripe, and Slack. Connect your Supabase project and your database joins that loop. + +
    +