diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 28501353fc0f4..2afa1d2bf3536 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -285,6 +285,13 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ level: 'reference_server', new: true, }, + { + label: 'Middleware SDK', + icon: 'reference-javascript', + href: '/reference/middleware' as `/${string}`, + level: 'reference_middleware', + new: true, + }, { label: 'CLI Commands', icon: 'reference-cli', @@ -3432,6 +3439,17 @@ export const reference_server_v1 = { }, } +export const reference_middleware_v1 = { + icon: 'reference-javascript', + title: 'Middleware', + url: '/reference/middleware', + parent: '/reference', + pkg: { + name: '@supabase/middleware', + repo: 'https://github.com/supabase/middleware', + }, +} + // TODO: How to? export const reference_dart_v1 = { icon: 'reference-dart', diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx index 73bcfdc175c7b..5d6064525604e 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx @@ -28,6 +28,7 @@ enum MenuId { LocalDevelopment = 'local_development', Contributing = 'contributing', RefServerV1 = 'reference_server_v1', + RefMiddlewareV1 = 'reference_middleware_v1', RefJavaScriptV1 = 'reference_javascript_v1', RefJavaScriptV2 = 'reference_javascript_v2', RefDartV1 = 'reference_dart_v1', @@ -153,6 +154,11 @@ const menus: Menu[] = [ type: 'reference', path: '/reference/server', }, + { + id: MenuId.RefMiddlewareV1, + type: 'reference', + path: '/reference/middleware', + }, { id: MenuId.RefJavaScriptV1, type: 'reference', diff --git a/apps/docs/content/navigation.references.ts b/apps/docs/content/navigation.references.ts index 92c56d6b60c7c..6bbb9082f6cda 100644 --- a/apps/docs/content/navigation.references.ts +++ b/apps/docs/content/navigation.references.ts @@ -44,6 +44,20 @@ export const REFERENCES = { }, }, }, + middleware: { + type: 'sdk', + name: 'Middleware', + library: '@supabase/middleware', + libPath: 'middleware', + versions: ['v1'], + typeSpec: true, + icon: 'reference-javascript', + meta: { + v1: { + libId: 'reference_middleware_v1', + }, + }, + }, dart: { type: 'sdk', name: 'Flutter', diff --git a/apps/docs/docs/ref/middleware/installing.mdx b/apps/docs/docs/ref/middleware/installing.mdx new file mode 100644 index 0000000000000..dd4da3ab121dd --- /dev/null +++ b/apps/docs/docs/ref/middleware/installing.mdx @@ -0,0 +1,84 @@ +--- +id: installing +title: Installing +slug: installing +--- + +### Install as a package + + + + + Install `@supabase/middleware` via your package manager. + + + + + + + + + ```sh Terminal + npm install @supabase/middleware + ``` + + + + + ```sh Terminal + yarn add @supabase/middleware + ``` + + + + + ```sh Terminal + pnpm add @supabase/middleware + ``` + + + + + + + +### Use via JSR (Deno / Bun) + + + + + `@supabase/middleware` is also published to [JSR](https://jsr.io/@supabase/middleware) for Deno and Bun environments. + + + + + + + + + ```sh Terminal + deno add jsr:@supabase/middleware + ``` + + + + + ```sh Terminal + bunx jsr add @supabase/middleware + ``` + + + + + + diff --git a/apps/docs/docs/ref/middleware/introduction.mdx b/apps/docs/docs/ref/middleware/introduction.mdx new file mode 100644 index 0000000000000..a73f26d31622d --- /dev/null +++ b/apps/docs/docs/ref/middleware/introduction.mdx @@ -0,0 +1,26 @@ +--- +id: introduction +title: Introduction +--- + +`@supabase/middleware` is a framework-agnostic engine for composing server-side request middleware. You write small units with `defineMiddleware`, compose them with `pipeline`, and run the result as a standard Fetch handler on Node, Deno, Bun, and Cloudflare Workers. + +Each middleware can guard the request, edit the response, or contribute typed values to a shared context that later middleware and your handler read. TypeScript checks the composition: a middleware that needs a value from an earlier middleware will not compile unless that middleware runs first. + + + +`@supabase/middleware` is in alpha. APIs may change between 0.x releases. The `middleware` option on `withSupabase` in `@supabase/server` is also alpha. + + + +This package contains the engine and the framework-neutral middleware: `withCors` and `withFeatureFlag`. Supabase-specific middleware such as `withClaims`, `withSupabaseClient`, `withSupabaseAdminClient`, `withPostgresClient`, and `withPostgresAdminClient` ships in [`@supabase/server`](/docs/reference/server/introduction). + +### Trust model + +A middleware in your pipeline runs with the same access as your own handler code. It sees the full request, everything earlier middleware put on the context, and every response on the way out. Nothing sandboxes it. Only compose middleware you trust as much as your own code. + +Ordering is your permission boundary. A middleware can only read context values that earlier entries contributed, so place third-party middleware as early as possible and sensitive contributions as late as possible. + +### Environment access + +Middleware reads configuration through `getEnv` instead of `process.env`. On Node, Deno, and Bun, `getEnv` reads the process environment. Cloudflare Workers pass env bindings per request instead of exposing a global environment; the pipeline seeds each request's bindings, and `getEnv` reads the ones for the current request. diff --git a/apps/docs/features/docs/Reference.constants.ts b/apps/docs/features/docs/Reference.constants.ts index ec3e861fb90d5..70a04b0d174e7 100644 --- a/apps/docs/features/docs/Reference.constants.ts +++ b/apps/docs/features/docs/Reference.constants.ts @@ -16,4 +16,9 @@ * not listed here keeps reading from the legacy `features/docs/generated/` * outputs. */ -export const SUPPORTS_NEW_REFERENCE_PROCESS = new Set(['javascript-v2', 'dart-v2', 'server-v1']) +export const SUPPORTS_NEW_REFERENCE_PROCESS = new Set([ + 'javascript-v2', + 'dart-v2', + 'server-v1', + 'middleware-v1', +]) diff --git a/apps/docs/features/docs/Reference.ui.tsx b/apps/docs/features/docs/Reference.ui.tsx index c8a2ee5704b75..42c984a41e6c8 100644 --- a/apps/docs/features/docs/Reference.ui.tsx +++ b/apps/docs/features/docs/Reference.ui.tsx @@ -474,7 +474,9 @@ export function ApiSchemaParamSubdetails({ if ( !('enum' in schema) && 'type' in schema && - (['boolean', 'number', 'integer'].includes(schema.type) || + (schema.type === 'boolean' || + ((schema.type === 'number' || schema.type === 'integer') && + !('minimum' in schema || 'maximum' in schema)) || (schema.type === 'string' && !('minLength' in schema || 'maxLength' in schema || 'pattern' in schema)) || (schema.type === 'array' && @@ -501,7 +503,15 @@ export function ApiSchemaParamSubdetails({ constraint: key, value: schema[key], })) - : [] + : 'type' in schema && (schema.type === 'number' || schema.type === 'integer') + ? ['minimum', 'maximum'] + .filter((key) => key in schema) + .map((key) => ({ + constraint: key, + value: schema[key], + })) + : [] + const subContent = asSchemaArray(rawSubContent) return ( @@ -575,7 +585,10 @@ export function ApiSchemaParamSubdetails({ {String(detail)} - ) : 'type' in schema && schema.type === 'string' ? ( + ) : 'type' in schema && + (schema.type === 'string' || + schema.type === 'number' || + schema.type === 'integer') ? ( {detail.constraint} diff --git a/apps/docs/internals/generate-reference-markdown.ts b/apps/docs/internals/generate-reference-markdown.ts index 8f18dabc28619..d985a788e1259 100644 --- a/apps/docs/internals/generate-reference-markdown.ts +++ b/apps/docs/internals/generate-reference-markdown.ts @@ -116,6 +116,13 @@ const REFERENCES: Ref[] = [ mdxDir: path.join(MDX_ROOT, 'server'), contentDir: path.join(process.cwd(), 'content/reference/server/v1'), }, + { + kind: 'sdk-new', + title: 'Supabase Middleware SDK Reference', + outFile: 'middleware.md', + mdxDir: path.join(MDX_ROOT, 'middleware'), + contentDir: path.join(process.cwd(), 'content/reference/middleware/v1'), + }, { kind: 'sdk-legacy', title: 'Kotlin Client Library Reference', diff --git a/apps/docs/layouts/MainSkeleton.tsx b/apps/docs/layouts/MainSkeleton.tsx index 0799e49106f98..baedba068c641 100644 --- a/apps/docs/layouts/MainSkeleton.tsx +++ b/apps/docs/layouts/MainSkeleton.tsx @@ -109,6 +109,10 @@ const levelsData = { icon: 'reference-javascript', name: 'Server Reference v1.0', }, + reference_middleware_v1: { + icon: 'reference-javascript', + name: 'Middleware Reference v1.0', + }, reference_javascript_v1: { icon: 'reference-javascript', name: 'JavaScript Reference v1.0', diff --git a/apps/docs/package.json b/apps/docs/package.json index a394f747f7573..f0f734c7c57c6 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -16,7 +16,7 @@ "clean": "rimraf .next .turbo features/docs/generated examples __generated__ tsconfig.tsbuildinfo", "codegen:examples": "shx cp -r ../../examples ./examples", "codegen:graphql": "tsx --conditions=react-server ./scripts/graphqlSchema.ts && graphql-codegen --config codegen.ts", - "codegen:references:new:ensure": "(test -f spec/reference/javascript/v2/supabase.json || (cd spec && make download.tsdoc.v2)) && (test -f spec/reference/server/v1/server.json || (cd spec && make download.server.v1))", + "codegen:references:new:ensure": "(test -f spec/reference/javascript/v2/supabase.json || (cd spec && make download.tsdoc.v2)) && (test -f spec/reference/server/v1/server.json || (cd spec && make download.server.v1)) && (test -f spec/reference/middleware/v1/middleware.json || (cd spec && make download.middleware.v1))", "codegen:references:dart": "tsx scripts/generate-dart-reference.ts", "precodegen:references:new": "pnpm run codegen:references:new:ensure && pnpm run codegen:references:dart", "codegen:references:new": "pnpm run codegen:references:new:ensure && tsx scripts/build-reference-content.ts", diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 712db4ab82876..f6ab7613eb97f 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -265,6 +265,7 @@ Ryan Goulet Ryan Senior Ruan Maia Saba Pochkhua +Safa Orhan Sagar Shedge Sam Meech-Ward Sam Rome @@ -313,6 +314,7 @@ Tom Ashley Tom G Tomás Pozo Tomás Torgal +Tomohiro Mitani Tyler Hillery Tyler Fontaine Tyler Shukert diff --git a/apps/docs/scripts/search/sources/index.ts b/apps/docs/scripts/search/sources/index.ts index d3becfcb67c36..091cb8adca176 100644 --- a/apps/docs/scripts/search/sources/index.ts +++ b/apps/docs/scripts/search/sources/index.ts @@ -63,6 +63,18 @@ export async function fetchServerLibReferenceSource() { }) } +export async function fetchMiddlewareLibReferenceSource() { + // Middleware SDK is driven by the new reference pipeline. Ingest search + // sources from the generated `content/reference/middleware/v1/` outputs so + // embeddings never drift from what the renderer shows. + return loadClientLibReferenceFromNewPipeline({ + source: 'middleware-lib', + path: '/reference/middleware', + meta: { title: 'Middleware Reference', language: 'TypeScript' }, + contentDir: 'content/reference/middleware/v1', + }) +} + export async function fetchDartLibReferenceSource() { // Dart v2 is driven by the new reference pipeline. Ingest search sources from // the generated `content/reference/dart/v2/` outputs so embeddings never @@ -145,6 +157,7 @@ export async function fetchAllSources(fullIndex: boolean) { const openApiReferenceSource = fetchOpenApiReferenceSource() const jsLibReferenceSource = fetchJsLibReferenceSource() const serverLibReferenceSource = fullIndex ? fetchServerLibReferenceSource() : [] + const middlewareLibReferenceSource = fullIndex ? fetchMiddlewareLibReferenceSource() : [] const dartLibReferenceSource = fullIndex ? fetchDartLibReferenceSource() : [] const pythonLibReferenceSource = fullIndex ? fetchPythonLibReferenceSource() : [] const cSharpLibReferenceSource = fullIndex ? fetchCSharpLibReferenceSource() : [] @@ -179,6 +192,7 @@ export async function fetchAllSources(fullIndex: boolean) { openApiReferenceSource, jsLibReferenceSource, serverLibReferenceSource, + middlewareLibReferenceSource, dartLibReferenceSource, pythonLibReferenceSource, cSharpLibReferenceSource, diff --git a/apps/docs/spec/Makefile b/apps/docs/spec/Makefile index 2110c1d0b4b0e..7a4d35bb0705f 100644 --- a/apps/docs/spec/Makefile +++ b/apps/docs/spec/Makefile @@ -1,7 +1,7 @@ REPO_DIR=$(shell pwd) GENERATOR_DIR=../../../packages/generator -.PHONY: run download download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 transform dereference.api.v1 dereference.auth.v1 dereference.storage.v0 generate generate.sections.api.v1 generate.partials.access-control format +.PHONY: run download download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 download.middleware.v1 transform dereference.api.v1 dereference.auth.v1 dereference.storage.v0 generate generate.sections.api.v1 generate.partials.access-control format run: download transform generate format @@ -11,7 +11,7 @@ run: download transform generate format ############################################################################### # comment out download.auth.v1 temporarily, we're manually creating the file # download: download.api.v1 download.auth.v1 download.storage.v1 download.tsdoc.v2 -download: download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 +download: download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 download.middleware.v1 download.api.v1: curl -sS https://api.supabase.com/api/v1-json > $(REPO_DIR)/api_v1_openapi.json @@ -54,6 +54,9 @@ download.tsdoc.v2: download.server.v1: curl -sSf https://supabase.github.io/server/spec.json > $(REPO_DIR)/reference/server/v1/server.json +download.middleware.v1: + curl -sSf https://supabase.github.io/middleware/spec.json > $(REPO_DIR)/reference/middleware/v1/middleware.json + download.analytics.v0: curl -sS https://logflare.app/api/openapi > $(REPO_DIR)/analytics_v0_openapi.json diff --git a/apps/docs/spec/reference/middleware/v1/config.json b/apps/docs/spec/reference/middleware/v1/config.json new file mode 100644 index 0000000000000..74fca13b72c83 --- /dev/null +++ b/apps/docs/spec/reference/middleware/v1/config.json @@ -0,0 +1,5 @@ +{ + "categoryOrder": ["Composition", "Middleware", "Environment", "Types"], + "partialsOrder": ["introduction", "installing"], + "navigationPrefixes": {} +} diff --git a/apps/docs/spec/reference/middleware/v1/partials/installing.mdx b/apps/docs/spec/reference/middleware/v1/partials/installing.mdx new file mode 100644 index 0000000000000..dd4da3ab121dd --- /dev/null +++ b/apps/docs/spec/reference/middleware/v1/partials/installing.mdx @@ -0,0 +1,84 @@ +--- +id: installing +title: Installing +slug: installing +--- + +### Install as a package + + + + + Install `@supabase/middleware` via your package manager. + + + + + + + + + ```sh Terminal + npm install @supabase/middleware + ``` + + + + + ```sh Terminal + yarn add @supabase/middleware + ``` + + + + + ```sh Terminal + pnpm add @supabase/middleware + ``` + + + + + + + +### Use via JSR (Deno / Bun) + + + + + `@supabase/middleware` is also published to [JSR](https://jsr.io/@supabase/middleware) for Deno and Bun environments. + + + + + + + + + ```sh Terminal + deno add jsr:@supabase/middleware + ``` + + + + + ```sh Terminal + bunx jsr add @supabase/middleware + ``` + + + + + + diff --git a/apps/docs/spec/reference/middleware/v1/partials/introduction.mdx b/apps/docs/spec/reference/middleware/v1/partials/introduction.mdx new file mode 100644 index 0000000000000..a73f26d31622d --- /dev/null +++ b/apps/docs/spec/reference/middleware/v1/partials/introduction.mdx @@ -0,0 +1,26 @@ +--- +id: introduction +title: Introduction +--- + +`@supabase/middleware` is a framework-agnostic engine for composing server-side request middleware. You write small units with `defineMiddleware`, compose them with `pipeline`, and run the result as a standard Fetch handler on Node, Deno, Bun, and Cloudflare Workers. + +Each middleware can guard the request, edit the response, or contribute typed values to a shared context that later middleware and your handler read. TypeScript checks the composition: a middleware that needs a value from an earlier middleware will not compile unless that middleware runs first. + + + +`@supabase/middleware` is in alpha. APIs may change between 0.x releases. The `middleware` option on `withSupabase` in `@supabase/server` is also alpha. + + + +This package contains the engine and the framework-neutral middleware: `withCors` and `withFeatureFlag`. Supabase-specific middleware such as `withClaims`, `withSupabaseClient`, `withSupabaseAdminClient`, `withPostgresClient`, and `withPostgresAdminClient` ships in [`@supabase/server`](/docs/reference/server/introduction). + +### Trust model + +A middleware in your pipeline runs with the same access as your own handler code. It sees the full request, everything earlier middleware put on the context, and every response on the way out. Nothing sandboxes it. Only compose middleware you trust as much as your own code. + +Ordering is your permission boundary. A middleware can only read context values that earlier entries contributed, so place third-party middleware as early as possible and sensitive contributions as late as possible. + +### Environment access + +Middleware reads configuration through `getEnv` instead of `process.env`. On Node, Deno, and Bun, `getEnv` reads the process environment. Cloudflare Workers pass env bindings per request instead of exposing a global environment; the pipeline seeds each request's bindings, and `getEnv` reads the ones for the current request. diff --git a/apps/kb/astro.config.mjs b/apps/kb/astro.config.mjs index 23662b95a674e..f44675364a7ed 100644 --- a/apps/kb/astro.config.mjs +++ b/apps/kb/astro.config.mjs @@ -37,6 +37,7 @@ const ssrLodashEs = { // https://astro.build/config export default defineConfig({ base: '/kb', + trailingSlash: 'ignore', integrations: [react()], vite: { ssr: { diff --git a/apps/kb/vercel.json b/apps/kb/vercel.json index 8df7e53755bba..2c4b12e56536d 100644 --- a/apps/kb/vercel.json +++ b/apps/kb/vercel.json @@ -1,3 +1,8 @@ { - "buildCommand": "pnpm build" + "buildCommand": "pnpm build", + "redirects": [{ "source": "/", "destination": "/kb", "permanent": false }], + "rewrites": [ + { "source": "/kb", "destination": "/index.html" }, + { "source": "/kb/:path*", "destination": "/:path*" } + ] } diff --git a/apps/studio/components/interfaces/SignIn/SignIn.utils.ts b/apps/studio/components/interfaces/SignIn/SignIn.utils.ts new file mode 100644 index 0000000000000..e9229aa3aec44 --- /dev/null +++ b/apps/studio/components/interfaces/SignIn/SignIn.utils.ts @@ -0,0 +1,32 @@ +import type HCaptcha from '@hcaptcha/react-hcaptcha' +import type { RefObject } from 'react' +import { toast } from 'sonner' + +import { captureCriticalError } from '@/lib/error-reporting' +import type { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' + +type TrackFunnelError = ReturnType + +export async function resolveCaptchaToken( + captchaRef: RefObject, + trackFunnelError: TrackFunnelError, + toastId: string | number +): Promise<{ ok: true; token: string | null } | { ok: false }> { + try { + const captchaResponse = await captchaRef.current?.execute({ async: true }) + return { ok: true, token: captchaResponse?.response ?? null } + } catch (error) { + toast.error('Could not complete the security check. Please try again.', { id: toastId }) + trackFunnelError( + 'signin', + { errorCategory: 'unknown', errorReason: 'captcha_challenge_failed' }, + 'toast', + toastId + ) + captureCriticalError( + error instanceof Error ? error : new Error(String(error)), + 'sign in captcha challenge' + ) + return { ok: false } + } +} diff --git a/apps/studio/components/interfaces/SignIn/SignInForm.tsx b/apps/studio/components/interfaces/SignIn/SignInForm.tsx index bf0549a81c4cc..258af90808ae9 100644 --- a/apps/studio/components/interfaces/SignIn/SignInForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInForm.tsx @@ -14,13 +14,16 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import { LastSignInWrapper } from './LastSignInWrapper' +import { resolveCaptchaToken } from './SignIn.utils' import { AlertError } from '@/components/ui/AlertError' import { useAddLoginEvent } from '@/data/misc/audit-login-mutation' import { getMfaAuthenticatorAssuranceLevel } from '@/data/profile/mfa-authenticator-assurance-level-query' import { useLastSignIn } from '@/hooks/misc/useLastSignIn' import { captureCriticalError } from '@/lib/error-reporting' import { auth, buildPathWithParams, getReturnToPath } from '@/lib/gotrue' +import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors' import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' const schema = z.object({ email: z.string().min(1, 'Email is required').email('Must be a valid email'), @@ -51,6 +54,7 @@ export const SignInForm = () => { }, []) const track = useTrack() + const trackFunnelError = useTrackFunnelError() const { mutate: addLoginEvent } = useAddLoginEvent() let forgotPasswordUrl = `/forgot-password` @@ -64,8 +68,13 @@ export const SignInForm = () => { let token = captchaToken if (!token) { - const captchaResponse = await captchaRef.current?.execute({ async: true }) - token = captchaResponse?.response ?? null + const captcha = await resolveCaptchaToken(captchaRef, trackFunnelError, toastId) + if (!captcha.ok) { + setCaptchaToken(null) + captchaRef.current?.resetCaptcha() + return + } + token = captcha.token } const { error } = await auth.signInWithPassword({ @@ -100,6 +109,7 @@ export const SignInForm = () => { router.push(redirectPath) } catch (error: any) { toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId }) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError(error, 'sign in via EP') } } else { @@ -107,13 +117,16 @@ export const SignInForm = () => { captchaRef.current?.resetCaptcha() if (error.message.toLowerCase() === 'email not confirmed') { - return toast.error( + toast.error( 'Your account has not been verified. Please check the verification link sent to your email. If you have not received the email or the link has expired, please sign up again to request a new verification link.', { id: toastId } ) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) + return } toast.error(error.message, { id: toastId }) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) } } @@ -125,7 +138,12 @@ export const SignInForm = () => { id={formId} method="POST" className="flex flex-col gap-4" - onSubmit={form.handleSubmit(onSubmit)} + onSubmit={(e) => { + track('sign_in_submitted', { category: 'account', method: 'email' }) + return form.handleSubmit(onSubmit, (errors) => + trackFunnelError('signin', classifyValidationError('signin', errors), 'form') + )(e) + }} > {authError && } { const router = useRouter() + const track = useTrack() + const trackFunnelError = useTrackFunnelError() useEffect(() => { ;(async () => { @@ -18,10 +24,21 @@ export const SignInPartner = () => { const { data } = await auth.getSession() if (!data.session && partner && token) { + // partner comes from the URL hash unauthenticated; only registry-known values may + // enter the method vocabulary, anything else would let a crafted link poison it + const knownPartner = getIdentityProviderConfig(partner) + const method = knownPartner?.id ?? 'unregistered_partner' + track('sign_in_submitted', { + category: 'account', + method, + }) try { - await auth.signInWithIdToken({ provider: partner, token }) + const { error } = await auth.signInWithIdToken({ provider: partner, token }) + if (error) { + trackFunnelError('signin', classifyApiError('signin', error), 'form') + } } finally { - router.replace({ pathname: '/sign-in-mfa', query: { method: partner } }) + router.replace({ pathname: '/sign-in-mfa', query: { method } }) } } else { router.replace({ pathname: '/sign-in' }) diff --git a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx index f6e42e82ce4c7..908f42e204c62 100644 --- a/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInSSOForm.tsx @@ -12,10 +12,14 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import { LastSignInWrapper } from './LastSignInWrapper' +import { resolveCaptchaToken } from './SignIn.utils' import { useLastSignIn } from '@/hooks/misc/useLastSignIn' import { BASE_PATH } from '@/lib/constants' import { captureCriticalError } from '@/lib/error-reporting' import { auth, buildPathWithParams } from '@/lib/gotrue' +import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors' +import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' const schema = z.object({ email: z.string().min(1, 'Email is required').email('Must be a valid email'), @@ -28,6 +32,8 @@ export const SignInSSOForm = () => { const captchaRef = useRef(null) const [captchaToken, setCaptchaToken] = useState(null) const [_, setLastSignInUsed] = useLastSignIn() + const track = useTrack() + const trackFunnelError = useTrackFunnelError() const form = useForm>({ resolver: zodResolver(schema), defaultValues: { email: '' }, @@ -39,8 +45,13 @@ export const SignInSSOForm = () => { let token = captchaToken if (!token) { - const captchaResponse = await captchaRef.current?.execute({ async: true }) - token = captchaResponse?.response ?? null + const captcha = await resolveCaptchaToken(captchaRef, trackFunnelError, toastId) + if (!captcha.ok) { + setCaptchaToken(null) + captchaRef.current?.resetCaptcha() + return + } + token = captcha.token } // redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx) @@ -71,6 +82,7 @@ export const SignInSSOForm = () => { setCaptchaToken(null) captchaRef.current?.resetCaptcha() toast.error(`Failed to sign in: ${error.message}`, { id: toastId }) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError(error, 'sign in via SSO') } } @@ -81,7 +93,12 @@ export const SignInSSOForm = () => { id={formId} method="POST" className="flex flex-col gap-4" - onSubmit={form.handleSubmit(onSubmit)} + onSubmit={(e) => { + track('sign_in_submitted', { category: 'account', method: 'sso' }) + return form.handleSubmit(onSubmit, (errors) => + trackFunnelError('signin', classifyValidationError('signin', errors), 'form') + )(e) + }} > { const [loading, setLoading] = useState(false) const displayName = getProviderDisplay(providerName).displayName + const track = useTrack() + const trackFunnelError = useTrackFunnelError() async function handleCustomSignIn() { setLoading(true) + track('sign_in_submitted', { category: 'account', method: providerName.toLowerCase() }) try { // redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx) @@ -36,7 +42,8 @@ export const SignInWithCustom = ({ providerName }: SignInWithCustomProps) => { if (error) throw error } catch (error: any) { - toast.error(`Failed to sign in via ${displayName}: ${error.message}`) + const toastId = toast.error(`Failed to sign in via ${displayName}: ${error.message}`) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError(error, `sign in via ${providerName}`) setLoading(false) } diff --git a/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx b/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx index 51ed1debe9a7c..bf3ee81a69c7a 100644 --- a/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInWithExternalProvider.tsx @@ -14,6 +14,9 @@ import { } from '@/lib/external-identity-providers' import { getErrorMessage } from '@/lib/get-error-message' import { auth, buildPathWithParams } from '@/lib/gotrue' +import { classifyApiError } from '@/lib/telemetry/funnel-errors' +import { useTrack } from '@/lib/telemetry/track' +import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error' interface SignInWithExternalProviderProps { provider: ExternalIdentityProviderConfig @@ -22,9 +25,12 @@ interface SignInWithExternalProviderProps { export const SignInWithExternalProvider = ({ provider }: SignInWithExternalProviderProps) => { const [loading, setLoading] = useState(false) const [, setLastSignInUsed] = useLastSignIn() + const track = useTrack() + const trackFunnelError = useTrackFunnelError() async function handleSignIn() { setLoading(true) + track('sign_in_submitted', { category: 'account', method: provider.id }) try { // Redirects to /sign-in-mfa to check if the user has MFA set up before entering the dashboard @@ -40,7 +46,8 @@ export const SignInWithExternalProvider = ({ provider }: SignInWithExternalProvi setLastSignInUsed(provider.id) } catch (error: unknown) { const message = getErrorMessage(error) ?? 'Unknown error' - toast.error(`Failed to sign in via ${provider.displayName}: ${message}`) + const toastId = toast.error(`Failed to sign in via ${provider.displayName}: ${message}`) + trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId) captureCriticalError( error instanceof Error ? error : new Error(message), `sign in via ${provider.displayName}` diff --git a/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx b/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx index 1056bd7295bb5..183af913a42d2 100644 --- a/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx +++ b/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx @@ -1,12 +1,17 @@ import { useQuery } from '@tanstack/react-query' import { useParams } from 'common' -import { RefreshCw } from 'lucide-react' +import { RefreshCw, Search } from 'lucide-react' import { useState } from 'react' -import { Button } from 'ui' +import { Button, InputGroup, InputGroupAddon, InputGroupInput } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { WorkerCommandLine } from '../WorkerCommandLine' import { WorkersLogsColumnRender } from '@/components/interfaces/Settings/Logs/LogColumnRenderers/WorkersLogsColumnRender' +import { EXPLORER_DATEPICKER_HELPERS } from '@/components/interfaces/Settings/Logs/Logs.constants' +import { + LogsDatePicker, + type DatePickerValue, +} from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types' import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable' import { AlertError } from '@/components/ui/AlertError' @@ -15,6 +20,7 @@ import { workerLogsQueryOptions, type WorkerLogStream, } from '@/data/workers/worker-logs-query' +import { useDebouncedValue } from '@/hooks/misc/useDebouncedValue' import { CLI_NAME } from '@/lib/constants/workers' interface WorkerLogsTabProps { @@ -22,9 +28,23 @@ interface WorkerLogsTabProps { stream: WorkerLogStream } +const defaultDateRange = (): DatePickerValue => { + const helper = EXPLORER_DATEPICKER_HELPERS.find((helper) => helper.text === 'Last 24 hours')! + + return { + from: helper.calcFrom(), + to: helper.calcTo(), + isHelper: true, + text: helper.text, + } +} + export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => { const { ref: projectRef } = useParams() const [selectedLog, setSelectedLog] = useState(null) + const [dateRange, setDateRange] = useState(defaultDateRange) + const [message, setMessage] = useState('') + const debouncedMessage = useDebouncedValue(message, 300) const { data: logs, @@ -33,13 +53,42 @@ export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => { isError, isFetching, refetch, - } = useQuery(workerLogsQueryOptions({ projectRef, name: workerName, stream })) + } = useQuery( + workerLogsQueryOptions({ + projectRef, + name: workerName, + stream, + iso_timestamp_start: dateRange.from, + iso_timestamp_end: dateRange.to, + message: debouncedMessage, + }) + ) const label = WORKER_LOG_STREAM_LABEL[stream].toLowerCase() return (
-
+
+
+ + + setMessage(event.target.value)} + /> + + + + +
diff --git a/apps/studio/components/interfaces/Workers/workerSnippets.test.ts b/apps/studio/components/interfaces/Workers/workerSnippets.test.ts index 77f434a143731..bf8c212e7e50b 100644 --- a/apps/studio/components/interfaces/Workers/workerSnippets.test.ts +++ b/apps/studio/components/interfaces/Workers/workerSnippets.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' -import { WORKERS_REGION } from './Workers.constants' import { buildWorkerCliCommands, buildWorkerSnippets, EXAMPLE_WORKER } from './workerSnippets' const input = (overrides: Partial[0]> = {}) => ({ @@ -10,73 +9,19 @@ const input = (overrides: Partial[0]> = { }) describe('buildWorkerSnippets', () => { - it('points the invoke examples at the worker URL', () => { - const { curl, javascript, python } = buildWorkerSnippets(input({ name: 'embed' })) - const url = 'https://abcdefgh.supabase.co/workers/v1/embed' + it('passes private exposure to the CLI and config', () => { + const { cli, configToml } = buildWorkerSnippets(input({ access: 'private' })) - expect(curl).toContain(`'${url}'`) - expect(javascript).toContain(`'${url}'`) - expect(python).toContain(`"${url}"`) - }) - - it('leaves a placeholder invoke URL until the project settings resolve', () => { - const { curl } = buildWorkerSnippets(input({ endpoint: undefined })) - expect(curl).toContain('[YOUR WORKER URL]') - }) - - it('does not require authorization for the CLI invoke example', () => { - expect(buildWorkerSnippets(input()).curl).not.toContain('Authorization') - }) - - it('asks for the anon key to invoke a public worker and the service role key for a private one', () => { - expect(buildWorkerSnippets(input({ access: 'public' })).javascript).toContain('[YOUR ANON KEY]') - expect(buildWorkerSnippets(input({ access: 'private' })).javascript).toContain( - '[YOUR SERVICE ROLE KEY]' - ) - }) - - it('falls back to a placeholder name when the worker has none', () => { - expect(buildWorkerSnippets(input({ name: ' ' })).cli).toContain('my-worker') - }) - - it('trims the name before interpolating it', () => { - expect(buildWorkerSnippets(input({ name: ' embed ' })).cli).toContain('new embed --runtime') - }) - - it('defaults the runtime when the API omits it', () => { - expect(buildWorkerSnippets(input({ runtime: undefined })).cli).toContain('--runtime node') - }) - - it('writes the worker spec into the config.toml block', () => { - const { configToml } = buildWorkerSnippets( - input({ - name: 'embed', - runtime: 'python', - size: '4gb-2vcpu', - access: 'private', - instances: 3, - }) - ) - - expect(configToml).toContain('[workers.embed]') - expect(configToml).toContain('runtime = "python"') - expect(configToml).toContain('size = "4gb-2vcpu" # 4 GB · 2 vCPU') - expect(configToml).toContain('access = "private"') - expect(configToml).toContain('instances = 3') - expect(configToml).toContain(WORKERS_REGION) + expect(cli).toContain('--exposure private') + expect(configToml).toContain('exposure = "private"') }) }) describe('buildWorkerCliCommands', () => { - it('names the worker in every command', () => { + it('targets the requested worker in every management command', () => { const commands = buildWorkerCliCommands('embed') - expect(commands).not.toHaveLength(0) - for (const { command } of commands) { - expect(command).toContain('embed') - } - }) - it('falls back to a placeholder name when the worker has none', () => { - expect(buildWorkerCliCommands(' ')[0].command).toContain('my-worker') + expect(commands).toHaveLength(4) + expect(commands.every(({ command }) => command.includes('embed'))).toBe(true) }) }) diff --git a/apps/studio/components/interfaces/Workers/workerSnippets.ts b/apps/studio/components/interfaces/Workers/workerSnippets.ts index 7cc337d332c39..aaea57235f50a 100644 --- a/apps/studio/components/interfaces/Workers/workerSnippets.ts +++ b/apps/studio/components/interfaces/Workers/workerSnippets.ts @@ -41,10 +41,7 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets { const cli = [ `supabase ${CLI_NAME} new ${name} --runtime ${runtime}`, - // size comes from config.toml — push has no flag for it. Same for access: the CLI - // doesn't have a route to a private worker yet, so this always deploys as public. - `supabase ${CLI_NAME} push ${name} --instances ${input.instances}`, - ...(input.access === 'private' ? [`# note: the CLI can only deploy public workers today`] : []), + `supabase ${CLI_NAME} push ${name} --instances ${input.instances} --exposure ${input.access}`, ].join('\n') const curl = [ @@ -57,7 +54,7 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets { `[${CLI_NAME}.${name}]`, `runtime = "${runtime}"`, `size = "${input.size}" # ${formatSize(input.size)}`, - `access = "${input.access}"`, + `exposure = "${input.access}"`, `instances = ${input.instances}`, `# region is locked to ${WORKERS_REGION} at alpha`, ].join('\n') @@ -78,10 +75,7 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets { configBlock, '```', ``, - `3. Run \`supabase ${CLI_NAME} push ${name}\` to deploy it.`, - ...(input.access === 'private' - ? [``, `Note: the CLI can only deploy public workers today.`] - : []), + `3. Run \`supabase ${CLI_NAME} push ${name} --exposure ${input.access}\` to deploy it.`, ].join('\n') const keyPlaceholder = input.access === 'public' ? '[YOUR ANON KEY]' : '[YOUR SERVICE ROLE KEY]' diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx index 2011c7b5ff99a..5414ed312a692 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -27,6 +27,7 @@ import { useCreateQuery, } from '@/components/interfaces/Explorer/hooks' import { useIsTemporarySqlEditorVisit } from '@/hooks/misc/useIsTemporarySqlEditorVisit' +import { useTrack } from '@/lib/telemetry/track' import { editorEntityTypes, EXPLORER_HOME_TAB, @@ -102,6 +103,7 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout const BackToSqlEditorButton = () => { const { ref } = useParams() + const track = useTrack() const { setIsTemporary } = useIsTemporarySqlEditorVisit(ref) if (!ref) return null @@ -114,7 +116,10 @@ const BackToSqlEditorButton = () => { setIsTemporary(true)} + onClick={() => { + setIsTemporary(true) + track('explorer_temp_access_sql_editor_clicked') + }} /> ) diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx index 78e3bfc29960c..7f61a8a5b9fe6 100644 --- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx +++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx @@ -9,6 +9,7 @@ import { CollapseButton } from '../Tabs/CollapseButton' import { EditorTabs } from '../Tabs/Tabs' import { useEditorType } from './EditorsLayout.hooks' import { useIsTemporarySqlEditorVisit } from '@/hooks/misc/useIsTemporarySqlEditorVisit' +import { useTrack } from '@/lib/telemetry/track' import { useTabsStateSnapshot } from '@/state/tabs' export interface ExplorerLayoutProps extends ComponentProps { @@ -86,6 +87,7 @@ export const EditorBaseLayout = ({ const BackToExplorerButton = () => { const { ref } = useParams() const router = useRouter() + const track = useTrack() const { isTemporary, setIsTemporary } = useIsTemporarySqlEditorVisit(ref) if (!ref || !isTemporary) return null @@ -95,6 +97,7 @@ const BackToExplorerButton = () => { tooltip="Back to Explorer" onClick={() => { setIsTemporary(false) + track('sql_editor_back_explorer_clicked') router.push(`/project/${ref}/explorer`) }} /> diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts index fe13a029f6fdd..4562af634fc49 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts @@ -48,6 +48,38 @@ const agentDatabaseCell = (database_identifier?: string): AgentCell => ({ database_identifier, }) +const chartDatabaseCell = (id: string, ySeries: string[]): CellWire => ({ + _tag: 'database_cell', + _id: id, + sql: 'select 1', + row_limit: 100, + view: 'chart', + chart: { + type: 'bar', + x_column: 'day', + y_series: ySeries, + cumulative: false, + scale: 'linear', + show_labels: false, + }, +}) + +const agentChartDatabaseCell = (ySeries: string[], database_identifier?: string): AgentCell => ({ + _tag: 'database_cell', + sql: 'select 1', + row_limit: 100, + database_identifier, + view: 'chart', + chart: { + type: 'bar', + x_column: 'day', + y_series: ySeries, + cumulative: false, + scale: 'linear', + show_labels: false, + }, +}) + const successfulDatabaseContext = ( databases: Array<{ identifier: string; region: string }> = [] ): NotebookDatabaseContext => ({ @@ -217,10 +249,11 @@ describe('getCellMetadata', () => { }) }) - it('labels an implicit primary database', () => { + it('labels an implicit primary database and table view', () => { expect(getCellMetadata(wireDatabaseCell('cell-1'), successfulDatabaseContext())).toEqual({ status: 'ready', - text: 'Database: Primary', + source: 'Database: Primary', + view: 'Table', }) }) @@ -232,7 +265,8 @@ describe('getCellMetadata', () => { ) ).toEqual({ status: 'ready', - text: 'Database: Replica', + source: 'Database: Replica', + view: 'Table', }) }) @@ -251,13 +285,24 @@ describe('getCellMetadata', () => { wireDatabaseCell('cell-1', 'Signups', 'missing-database'), successfulDatabaseContext() ) - ).toEqual({ status: 'ready', text: 'Database: Unknown' }) + ).toEqual({ status: 'ready', source: 'Database: Unknown', view: 'Table' }) }) it('labels a log cell with its formatted time range', () => { expect(getCellMetadata(wireLogCell('cell-1'), successfulDatabaseContext())).toEqual({ status: 'ready', - text: 'Time range: Last 7 days', + source: 'Time range: Last 7 days', + view: 'Table', + }) + }) + + it('reports the chart summary as the view field for a chart-view database cell', () => { + expect( + getCellMetadata(chartDatabaseCell('cell-1', ['signups']), successfulDatabaseContext()) + ).toEqual({ + status: 'ready', + source: 'Database: Primary', + view: 'Chart (bar, x: day, y: signups)', }) }) }) @@ -309,7 +354,7 @@ describe('getEntryMetadata', () => { ).toEqual({ status: 'loading' }) }) - it('returns a single line when replacement metadata is unchanged', () => { + it('hides metadata entirely when a replacement changes neither the database nor the view', () => { expect( getEntryMetadata( { @@ -320,7 +365,58 @@ describe('getEntryMetadata', () => { }, successfulDatabaseContext() ) - ).toEqual({ status: 'ready', text: 'Database: Primary' }) + ).toEqual({ status: 'hidden' }) + }) + + it('surfaces only the view change from table to chart, omitting the unchanged database', () => { + expect( + getEntryMetadata( + { + _tag: 'replaced', + before: wireDatabaseCell('cell-1'), + after: agentChartDatabaseCell(['signups']), + operationIndex: 0, + }, + successfulDatabaseContext() + ) + ).toEqual({ + status: 'ready', + text: 'Table → Chart (bar, x: day, y: signups)', + }) + }) + + it('surfaces only a chart parameter change, omitting the unchanged database', () => { + expect( + getEntryMetadata( + { + _tag: 'replaced', + before: chartDatabaseCell('cell-1', ['signups']), + after: agentChartDatabaseCell(['active_users']), + operationIndex: 0, + }, + successfulDatabaseContext() + ) + ).toEqual({ + status: 'ready', + text: 'Chart (bar, x: day, y: signups) → Chart (bar, x: day, y: active_users)', + }) + }) + + it('surfaces only a database change, omitting the unchanged view', () => { + expect( + getEntryMetadata( + { + _tag: 'replaced', + before: chartDatabaseCell('cell-1', ['signups']), + after: agentChartDatabaseCell(['signups'], 'replica-3'), + operationIndex: 0, + }, + successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }]) + ) + ).toEqual({ + status: 'ready', + text: 'Database: Primary → Database: Replica', + }) }) }) diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts index a8717269f6a5d..bedbe9ae1e89c 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts @@ -5,7 +5,7 @@ import type { NotebookCellDiffEntry, OperationResultCell, } from '@/data/content/notebooks/notebook-operations' -import type { TimeRange } from '@/data/content/notebooks/notebook-schema' +import type { ChartConfig, TimeRange } from '@/data/content/notebooks/notebook-schema' import type { Database } from '@/data/read-replicas/replicas-query' type DatabaseDetails = Pick @@ -23,6 +23,11 @@ export type NotebookDatabaseTarget = | { status: 'unknown' } | { status: 'error' } +export type NotebookCellFields = + | { status: 'hidden' } + | { status: 'loading' } + | { status: 'ready'; source?: string; view?: string } + export type NotebookCellMetadata = | { status: 'hidden' } | { status: 'loading' } @@ -139,49 +144,83 @@ function formatDatabaseTarget(target: Exclude +): string { + if ((cell.view ?? 'table') !== 'chart') return 'Table' + return `Chart (${cell.chart !== undefined ? formatChartConfig(cell.chart) : 'unconfigured'})` +} + export function getCellMetadata( cell: OperationResultCell, databaseContext: NotebookDatabaseContext -): NotebookCellMetadata { +): NotebookCellFields { switch (cell._tag) { case 'markdown_cell': return { status: 'hidden' } case 'database_cell': { const target = resolveNotebookDatabaseTarget(cell.database_identifier, databaseContext) - return target.status === 'loading' - ? { status: 'loading' } - : { status: 'ready', text: formatDatabaseTarget(target) } + if (target.status === 'loading') return { status: 'loading' } + + return { status: 'ready', source: formatDatabaseTarget(target), view: formatCellView(cell) } } case 'log_cell': - return { status: 'ready', text: `Time range: ${formatTimeRange(cell.time_range)}` } + return { + status: 'ready', + source: `Time range: ${formatTimeRange(cell.time_range)}`, + view: formatCellView(cell), + } } } -/** Header metadata for a diff row, including a before → after pair on replacements. */ +/** Joins a cell's fields into display text, dropping the view when it's just the default table. */ +function formatCellFieldsText(fields: { source?: string; view?: string }): string { + return [fields.source, fields.view === 'Table' ? undefined : fields.view] + .filter((part): part is string => part !== undefined) + .join(' · ') +} + +/** A field that's identical before and after carries no information about what changed, so it's dropped. */ +function diffField(before: string | undefined, after: string | undefined): string | undefined { + if (before === after) return undefined + return `${before ?? 'Not configured'} → ${after ?? 'Not configured'}` +} + +/** Header metadata for a diff row. On a replacement, only the fields that actually changed are shown. */ export function getEntryMetadata( entry: NotebookCellDiffEntry, databaseContext: NotebookDatabaseContext ): NotebookCellMetadata { if (entry._tag !== 'replaced') { - return getCellMetadata(entry.cell, databaseContext) - } + const fields = getCellMetadata(entry.cell, databaseContext) + if (fields.status !== 'ready') return fields - const beforeMetadata = getCellMetadata(entry.before, databaseContext) - const afterMetadata = getCellMetadata(entry.after, databaseContext) - if (beforeMetadata.status === 'loading' || afterMetadata.status === 'loading') { - return { status: 'loading' } + const text = formatCellFieldsText(fields) + return text === '' ? { status: 'hidden' } : { status: 'ready', text } } - const beforeText = beforeMetadata.status === 'ready' ? beforeMetadata.text : null - const afterText = afterMetadata.status === 'ready' ? afterMetadata.text : null - if (beforeText === null && afterText === null) return { status: 'hidden' } - if (beforeText === afterText) return { status: 'ready', text: afterText ?? 'No metadata' } + const before = getCellMetadata(entry.before, databaseContext) + const after = getCellMetadata(entry.after, databaseContext) + if (before.status === 'loading' || after.status === 'loading') return { status: 'loading' } - return { - status: 'ready', - text: `${beforeText ?? 'No metadata'} → ${afterText ?? 'No metadata'}`, - } + const beforeSource = before.status === 'ready' ? before.source : undefined + const afterSource = after.status === 'ready' ? after.source : undefined + const beforeView = before.status === 'ready' ? before.view : undefined + const afterView = after.status === 'ready' ? after.view : undefined + + const parts = [diffField(beforeSource, afterSource), diffField(beforeView, afterView)].filter( + (part): part is string => part !== undefined + ) + + return parts.length > 0 ? { status: 'ready', text: parts.join(' · ') } : { status: 'hidden' } } export type NotebookDiffSummary = diff --git a/apps/studio/data/reports/database-charts.test.ts b/apps/studio/data/reports/database-charts.test.ts index 1fdf6e2c06b5b..8d41b0365877d 100644 --- a/apps/studio/data/reports/database-charts.test.ts +++ b/apps/studio/data/reports/database-charts.test.ts @@ -77,6 +77,29 @@ describe('getReportAttributesV2 dedicated pooler chart', () => { }) }) +const getSupavisorChart = (project: Project) => + getReportAttributesV2(ENTITLED_FEATURES, project).find( + (chart) => chart.id === 'supavisor-connections-active' + ) + +describe('getReportAttributesV2 shared pooler chart', () => { + it('shows the chart for standard projects', () => { + expect(getSupavisorChart(buildProject())?.hide).toBe(false) + }) + + it('hides the chart for high availability projects', () => { + expect(getSupavisorChart(buildProject({ high_availability: true }))?.hide).toBe(true) + }) + + it('hides the chart when the database entitlement is missing', () => { + expect( + getReportAttributesV2([], buildProject()).find( + (chart) => chart.id === 'supavisor-connections-active' + )?.hide + ).toBe(true) + }) +}) + describe('getReportAttributesV2 disk-io-burst-balance chart', () => { it('shows the chart for burstable non high availability projects', () => { expect(getBurstBalanceChart(buildProject())?.hide).toBe(false) diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index 0615d1f7052cd..65e300266e016 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -541,7 +541,8 @@ export const getReportAttributesV2: ( valuePrecision: 0, entitlement: 'database', requiredPlan: 'Pro', - hide: !entitledFeatures.includes('database'), + // High Availability projects don't run Supavisor, so there's no data to show. + hide: !entitledFeatures.includes('database') || isHighAvailability, showTooltip: true, showLegend: false, showMaxValue: false, diff --git a/apps/studio/data/workers/keys.ts b/apps/studio/data/workers/keys.ts index 9eedc104d21fd..cdbce56445254 100644 --- a/apps/studio/data/workers/keys.ts +++ b/apps/studio/data/workers/keys.ts @@ -2,6 +2,14 @@ export const workersKeys = { list: (projectRef: string | undefined) => ['projects', projectRef, 'workers'] as const, detail: (projectRef: string | undefined, name: string | undefined) => ['projects', projectRef, 'worker', name, 'detail'] as const, - logs: (projectRef: string | undefined, name: string | undefined, stream: string) => - ['projects', projectRef, 'worker', name, 'logs', stream] as const, + logs: ( + projectRef: string | undefined, + name: string | undefined, + stream: string, + filters: { + iso_timestamp_start?: string + iso_timestamp_end?: string + message?: string + } + ) => ['projects', projectRef, 'worker', name, 'logs', stream, filters] as const, } diff --git a/apps/studio/data/workers/worker-logs-query.test.ts b/apps/studio/data/workers/worker-logs-query.test.ts index 2c909f8459d26..baf6d79a80ddb 100644 --- a/apps/studio/data/workers/worker-logs-query.test.ts +++ b/apps/studio/data/workers/worker-logs-query.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' +import { workersKeys } from './keys' import { parseWorkerLogRows, workerLogsSql } from './worker-logs-query' describe('workerLogsSql', () => { @@ -9,6 +10,12 @@ describe('workerLogsSql', () => { ) }) + it('filters by event message before applying the limit', () => { + expect(workerLogsSql('embed', 'requests', { message: 'timeout' })).toBe( + "select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes['worker'] = 'embed' and log_attributes['source'] = 'worker_ingress_logs' and event_message ilike '%timeout%' order by timestamp desc limit 100" + ) + }) + it('names the right stream for each tab', () => { expect(workerLogsSql('embed', 'requests')).toContain("'worker_ingress_logs'") expect(workerLogsSql('embed', 'builds')).toContain("'worker_api_logs'") @@ -19,6 +26,36 @@ describe('workerLogsSql', () => { "log_attributes['worker'] = 'embed'' or ''1''=''1'" ) }) + + it('escapes filter values rather than interpolating them raw', () => { + expect(workerLogsSql('embed', 'requests', { message: "can't connect" })).toContain( + "event_message ilike '%can''t connect%'" + ) + }) +}) + +describe('workersKeys.logs', () => { + it('includes the selected time range and filters', () => { + expect( + workersKeys.logs('project-ref', 'embed', 'requests', { + iso_timestamp_start: '2026-09-01T12:00:00.000Z', + iso_timestamp_end: '2026-09-02T12:00:00.000Z', + message: 'timeout', + }) + ).toEqual([ + 'projects', + 'project-ref', + 'worker', + 'embed', + 'logs', + 'requests', + { + iso_timestamp_start: '2026-09-01T12:00:00.000Z', + iso_timestamp_end: '2026-09-02T12:00:00.000Z', + message: 'timeout', + }, + ]) + }) }) describe('parseWorkerLogRows', () => { diff --git a/apps/studio/data/workers/worker-logs-query.ts b/apps/studio/data/workers/worker-logs-query.ts index eb68cd8968ca9..7fb4cc10ca82c 100644 --- a/apps/studio/data/workers/worker-logs-query.ts +++ b/apps/studio/data/workers/worker-logs-query.ts @@ -1,5 +1,4 @@ import { queryOptions } from '@tanstack/react-query' -import dayjs from 'dayjs' import { z } from 'zod' import { workersKeys } from './keys' @@ -29,7 +28,6 @@ export const WORKER_LOG_STREAM_LABEL: Record = { const WORKER_NAME_KEY = 'worker' const STREAM_KEY = 'source' -const LOOKBACK_HOURS = 24 const LOG_LIMIT = 100 const workerLogRowSchema = z.object({ @@ -43,10 +41,22 @@ export type WorkerLogsVariables = { projectRef?: string name?: string stream: WorkerLogStream + iso_timestamp_start: string + iso_timestamp_end: string + message?: string } -export const workerLogsSql = (name: string, stream: WorkerLogStream) => - safeSql`select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes[${analyticsLiteral(WORKER_NAME_KEY)}] = ${analyticsLiteral(name)} and log_attributes[${analyticsLiteral(STREAM_KEY)}] = ${analyticsLiteral(WORKER_LOG_SOURCES[stream])} order by timestamp desc limit ${analyticsLiteral(LOG_LIMIT)}` +export const workerLogsSql = ( + name: string, + stream: WorkerLogStream, + { message }: Pick = {} +) => { + const messageFilter = message + ? safeSql` and event_message ilike ${analyticsLiteral(`%${message}%`)}` + : safeSql`` + + return safeSql`select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes[${analyticsLiteral(WORKER_NAME_KEY)}] = ${analyticsLiteral(name)} and log_attributes[${analyticsLiteral(STREAM_KEY)}] = ${analyticsLiteral(WORKER_LOG_SOURCES[stream])}${messageFilter} order by timestamp desc limit ${analyticsLiteral(LOG_LIMIT)}` +} export const parseWorkerLogRows = (result: unknown): LogData[] => z @@ -60,30 +70,46 @@ export const parseWorkerLogRows = (result: unknown): LogData[] => })) async function getWorkerLogs( - { projectRef, name, stream }: WorkerLogsVariables, + { + projectRef, + name, + stream, + iso_timestamp_start, + iso_timestamp_end, + message, + }: WorkerLogsVariables, signal?: AbortSignal ): Promise { if (!projectRef) throw new Error('projectRef is required') if (!name) throw new Error('name is required') - const end = dayjs() - const start = end.subtract(LOOKBACK_HOURS, 'hour') - const data = await executeAnalyticsSql({ projectRef, endpoint: logsAllEndpointUrl(true), - sql: workerLogsSql(name, stream), - iso_timestamp_start: start.toISOString(), - iso_timestamp_end: end.toISOString(), + sql: workerLogsSql(name, stream, { message }), + iso_timestamp_start, + iso_timestamp_end, signal, }) return parseWorkerLogRows(data?.result) } -export const workerLogsQueryOptions = ({ projectRef, name, stream }: WorkerLogsVariables) => - queryOptions({ - queryKey: workersKeys.logs(projectRef, name, stream), - queryFn: ({ signal }) => getWorkerLogs({ projectRef, name, stream }, signal), +export const workerLogsQueryOptions = (variables: WorkerLogsVariables) => { + const { projectRef, name, stream, iso_timestamp_start, iso_timestamp_end } = variables + const message = variables.message?.trim() || undefined + + return queryOptions({ + queryKey: workersKeys.logs(projectRef, name, stream, { + iso_timestamp_start, + iso_timestamp_end, + message, + }), + queryFn: ({ signal }) => + getWorkerLogs( + { projectRef, name, stream, iso_timestamp_start, iso_timestamp_end, message }, + signal + ), enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof name !== 'undefined', }) +} diff --git a/apps/studio/data/workers/workers-query.ts b/apps/studio/data/workers/workers-query.ts index 5aaed1734df58..a74453ffa6335 100644 --- a/apps/studio/data/workers/workers-query.ts +++ b/apps/studio/data/workers/workers-query.ts @@ -28,4 +28,5 @@ export const workersQueryOptions = ({ projectRef }: WorkersVariables) => queryKey: workersKeys.list(projectRef), queryFn: ({ signal }) => getWorkers({ projectRef }, signal), enabled: IS_PLATFORM && typeof projectRef !== 'undefined', + refetchOnWindowFocus: 'always', }) diff --git a/apps/studio/lib/telemetry/funnel-errors.test.ts b/apps/studio/lib/telemetry/funnel-errors.test.ts index 6abe0049b6d73..161a1c79df08e 100644 --- a/apps/studio/lib/telemetry/funnel-errors.test.ts +++ b/apps/studio/lib/telemetry/funnel-errors.test.ts @@ -58,6 +58,84 @@ describe('classifyApiError', () => { errorCode: 400, }) }) + + describe('signin', () => { + it('reads a GoTrue AuthError status as the code', () => { + expect( + classifyApiError('signin', { status: 400, message: 'Invalid login credentials' }) + ).toEqual({ + errorCategory: 'api', + errorReason: 'invalid_credentials', + errorCode: 400, + }) + }) + + it('classifies an unconfirmed email', () => { + expect(classifyApiError('signin', { status: 400, message: 'Email not confirmed' })).toEqual({ + errorCategory: 'api', + errorReason: 'email_not_confirmed', + errorCode: 400, + }) + }) + + it('classifies 429 via status as rate_limited', () => { + expect(classifyApiError('signin', { status: 429, message: 'Rate limit exceeded' })).toEqual({ + errorCategory: 'api', + errorReason: 'rate_limited', + errorCode: 429, + }) + }) + + it('classifies a captcha failure', () => { + expect( + classifyApiError('signin', { status: 400, message: 'captcha verification process failed' }) + ).toEqual({ errorCategory: 'api', errorReason: 'captcha_failed', errorCode: 400 }) + }) + + it('matches the SSO pattern before the 404 status map', () => { + expect( + classifyApiError('signin', { + status: 404, + message: 'No SSO provider assigned for this domain', + }) + ).toEqual({ errorCategory: 'api', errorReason: 'sso_provider_not_found', errorCode: 404 }) + }) + + it('classifies a redirect allow-list rejection', () => { + expect(classifyApiError('signin', { status: 400, message: 'Invalid redirect URL' })).toEqual({ + errorCategory: 'api', + errorReason: 'redirect_not_allowed', + errorCode: 400, + }) + }) + + it('classifies a disabled provider', () => { + expect( + classifyApiError('signin', { + status: 400, + message: 'Unsupported provider: provider is not enabled', + }) + ).toEqual({ errorCategory: 'api', errorReason: 'provider_not_enabled', errorCode: 400 }) + }) + + it('classifies a GoTrue transport failure (status 0) as network_error, never api/other', () => { + expect( + classifyApiError('signin', { + name: 'AuthRetryableFetchError', + status: 0, + message: 'Failed to fetch', + }) + ).toEqual({ errorCategory: 'network', errorReason: 'network_error' }) + }) + + it('classifies a retryable 5xx via status as server_error', () => { + expect(classifyApiError('signin', { status: 503, message: 'Service unavailable' })).toEqual({ + errorCategory: 'api', + errorReason: 'server_error', + errorCode: 503, + }) + }) + }) }) describe('classifyValidationError', () => { @@ -79,6 +157,15 @@ describe('classifyValidationError', () => { ).toEqual({ errorCategory: 'validation', errorReason: 'email_invalid' }) }) + it('maps a signin password error to password_invalid', () => { + expect( + classifyValidationError('signin', { password: { type: 'too_small' } } as FieldErrors) + ).toEqual({ + errorCategory: 'validation', + errorReason: 'password_invalid', + }) + }) + it('maps an org name error to org_name_missing', () => { expect( classifyValidationError('org_creation', { name: { type: 'too_small' } } as FieldErrors) diff --git a/apps/studio/lib/telemetry/funnel-errors.ts b/apps/studio/lib/telemetry/funnel-errors.ts index 67c92592fed41..d4873ae20398f 100644 --- a/apps/studio/lib/telemetry/funnel-errors.ts +++ b/apps/studio/lib/telemetry/funnel-errors.ts @@ -1,6 +1,6 @@ import type { FieldErrors } from 'react-hook-form' -export type FunnelOrigin = 'signup' | 'project_creation' | 'org_creation' +export type FunnelOrigin = 'signup' | 'signin' | 'project_creation' | 'org_creation' export type ErrorCategory = 'validation' | 'api' | 'network' | 'payment' | 'unknown' export interface FunnelErrorClassification { @@ -19,6 +19,16 @@ const API_REASON_PATTERNS = { [/password/i, 'password_rejected'], [/valid email|invalid email|email address/i, 'email_invalid'], ], + signin: [ + [/invalid login credentials/i, 'invalid_credentials'], + [/email not confirmed/i, 'email_not_confirmed'], + [/rate limit|too many requests|after \d+ second/i, 'rate_limited'], + [/captcha/i, 'captcha_failed'], + [/sso provider/i, 'sso_provider_not_found'], + [/redirect|requested path is invalid/i, 'redirect_not_allowed'], + [/provider is not enabled|unsupported provider/i, 'provider_not_enabled'], + [/valid email|invalid email|email address/i, 'email_invalid'], + ], project_creation: [ [/already exists/i, 'project_name_taken'], [/free plan|free tier/i, 'free_tier_limit'], @@ -39,6 +49,10 @@ const VALIDATION_FIELD_REASONS = { email: 'email_invalid', password: 'password_invalid', }, + signin: { + email: 'email_invalid', + password: 'password_invalid', + }, project_creation: { organization: 'organization_missing', projectName: 'project_name_invalid', @@ -67,6 +81,7 @@ const STRIPE_DECLINE_REASONS = { } as const satisfies Record const GENERIC_REASONS = [ + 'captcha_challenge_failed', 'rate_limited', 'server_error', 'connection_timeout', @@ -95,8 +110,16 @@ const STATUS_REASONS: Readonly>> = { } export function classifyApiError(origin: FunnelOrigin, error: unknown): FunnelErrorClassification { - const err = error as { code?: unknown; errorType?: unknown; message?: unknown } - const code = typeof err?.code === 'number' ? err.code : undefined + const err = error as { code?: unknown; status?: unknown; errorType?: unknown; message?: unknown } + // GoTrue AuthErrors carry a numeric `status` and a string `code` slug; auth-js uses + // status 0 for transport failures (AuthRetryableFetchError), which must classify as + // network_error, so the fallback only accepts positive statuses. + const code = + typeof err?.code === 'number' + ? err.code + : typeof err?.status === 'number' && err.status > 0 + ? err.status + : undefined const message = typeof err?.message === 'string' ? err.message : '' if (err?.errorType === 'connection-timeout') { diff --git a/apps/studio/lib/toast-errors.test.tsx b/apps/studio/lib/toast-errors.test.tsx index 07e0971d3adaf..60f0141b9d1e9 100644 --- a/apps/studio/lib/toast-errors.test.tsx +++ b/apps/studio/lib/toast-errors.test.tsx @@ -76,6 +76,33 @@ describe('ToastErrorTracker', () => { expect(mockTrack).toHaveBeenCalledTimes(1) }) + it('tracks a loading toast updated to an error exactly once (sign-in reuses the loading toast id)', async () => { + render() + let toastId: string | number + act(() => { + toastId = toast.loading('Signing in...') + }) + act(() => { + toast.error('Invalid login credentials', { id: toastId }) + registerFunnelErrorToast(toastId, { + origin: 'signin', + errorCategory: 'api', + errorReason: 'invalid_credentials', + errorCode: 400, + }) + }) + await waitFor(() => + expect(mockTrack).toHaveBeenCalledWith('dashboard_error_created', { + source: 'toast', + origin: 'signin', + errorCategory: 'api', + errorReason: 'invalid_credentials', + errorCode: 400, + }) + ) + expect(mockTrack).toHaveBeenCalledTimes(1) + }) + it('ignores non-error toasts', async () => { render() act(() => { diff --git a/apps/studio/pages/project/[ref]/workers/index.tsx b/apps/studio/pages/project/[ref]/workers/index.tsx index 73debf88890ce..327dfd8e7320f 100644 --- a/apps/studio/pages/project/[ref]/workers/index.tsx +++ b/apps/studio/pages/project/[ref]/workers/index.tsx @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query' import { useParams } from 'common' +import { RefreshCw } from 'lucide-react' import { useState } from 'react' +import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { @@ -38,6 +40,8 @@ const WorkersPage: NextPageWithLayout = () => { isPending, isError, isSuccess, + isFetching, + refetch, } = useQuery(workersQueryOptions({ projectRef: ref })) const isNotEnrolled = isError && isWorkersUnavailable(error) @@ -74,7 +78,22 @@ const WorkersPage: NextPageWithLayout = () => { /> )} {isMissingPermission && } - {isUnexpectedError && } + {isUnexpectedError && ( + } + loading={isFetching} + onClick={() => refetch()} + > + Refresh + + } + /> + )} {isSuccess && workers.length === 0 && ( setIsDeployInstructionsOpen(true)} /> )} @@ -83,6 +102,8 @@ const WorkersPage: NextPageWithLayout = () => { projectRef={ref} workers={workers} onDeploy={() => setIsDeployInstructionsOpen(true)} + onRefresh={() => refetch()} + isRefreshing={isFetching} /> )} diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index d4fd8a846910e..a2c7b08930896 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -1899,6 +1899,16 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild bucket, ]) + // [Monica] The effect above only refreshes `selectedBucket` when the project changes, so + // editing the current bucket (e.g. toggling public/private) doesn't update it there. This + // keeps `selectedBucket` synced to the bucket query on every change, so Get URL always + // uses the current public/private state instead of a stale one from initial load. + useEffect(() => { + if (bucket && state.projectRef === project?.ref) { + state.selectedBucket = bucket + } + }, [bucket, project?.ref, state.projectRef]) + return ( {children} diff --git a/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx b/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx index a30ff8ba80bd2..72111ae36b9ea 100644 --- a/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx +++ b/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx @@ -1,5 +1,5 @@ import { QueryClient } from '@tanstack/react-query' -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import type { components } from 'api-types' import { HttpResponse } from 'msw' import { beforeEach, describe, expect, it } from 'vitest' @@ -90,6 +90,17 @@ describe('/project/[ref]/workers', () => { expect(screen.queryByRole('table')).not.toBeInTheDocument() }) + it('refreshes the workers list on request', async () => { + mockWorkersList([workerDatum('existing')]) + + await renderWorkersPage() + + mockWorkersList([workerDatum('embed')]) + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + + expect(await screen.findByRole('link', { name: 'embed' })).toBeVisible() + }) + it('explains that a project outside the alpha is not enrolled', async () => { mockWorkersListFailure(404) @@ -109,4 +120,25 @@ describe('/project/[ref]/workers', () => { ).toBeVisible() expect(screen.queryByRole('table')).not.toBeInTheDocument() }) + + it('allows retrying an unexpected error', async () => { + let requestCount = 0 + addAPIMock({ + method: 'get', + path: '/v2/projects/:ref/workers', + response: (): HttpResponse | HttpResponse => { + if (requestCount++ === 0) { + return HttpResponse.json({ message: 'Unavailable' }, { status: 500 }) + } + + return HttpResponse.json({ data: [workerDatum('embed')] }) + }, + }) + + await renderWorkersPage() + + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + + expect(await screen.findByRole('link', { name: 'embed' })).toBeVisible() + }) }) diff --git a/apps/www/.env.local.example b/apps/www/.env.local.example index 517bb89bd7b04..0c5e2947a5304 100644 --- a/apps/www/.env.local.example +++ b/apps/www/.env.local.example @@ -10,6 +10,7 @@ NEXT_PUBLIC_DOCS_URL=http://localhost:3005 NEXT_PUBLIC_ENVIRONMENT=local NEXT_PUBLIC_HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001 NEXT_PUBLIC_IS_PLATFORM=true +NEXT_PUBLIC_KB_URL=http://localhost:3008/kb NEXT_PUBLIC_MARKETPLACE_API_URL=https://fgxbxpvumhvzrhqngsyu.supabase.co NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY=sb_publishable_VuF5ZvGqj6ODhZgN1J_vMw_YbiEs1R6 NEXT_PUBLIC_MISC_USE_ANON_KEY=sb_publishable_t45SVhgymMJOuamUXzJzPQ_sY-tSoUr diff --git a/apps/www/lib/rewrites.js b/apps/www/lib/rewrites.js index c11c0129b135f..6c7aba2f384c9 100644 --- a/apps/www/lib/rewrites.js +++ b/apps/www/lib/rewrites.js @@ -39,6 +39,14 @@ const rewrites = [ source: '/design-system/:path*', destination: `${process.env.NEXT_PUBLIC_DESIGN_SYSTEM_URL}/:path*`, }, + { + source: '/kb', + destination: `${process.env.NEXT_PUBLIC_KB_URL}`, + }, + { + source: '/kb/:path*', + destination: `${process.env.NEXT_PUBLIC_KB_URL}/:path*`, + }, { source: '/evals', destination: 'https://supabase-evals.vercel.app', diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc index ea5a9af80f3b1..09078f1751445 100644 --- a/apps/www/turbo.jsonc +++ b/apps/www/turbo.jsonc @@ -21,6 +21,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_REFERENCE_DOCS_URL", "NEXT_PUBLIC_LIBRARY_URL", + "NEXT_PUBLIC_KB_URL", // Temporary fallback during the production environment-variable migration. "NEXT_PUBLIC_UI_LIBRARY_URL", "NEXT_PUBLIC_SUPABASE_URL", diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 2b7ff3134ac4b..dbe916ee052e7 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -50,8 +50,7 @@ export interface SignUpEvent { * * Some unintuitive behavior: * - If signing up with GitHub the SignInEvent gets triggered first before the SignUpEvent. - * - Captured server-side; the distinct_id often resolves to the anonymous cookie because - * the event races identify, so don't use it as a funnel join key across the auth boundary. + * - distinct_id often resolves to the anonymous cookie (races identify); not a person-level join key. * * @group Events * @source studio @@ -68,6 +67,26 @@ export interface SignInEvent { } } +/** + * Triggered when a user initiates a sign-in (form submit including client-side validation + * failures, OAuth or custom-provider click, partner token exchange), before auth resolves. + * Pre-auth, so distinct_id is the anonymous cookie: not a person-level join key. + * + * @group Events + * @source studio + * @page /sign-in, /sign-in-sso, /sign-in-partner + */ +export interface SignInSubmittedEvent { + action: 'sign_in_submitted' + properties: { + category: 'account' + /** + * Matches the sign_in event's method vocabulary, e.g. email (password path), github, sso + */ + method: string + } +} + /** * User copied the database connection string. * @@ -1568,6 +1587,32 @@ export interface ExplorerBannerCtaButtonClickedEvent { groups: TelemetryGroups } +/** + * User clicked the button in the Explorer sidebar title bar to temporarily switch to the SQL + * Editor for snippet access. + * + * @group Events + * @source studio + * @page /project/{ref}/explorer + */ +export interface ExplorerTempAccessSqlEditorClickedEvent { + action: 'explorer_temp_access_sql_editor_clicked' + groups: TelemetryGroups +} + +/** + * User clicked the "Back to Explorer" button in the SQL Editor title bar, shown only when the + * visit originated from the Explorer's temporary switch button. + * + * @group Events + * @source studio + * @page /project/{ref}/sql + */ +export interface SqlEditorBackExplorerClickedEvent { + action: 'sql_editor_back_explorer_clicked' + groups: TelemetryGroups +} + /** * User clicked a metric card PID in the Overview panel of the Database Connections observability page, selecting it in the activity table below. * @@ -3176,7 +3221,7 @@ export interface DashboardErrorCreatedEvent { /** * Funnel the error occurred in (set only for instrumented funnel errors) */ - origin?: 'signup' | 'project_creation' | 'org_creation' + origin?: 'signup' | 'signin' | 'project_creation' | 'org_creation' /** * Coarse classification of the funnel error */ @@ -3772,6 +3817,7 @@ export interface HeaderLocalVersionPopoverOpenedEvent { export type TelemetryEvent = | SignUpEvent | SignInEvent + | SignInSubmittedEvent | ConnectionStringCopiedEvent | McpInstallButtonClickedEvent | ApiDocsOpenedEvent @@ -3871,6 +3917,8 @@ export type TelemetryEvent = | ExplorerBannerExposedEvent | ExplorerBannerDismissButtonClickedEvent | ExplorerBannerCtaButtonClickedEvent + | ExplorerTempAccessSqlEditorClickedEvent + | SqlEditorBackExplorerClickedEvent | SessionTerminateButtonClickedEvent | SessionTerminateSubmittedEvent | QueryCancelButtonClickedEvent diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 4e8e13aa9f897..f3cce2f62862f 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -428,8 +428,16 @@ export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathn } } + // keepalive lets the request survive the same-tick OAuth redirect after + // sign_in_submitted, but keepalive requests share a ~64KB in-flight quota + // page-wide, so it stays scoped to that event. Callers like useTrack + // fire-and-forget, so rejections are handled here rather than surfacing + // as unhandled promise rejections. return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, { headers: { Version: '2' }, + keepalive: event.action === 'sign_in_submitted', + }).catch((error) => { + console.error('Problem sending telemetry event:', error) }) }